diff --git a/.cursor/rules/build-and-deployment.mdc b/.cursor/rules/build-and-deployment.mdc new file mode 100644 index 000000000000..ef95cc6b9839 --- /dev/null +++ b/.cursor/rules/build-and-deployment.mdc @@ -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 diff --git a/.cursor/rules/cache-optimization.mdc b/.cursor/rules/cache-optimization.mdc new file mode 100644 index 000000000000..06f780df730e --- /dev/null +++ b/.cursor/rules/cache-optimization.mdc @@ -0,0 +1,414 @@ +--- +description: Caching rules for performance improvements +globs: +alwaysApply: false +--- +# Cache Optimization Patterns for Formbricks + +## Cache Strategy Overview + +Formbricks uses a **hybrid caching approach** optimized for enterprise scale: + +- **Redis** for persistent cross-request caching +- **React `cache()`** for request-level deduplication +- **NO Next.js `unstable_cache()`** - avoid for reliability + +## Key Files + +### Core Cache Infrastructure +- [apps/web/modules/cache/lib/service.ts](mdc:apps/web/modules/cache/lib/service.ts) - Redis cache service +- [apps/web/modules/cache/lib/withCache.ts](mdc:apps/web/modules/cache/lib/withCache.ts) - Cache wrapper utilities +- [apps/web/modules/cache/lib/cacheKeys.ts](mdc:apps/web/modules/cache/lib/cacheKeys.ts) - Enterprise cache key patterns and utilities + +### Environment State Caching (Critical Endpoint) +- [apps/web/app/api/v1/client/[environmentId]/environment/route.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/route.ts) - Main endpoint serving hundreds of thousands of SDK clients +- [apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts) - Optimized data layer with caching + +## Enterprise-Grade Cache Key Patterns + +**Always use** the `createCacheKey` utilities from [cacheKeys.ts](mdc:apps/web/modules/cache/lib/cacheKeys.ts): + +```typescript +// ✅ Correct patterns +createCacheKey.environment.state(environmentId) // "fb:env:abc123:state" +createCacheKey.organization.billing(organizationId) // "fb:org:xyz789:billing" +createCacheKey.license.status(organizationId) // "fb:license:org123:status" +createCacheKey.user.permissions(userId, orgId) // "fb:user:456:org:123:permissions" + +// ❌ Never use flat keys - collision-prone +"environment_abc123" +"user_data_456" +``` + +## When to Use Each Cache Type + +### Use React `cache()` for Request Deduplication +```typescript +// ✅ Prevents multiple calls within same request +export const getEnterpriseLicense = reactCache(async () => { + // Complex license validation logic +}); +``` + +### Use `withCache()` for Simple Database Queries +```typescript +// ✅ Simple caching with automatic fallback (TTL in milliseconds) +export const getActionClasses = (environmentId: string) => { + return withCache(() => fetchActionClassesFromDB(environmentId), { + key: createCacheKey.environment.actionClasses(environmentId), + ttl: 60 * 30 * 1000, // 30 minutes in milliseconds + })(); +}; +``` + +### Use Explicit Redis Cache for Complex Business Logic +```typescript +// ✅ Full control for high-stakes endpoints +export const getEnvironmentState = async (environmentId: string) => { + const cached = await environmentStateCache.getEnvironmentState(environmentId); + if (cached) return cached; + + const fresh = await buildComplexState(environmentId); + await environmentStateCache.setEnvironmentState(environmentId, fresh); + return fresh; +}; +``` + +## Caching Decision Framework + +### When TO Add Caching + +```typescript +// ✅ Expensive operations that benefit from caching +- Database queries (>10ms typical) +- External API calls (>50ms typical) +- Complex computations (>5ms) +- File system operations +- Heavy data transformations + +// Example: Database query with complex joins (TTL in milliseconds) +export const getEnvironmentWithDetails = withCache( + async (environmentId: string) => { + return prisma.environment.findUnique({ + where: { id: environmentId }, + include: { /* complex joins */ } + }); + }, + { key: createCacheKey.environment.details(environmentId), ttl: 60 * 30 * 1000 } // 30 minutes +)(); +``` + +### When NOT to Add Caching + +```typescript +// ❌ Don't cache these operations - minimal overhead +- Simple property access (<0.1ms) +- Basic transformations (<1ms) +- Functions that just call already-cached functions +- Pure computation without I/O + +// ❌ Bad example: Redundant caching +const getCachedLicenseFeatures = withCache( + async () => { + const license = await getEnterpriseLicense(); // Already cached! + return license.active ? license.features : null; // Just property access + }, + { key: "license-features", ttl: 1800 * 1000 } // 30 minutes in milliseconds +); + +// ✅ Good example: Simple and efficient +const getLicenseFeatures = async () => { + const license = await getEnterpriseLicense(); // Already cached + return license.active ? license.features : null; // 0.1ms overhead +}; +``` + +### Computational Overhead Analysis + +Before adding caching, analyze the overhead: + +```typescript +// ✅ High overhead - CACHE IT +- Database queries: ~10-100ms +- External APIs: ~50-500ms +- File I/O: ~5-50ms +- Complex algorithms: >5ms + +// ❌ Low overhead - DON'T CACHE +- Property access: ~0.001ms +- Simple lookups: ~0.1ms +- Basic validation: ~1ms +- Type checks: ~0.01ms + +// Example decision tree: +const expensiveOperation = async () => { + return prisma.query(); // 50ms - CACHE IT +}; + +const cheapOperation = (data: any) => { + return data.property; // 0.001ms - DON'T CACHE +}; +``` + +### Avoid Cache Wrapper Anti-Pattern + +```typescript +// ❌ Don't create wrapper functions just for caching +const getCachedUserPermissions = withCache( + async (userId: string) => getUserPermissions(userId), + { key: createCacheKey.user.permissions(userId), ttl: 3600 * 1000 } // 1 hour in milliseconds +); + +// ✅ Add caching directly to the original function +export const getUserPermissions = withCache( + async (userId: string) => { + return prisma.user.findUnique({ + where: { id: userId }, + include: { permissions: true } + }); + }, + { key: createCacheKey.user.permissions(userId), ttl: 3600 * 1000 } // 1 hour in milliseconds +); +``` + +## TTL Coordination Strategy + +### Multi-Layer Cache Coordination +For endpoints serving client SDKs, coordinate TTLs across layers: + +```typescript +// Client SDK cache (expiresAt) - longest TTL for fewer requests +const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client) + +// Server Redis cache - shorter TTL ensures fresh data for clients +const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds + +// HTTP cache headers (seconds) +const BROWSER_TTL = 60 * 60; // 1 hour (max-age) +const CDN_TTL = 60 * 30; // 30 minutes (s-maxage) +const CORS_TTL = 60 * 60; // 1 hour (balanced approach) +``` + +### Standard TTL Guidelines (in milliseconds for cache-manager + Keyv) +```typescript +// Configuration data - rarely changes +const CONFIG_TTL = 60 * 60 * 24 * 1000; // 24 hours + +// User data - moderate frequency +const USER_TTL = 60 * 60 * 2 * 1000; // 2 hours + +// Survey data - changes moderately +const SURVEY_TTL = 60 * 15 * 1000; // 15 minutes + +// Billing data - expensive to compute +const BILLING_TTL = 60 * 30 * 1000; // 30 minutes + +// Action classes - infrequent changes +const ACTION_CLASS_TTL = 60 * 30 * 1000; // 30 minutes +``` + +## High-Frequency Endpoint Optimization + +### Performance Patterns for High-Volume Endpoints + +```typescript +// ✅ Optimized high-frequency endpoint pattern +export const GET = async (request: NextRequest, props: { params: Promise<{ id: string }> }) => { + const params = await props.params; + + try { + // Simple validation (avoid Zod for high-frequency) + if (!params.id || typeof params.id !== 'string') { + return responses.badRequestResponse("ID is required", undefined, true); + } + + // Single optimized query with caching + const data = await getOptimizedData(params.id); + + return responses.successResponse( + { + data, + expiresAt: new Date(Date.now() + CLIENT_TTL * 1000), // SDK cache duration + }, + true, + "public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600" + ); + } catch (err) { + // Simplified error handling for performance + if (err instanceof ResourceNotFoundError) { + return responses.notFoundResponse(err.resourceType, err.resourceId); + } + logger.error({ error: err, url: request.url }, "Error in high-frequency endpoint"); + return responses.internalServerErrorResponse(err.message, true); + } +}; +``` + +### Avoid These Performance Anti-Patterns + +```typescript +// ❌ Avoid for high-frequency endpoints +const inputValidation = ZodSchema.safeParse(input); // Too slow +const startTime = Date.now(); logger.debug(...); // Logging overhead +const { data, revalidateEnvironment } = await get(); // Complex return types +``` + +### CORS Optimization +```typescript +// ✅ Balanced CORS caching (not too aggressive) +export const OPTIONS = async (): Promise => { + return responses.successResponse( + {}, + true, + "public, s-maxage=3600, max-age=3600" // 1 hour balanced approach + ); +}; +``` + +## Redis Cache Migration from Next.js + +### Avoid Legacy Next.js Patterns +```typescript +// ❌ Old Next.js unstable_cache pattern (avoid) +const getCachedData = unstable_cache( + async (id) => fetchData(id), + ['cache-key'], + { tags: ['environment'], revalidate: 900 } +); + +// ❌ Don't use revalidateEnvironment flags with Redis +return { data, revalidateEnvironment: true }; // This gets cached incorrectly! + +// ✅ New Redis pattern with withCache (TTL in milliseconds) +export const getCachedData = (id: string) => + withCache( + () => fetchData(id), + { + key: createCacheKey.environment.data(id), + ttl: 60 * 15 * 1000, // 15 minutes in milliseconds + } + )(); +``` + +### Remove Revalidation Logic +When migrating from Next.js `unstable_cache`: +- Remove `revalidateEnvironment` or similar flags +- Remove tag-based invalidation logic +- Use TTL-based expiration instead +- Handle one-time updates (like `appSetupCompleted`) directly in cache + +## Data Layer Optimization + +### Single Query Pattern +```typescript +// ✅ Optimize with single database query +export const getOptimizedEnvironmentData = async (environmentId: string) => { + return prisma.environment.findUniqueOrThrow({ + where: { id: environmentId }, + include: { + project: { + select: { id: true, recontactDays: true, /* ... */ } + }, + organization: { + select: { id: true, billing: true } + }, + surveys: { + where: { status: "inProgress" }, + select: { id: true, name: true, /* ... */ } + }, + actionClasses: { + select: { id: true, name: true, /* ... */ } + } + } + }); +}; + +// ❌ Avoid multiple separate queries +const environment = await getEnvironment(id); +const organization = await getOrganization(environment.organizationId); +const surveys = await getSurveys(id); +const actionClasses = await getActionClasses(id); +``` + +## Invalidation Best Practices + +**Always use explicit key-based invalidation:** + +```typescript +// ✅ Clear and debuggable +await invalidateCache(createCacheKey.environment.state(environmentId)); +await invalidateCache([ + createCacheKey.environment.surveys(environmentId), + createCacheKey.environment.actionClasses(environmentId) +]); + +// ❌ Avoid complex tag systems +await invalidateByTags(["environment", "survey"]); // Don't do this +``` + +## Critical Performance Targets + +### High-Frequency Endpoint Goals +- **Cache hit ratio**: >85% +- **Response time P95**: <200ms +- **Database load reduction**: >60% +- **HTTP cache duration**: 1hr browser, 30min Cloudflare +- **SDK refresh interval**: 1 hour with 30min server cache + +### Performance Monitoring +- Use **existing elastic cache analytics** for metrics +- Log cache errors and warnings (not debug info) +- Track database query reduction +- Monitor response times for cached endpoints +- **Avoid performance logging** in high-frequency endpoints + +## Error Handling Pattern + +Always provide fallback to fresh data on cache errors: + +```typescript +try { + const cached = await cache.get(key); + if (cached) return cached; + + const fresh = await fetchFresh(); + await cache.set(key, fresh, ttl); // ttl in milliseconds + return fresh; +} catch (error) { + // ✅ Always fallback to fresh data + logger.warn("Cache error, fetching fresh", { key, error }); + return fetchFresh(); +} +``` + +## Common Pitfalls to Avoid + +1. **Never use Next.js `unstable_cache()`** - unreliable in production +2. **Don't use revalidation flags with Redis** - they get cached incorrectly +3. **Avoid Zod validation** for simple parameters in high-frequency endpoints +4. **Don't add performance logging** to high-frequency endpoints +5. **Coordinate TTLs** between client and server caches +6. **Don't over-engineer** with complex tag systems +7. **Avoid caching rapidly changing data** (real-time metrics) +8. **Always validate cache keys** to prevent collisions +9. **Don't add redundant caching layers** - analyze computational overhead first +10. **Avoid cache wrapper functions** - add caching directly to expensive operations +11. **Don't cache property access or simple transformations** - overhead is negligible +12. **Analyze the full call chain** before adding caching to avoid double-caching +13. **Remember TTL is in milliseconds** for cache-manager + Keyv stack (not seconds) + +## Monitoring Strategy + +- Use **existing elastic cache analytics** for metrics +- Log cache errors and warnings +- Track database query reduction +- Monitor response times for cached endpoints +- **Don't add custom metrics** that duplicate existing monitoring + +## Important Notes + +### TTL Units +- **cache-manager + Keyv**: TTL in **milliseconds** +- **Direct Redis commands**: TTL in **seconds** (EXPIRE, SETEX) or **milliseconds** (PEXPIRE, PSETEX) +- **HTTP cache headers**: TTL in **seconds** (max-age, s-maxage) +- **Client SDK**: TTL in **seconds** (expiresAt calculation) diff --git a/.cursor/rules/database-performance.mdc b/.cursor/rules/database-performance.mdc new file mode 100644 index 000000000000..5cd41ee3dd93 --- /dev/null +++ b/.cursor/rules/database-performance.mdc @@ -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 diff --git a/.cursor/rules/database.mdc b/.cursor/rules/database.mdc new file mode 100644 index 000000000000..809949a2a43b --- /dev/null +++ b/.cursor/rules/database.mdc @@ -0,0 +1,101 @@ +--- +description: > + This rule provides comprehensive knowledge about the Formbricks database structure, relationships, + and data patterns. It should be used **only when the agent explicitly requests database schema-level + details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models, + investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships. +globs: [] +alwaysApply: agent-requested +--- +# Formbricks Database Schema Reference + +This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly. + +## Database Overview + +Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations. + +### Core Hierarchy +``` +Organization +└── Project + └── Environment (production/development) + ├── Survey + ├── Contact + ├── ActionClass + └── Integration +``` + +## Schema Reference + +For the complete and up-to-date database schema, please refer to: +- Main schema: `packages/database/schema.prisma` +- JSON type definitions: `packages/database/json-types.ts` + +The schema.prisma file contains all model definitions, relationships, enums, and field types. The json-types.ts file contains TypeScript type definitions for JSON fields. + +## Data Access Patterns + +### Multi-tenancy +- All data is scoped by Organization +- Environment-level isolation for surveys and contacts +- Project-level grouping for related surveys + +### Soft Deletion +Some models use soft deletion patterns: +- Check `isActive` fields where present +- Use proper filtering in queries + +### Cascading Deletes +Configured cascade relationships: +- Organization deletion cascades to all child entities +- Survey deletion removes responses, displays, triggers +- Contact deletion removes attributes and responses + +## Common Query Patterns + +### Survey with Responses +```typescript +// Include response count and latest responses +const survey = await prisma.survey.findUnique({ + where: { id: surveyId }, + include: { + responses: { + take: 10, + orderBy: { createdAt: 'desc' } + }, + _count: { + select: { responses: true } + } + } +}); +``` + +### Environment Scoping +```typescript +// Always scope by environment +const surveys = await prisma.survey.findMany({ + where: { + environmentId: environmentId, + // Additional filters... + } +}); +``` + +### Contact with Attributes +```typescript +const contact = await prisma.contact.findUnique({ + where: { id: contactId }, + include: { + attributes: { + include: { + attributeKey: true + } + } + } +}); +``` + +This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security. + + diff --git a/.cursor/rules/documentations.mdc b/.cursor/rules/documentations.mdc new file mode 100644 index 000000000000..4754876c25a5 --- /dev/null +++ b/.cursor/rules/documentations.mdc @@ -0,0 +1,23 @@ +--- +description: Guideline for writing end-user facing documentation in the apps/docs folder +globs: +alwaysApply: false +--- +Follow these instructions and guidelines when asked to write documentation in the apps/docs folder + +Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file: + +--- +title: "FEATURE NAME" +description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT." +icon: "link" +--- + +- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT. +- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt +- In all Headlines, only capitalize the current feature and nothing else, to Camel Case +- If a feature is part of the Enterprise Edition, use this note: + + + FEATURE NAME is part of the @Enterprise Edition. + \ No newline at end of file diff --git a/.cursor/rules/eks-alb-optimization.mdc b/.cursor/rules/eks-alb-optimization.mdc new file mode 100644 index 000000000000..c577e9140c0d --- /dev/null +++ b/.cursor/rules/eks-alb-optimization.mdc @@ -0,0 +1,152 @@ +--- +description: +globs: +alwaysApply: false +--- +# EKS & ALB Optimization Guide for Error Reduction + +## Infrastructure Overview + +This project uses AWS EKS with Application Load Balancer (ALB) for the Formbricks application. The infrastructure has been optimized to minimize ELB 502/504 errors through careful configuration of connection handling, health checks, and pod lifecycle management. + +## Key Infrastructure Files + +### Terraform Configuration +- **Main Infrastructure**: [infra/terraform/main.tf](mdc:infra/terraform/main.tf) - EKS cluster, VPC, Karpenter, and core AWS resources +- **Monitoring**: [infra/terraform/cloudwatch.tf](mdc:infra/terraform/cloudwatch.tf) - CloudWatch alarms for 502/504 error tracking and alerting +- **Database**: [infra/terraform/rds.tf](mdc:infra/terraform/rds.tf) - Aurora PostgreSQL configuration + +### Helm Configuration +- **Production**: [infra/formbricks-cloud-helm/values.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values.yaml.gotmpl) - Optimized ALB and pod configurations +- **Staging**: [infra/formbricks-cloud-helm/values-staging.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values-staging.yaml.gotmpl) - Staging environment with spot instances +- **Deployment**: [infra/formbricks-cloud-helm/helmfile.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/helmfile.yaml.gotmpl) - Multi-environment Helm releases + +## ALB Optimization Patterns + +### Connection Handling Optimizations +```yaml +# Key ALB annotations for reducing 502/504 errors +alb.ingress.kubernetes.io/load-balancer-attributes: | + idle_timeout.timeout_seconds=120, + connection_logs.s3.enabled=false, + access_logs.s3.enabled=false + +alb.ingress.kubernetes.io/target-group-attributes: | + deregistration_delay.timeout_seconds=30, + stickiness.enabled=false, + load_balancing.algorithm.type=least_outstanding_requests, + target_group_health.dns_failover.minimum_healthy_targets.count=1 +``` + +### Health Check Configuration +- **Interval**: 15 seconds for faster detection of unhealthy targets +- **Timeout**: 5 seconds to prevent false positives +- **Thresholds**: 2 healthy, 3 unhealthy for balanced responsiveness +- **Path**: `/health` endpoint optimized for < 100ms response time + +## Pod Lifecycle Management + +### Graceful Shutdown Pattern +```yaml +# PreStop hook to allow connection draining +lifecycle: + preStop: + exec: + command: ["/bin/sh", "-c", "sleep 15"] + +# Termination grace period for complete cleanup +terminationGracePeriodSeconds: 45 +``` + +### Health Probe Strategy +- **Startup Probe**: 5s initial delay, 5s interval, max 60s startup time +- **Readiness Probe**: 10s delay, 10s interval for traffic readiness +- **Liveness Probe**: 30s delay, 30s interval for container health + +### Rolling Update Configuration +```yaml +strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 25% # Maintain capacity during updates + maxSurge: 50% # Allow faster rollouts +``` + +## Karpenter Node Management + +### Node Lifecycle Optimization +- **Startup Taints**: Prevent traffic during node initialization +- **Graceful Shutdown**: 30s grace period for pod eviction +- **Consolidation Delay**: 60s to reduce unnecessary churn +- **Eviction Policies**: Configured for smooth pod migrations + +### Instance Selection +- **Families**: c8g, c7g, m8g, m7g, r8g, r7g (ARM64 Graviton) +- **Sizes**: 2, 4, 8 vCPUs for cost optimization +- **Bottlerocket AMI**: Enhanced security and performance + +## Monitoring & Alerting + +### Critical ALB Metrics +1. **ELB 502 Errors**: Threshold 20 over 5 minutes +2. **ELB 504 Errors**: Threshold 15 over 5 minutes +3. **Target Connection Errors**: Threshold 50 over 5 minutes +4. **4XX Errors**: Threshold 100 over 10 minutes (client issues) + +### Expected Improvements +- **60-80% reduction** in ELB 502 errors +- **Faster recovery** during pod restarts +- **Better connection reuse** efficiency +- **Improved autoscaling** responsiveness + +## Deployment Patterns + +### Infrastructure Updates +1. **Terraform First**: Apply infrastructure changes via [infra/deploy-improvements.sh](mdc:infra/deploy-improvements.sh) +2. **Helm Second**: Deploy application configurations +3. **Verification**: Check pod status, endpoints, and ALB health +4. **Monitoring**: Watch CloudWatch metrics for 24-48 hours + +### Environment-Specific Configurations +- **Production**: On-demand instances, stricter resource limits +- **Staging**: Spot instances, rate limiting disabled, relaxed resources + +## Troubleshooting Patterns + +### 502 Error Investigation +1. Check pod readiness and health probe status +2. Verify ALB target group health +3. Review deregistration timing during deployments +4. Monitor connection pool utilization + +### 504 Error Analysis +1. Check application response times +2. Verify timeout configurations (ALB: 120s, App: aligned) +3. Review database query performance +4. Monitor resource utilization during traffic spikes + +### Connection Error Patterns +1. Verify Karpenter node lifecycle timing +2. Check pod termination grace periods +3. Review ALB connection draining settings +4. Monitor cluster autoscaling events + +## Best Practices + +### When Making Changes +- **Test in staging first** with same configurations +- **Monitor metrics** for 24-48 hours after changes +- **Use gradual rollouts** with proper health checks +- **Maintain ALB timeout alignment** across all layers + +### Performance Optimization +- **Health endpoint** should respond < 100ms consistently +- **Connection pooling** aligned with ALB idle timeouts +- **Resource requests/limits** tuned for consistent performance +- **Graceful shutdown** implemented in application code + +### Monitoring Strategy +- **Real-time alerts** for error rate spikes +- **Trend analysis** for connection patterns +- **Capacity planning** based on LCU usage +- **4XX pattern analysis** for client behavior insights diff --git a/.cursor/rules/formbricks-architecture.mdc b/.cursor/rules/formbricks-architecture.mdc new file mode 100644 index 000000000000..c0bec2e71867 --- /dev/null +++ b/.cursor/rules/formbricks-architecture.mdc @@ -0,0 +1,332 @@ +--- +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 +├── 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 + +## 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 ( + + {children} + + ); +}; + +// 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 ( + + + {children} + + + ); +} +``` + +## 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 ( +
+ + +
+ ); +} +``` + +### 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 diff --git a/.cursor/rules/github-actions-security.mdc b/.cursor/rules/github-actions-security.mdc new file mode 100644 index 000000000000..8da9a0e8053e --- /dev/null +++ b/.cursor/rules/github-actions-security.mdc @@ -0,0 +1,232 @@ +--- +description: Security best practices and guidelines for writing GitHub Actions and workflows +globs: .github/workflows/*.yml,.github/workflows/*.yaml,.github/actions/*/action.yml,.github/actions/*/action.yaml +--- + +# GitHub Actions Security Best Practices + +## Required Security Measures + +### 1. Set Minimum GITHUB_TOKEN Permissions + +Always explicitly set the minimum required permissions for GITHUB_TOKEN: + +```yaml +permissions: + contents: read + # Only add additional permissions if absolutely necessary: + # pull-requests: write # for commenting on PRs + # issues: write # for creating/updating issues + # checks: write # for publishing check results +``` + +### 2. Add Harden-Runner as First Step + +For **every job** on `ubuntu-latest`, add Harden-Runner as the first step: + +```yaml +- name: Harden the runner + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit # or 'block' for stricter security +``` + +### 3. Pin Actions to Full Commit SHA + +**Always** pin third-party actions to their full commit SHA, not tags: + +```yaml +# ❌ BAD - uses mutable tag +- uses: actions/checkout@v4 + +# ✅ GOOD - pinned to immutable commit SHA +- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 +``` + +### 4. Secure Variable Handling + +Prevent command injection by properly quoting variables: + +```yaml +# ❌ BAD - potential command injection +run: echo "Processing ${{ inputs.user_input }}" + +# ✅ GOOD - properly quoted +env: + USER_INPUT: ${{ inputs.user_input }} +run: echo "Processing ${USER_INPUT}" +``` + +Use `${VARIABLE}` syntax in shell scripts instead of `$VARIABLE`. + +### 5. Environment Variables for Secrets + +Store sensitive data in environment variables, not inline: + +```yaml +# ❌ BAD +run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" api.example.com + +# ✅ GOOD +env: + API_TOKEN: ${{ secrets.TOKEN }} +run: curl -H "Authorization: Bearer ${API_TOKEN}" api.example.com +``` + +## Workflow Structure Best Practices + +### Required Workflow Elements + +```yaml +name: "Descriptive Workflow Name" + +on: + # Define specific triggers + push: + branches: [main] + pull_request: + branches: [main] + +# Always set explicit permissions +permissions: + contents: read + +jobs: + job-name: + name: "Descriptive Job Name" + runs-on: ubuntu-latest + timeout-minutes: 30 # tune per job; standardize repo-wide + + # Set job-level permissions if different from workflow level + permissions: + contents: read + + steps: + # Always start with Harden-Runner on ubuntu-latest + - name: Harden the runner + uses: step-security/harden-runner@v2 + with: + egress-policy: audit + + # Pin all actions to commit SHA + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 +``` + +### Input Validation for Actions + +For composite actions, always validate inputs: + +```yaml +inputs: + user_input: + description: "User provided input" + required: true + +runs: + using: "composite" + steps: + - name: Validate input + shell: bash + run: | + # Harden shell and validate input format/content before use + set -euo pipefail + + USER_INPUT="${{ inputs.user_input }}" + + if [[ ! "${USER_INPUT}" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "❌ Invalid input format" + exit 1 + fi +``` + +## Docker Security in Actions + +### Pin Docker Images to Digests + +```yaml +# ❌ BAD - mutable tag +container: node:18 + +# ✅ GOOD - pinned to digest +container: node:18@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d6a37b82dfe1604c4c09cad +``` + +## Common Patterns + +### Secure File Operations + +```yaml +- name: Process files securely + shell: bash + env: + FILE_PATH: ${{ inputs.file_path }} + run: | + set -euo pipefail # Fail on errors, undefined vars, pipe failures + + # Use absolute paths and validate + SAFE_PATH=$(realpath "${FILE_PATH}") + if [[ "$SAFE_PATH" != "${GITHUB_WORKSPACE}"/* ]]; then + echo "❌ Path outside workspace" + exit 1 + fi +``` + +### Artifact Handling + +```yaml +- name: Upload artifacts securely + uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + with: + name: build-artifacts + path: | + dist/ + !dist/**/*.log # Exclude sensitive files + retention-days: 30 +``` + +### GHCR authentication for pulls/scans + +```yaml +# Minimal permissions required for GHCR pulls/scans +permissions: + contents: read + packages: read + +steps: + - name: Log in to GitHub Container Registry + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} +``` + +## Security Checklist + +- [ ] Minimum GITHUB_TOKEN permissions set +- [ ] Harden-Runner added to all ubuntu-latest jobs +- [ ] All third-party actions pinned to commit SHA +- [ ] Input validation implemented for custom actions +- [ ] Variables properly quoted in shell scripts +- [ ] Secrets stored in environment variables +- [ ] Docker images pinned to digests (if used) +- [ ] Error handling with `set -euo pipefail` +- [ ] File paths validated and sanitized +- [ ] No sensitive data in logs or outputs +- [ ] GHCR login performed before pulls/scans (packages: read) +- [ ] Job timeouts configured (`timeout-minutes`) + +## Recommended Additional Workflows + +Consider adding these security-focused workflows to your repository: + +1. **CodeQL Analysis** - Static Application Security Testing (SAST) +2. **Dependency Review** - Scan for vulnerable dependencies in PRs +3. **Dependabot Configuration** - Automated dependency updates + +## Resources + +- [GitHub Security Hardening Guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions) +- [Step Security Harden-Runner](https://github.com/step-security/harden-runner) +- [Secure-Repo Best Practices](https://github.com/step-security/secure-repo) diff --git a/.cursor/rules/performance-optimization.mdc b/.cursor/rules/performance-optimization.mdc new file mode 100644 index 000000000000..b93c988bf44d --- /dev/null +++ b/.cursor/rules/performance-optimization.mdc @@ -0,0 +1,5 @@ +--- +description: +globs: +alwaysApply: false +--- diff --git a/.cursor/rules/react-context-patterns.mdc b/.cursor/rules/react-context-patterns.mdc new file mode 100644 index 000000000000..b5792901d167 --- /dev/null +++ b/.cursor/rules/react-context-patterns.mdc @@ -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( + + + +); +``` + +### 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 diff --git a/.cursor/rules/react-context-providers.mdc b/.cursor/rules/react-context-providers.mdc new file mode 100644 index 000000000000..b93c988bf44d --- /dev/null +++ b/.cursor/rules/react-context-providers.mdc @@ -0,0 +1,5 @@ +--- +description: +globs: +alwaysApply: false +--- diff --git a/.cursor/rules/storybook-component-migration.mdc b/.cursor/rules/storybook-component-migration.mdc new file mode 100644 index 000000000000..cfaa81950f07 --- /dev/null +++ b/.cursor/rules/storybook-component-migration.mdc @@ -0,0 +1,216 @@ +--- +description: Migrate deprecated UI components to a unified component +globs: +alwaysApply: false +--- +# Component Migration Automation Rule + +## Overview +This rule automates the migration of deprecated components to new component systems in React/TypeScript codebases. + +## Trigger +When the user requests component migration (e.g., "migrate [DeprecatedComponent] to [NewComponent]" or "component migration"). + +## Process + +### Step 1: Discovery and Planning +1. **Identify migration parameters:** + - Ask user for deprecated component name (e.g., "Modal") + - Ask user for new component name(s) (e.g., "Dialog") + - Ask for any components to exclude (e.g., "ModalWithTabs") + - Ask for specific import paths if needed + +2. **Scan codebase** for deprecated components: + - Search for `import.*[DeprecatedComponent]` patterns + - Exclude specified components that should not be migrated + - List all found components with file paths + - Present numbered list to user for confirmation + +### Step 2: Component-by-Component Migration +For each component, follow this exact sequence: + +#### 2.1 Component Migration +- **Import changes:** + - Ask user to provide the new import structure + - Example transformation pattern: + ```typescript + // FROM: + import { [DeprecatedComponent] } from "@/components/ui/[DeprecatedComponent]" + + // TO: + import { + [NewComponent], + [NewComponentPart1], + [NewComponentPart2], + // ... other parts + } from "@/components/ui/[NewComponent]" + ``` + +- **Props transformation:** + - Ask user for prop mapping rules (e.g., `open` → `open`, `setOpen` → `onOpenChange`) + - Ask for props to remove (e.g., `noPadding`, `closeOnOutsideClick`, `size`) + - Apply transformations based on user specifications + +- **Structure transformation:** + - Ask user for the new component structure pattern + - Apply the transformation maintaining all functionality + - Preserve all existing logic, state management, and event handlers + +#### 2.2 Wait for User Approval +- Present the migration changes +- Wait for explicit user approval before proceeding +- If rejected, ask for specific feedback and iterate +#### 2.3 Re-read and Apply Additional Changes +- Re-read the component file to capture any user modifications +- Apply any additional improvements the user made +- Ensure all changes are incorporated + +#### 2.4 Test File Updates +- **Find corresponding test file** (same name with `.test.tsx` or `.test.ts`) +- **Update test mocks:** + - Ask user for new component mock structure + - Replace old component mocks with new ones + - Example pattern: + ```typescript + // Add to test setup: + jest.mock("@/components/ui/[NewComponent]", () => ({ + [NewComponent]: ({ children, [props] }: any) => ([mock implementation]), + [NewComponentPart1]: ({ children }: any) =>
{children}
, + [NewComponentPart2]: ({ children }: any) =>
{children}
, + // ... other parts + })); + ``` +- **Update test expectations:** + - Change test IDs from old component to new component + - Update any component-specific assertions + - Ensure all new component parts used in the component are mocked + +#### 2.5 Run Tests and Optimize +- Execute `Node package manager test -- ComponentName.test.tsx` +- Fix any failing tests +- Optimize code quality (imports, formatting, etc.) +- Re-run tests until all pass +- **Maximum 3 iterations** - if still failing, ask user for guidance + +#### 2.6 Wait for Final Approval +- Present test results and any optimizations made +- Wait for user approval of the complete migration +- If rejected, iterate based on feedback + +#### 2.7 Git Commit +- Run: `git add .` +- Run: `git commit -m "migrate [ComponentName] from [DeprecatedComponent] to [NewComponent]"` +- Confirm commit was successful + +### Step 3: Final Report Generation +After all components are migrated, generate a comprehensive GitHub PR report: + +#### PR Title +``` +feat: migrate [DeprecatedComponent] components to [NewComponent] system +``` + +#### PR Description Template +```markdown +## 🔄 [DeprecatedComponent] to [NewComponent] Migration + +### Overview +Migrated [X] [DeprecatedComponent] components to the new [NewComponent] component system to modernize the UI architecture and improve consistency. + +### Components Migrated +[List each component with file path] + +### Technical Changes +- **Imports:** Replaced `[DeprecatedComponent]` with `[NewComponent], [NewComponentParts...]` +- **Props:** [List prop transformations] +- **Structure:** Implemented proper [NewComponent] component hierarchy +- **Styling:** [Describe styling changes] +- **Tests:** Updated all test mocks and expectations + +### Migration Pattern +```typescript +// Before +<[DeprecatedComponent] [oldProps]> + [oldStructure] + + +// After +<[NewComponent] [newProps]> + [newStructure] + +``` + +### Testing +- ✅ All existing tests updated and passing +- ✅ Component functionality preserved +- ✅ UI/UX behavior maintained + +### How to Test This PR +1. **Functional Testing:** + - Navigate to each migrated component's usage + - Verify [component] opens and closes correctly + - Test all interactive elements within [components] + - Confirm styling and layout are preserved + +2. **Automated Testing:** + ```bash + Node package manager test + ``` + +3. **Visual Testing:** + - Check that all [components] maintain proper styling + - Verify responsive behavior + - Test keyboard navigation and accessibility + +### Breaking Changes +[List any breaking changes or state "None - this is a drop-in replacement maintaining all existing functionality."] + +### Notes +- [Any excluded components] were preserved as they already use [NewComponent] internally +- All form validation and complex state management preserved +- Enhanced code quality with better imports and formatting +``` + +## Special Considerations + +### Excluded Components +- **DO NOT MIGRATE** components specified by user as exclusions +- They may already use the new component internally or have other reasons +- Inform user these are skipped and why + +### Complex Components +- Preserve all existing functionality (forms, validation, state management) +- Maintain prop interfaces +- Keep all event handlers and callbacks +- Preserve accessibility features + +### Test Coverage +- Ensure all new component parts are mocked when used +- Mock all new component parts that appear in the component +- Update test IDs from old component to new component +- Maintain all existing test scenarios + +### Error Handling +- If tests fail after 3 iterations, stop and ask user for guidance +- If component is too complex, ask user for specific guidance +- If unsure about functionality preservation, ask for clarification + +### Migration Patterns +- Always ask user for specific migration patterns before starting +- Confirm import structures, prop mappings, and component hierarchies +- Adapt to different component architectures (simple replacements, complex restructuring, etc.) + +## Success Criteria +- All deprecated components successfully migrated to new components +- All tests passing +- No functionality lost +- Code quality maintained or improved +- User approval on each component +- Successful git commits for each migration +- Comprehensive PR report generated + +## Usage Examples +- "migrate Modal to Dialog" +- "migrate Button to NewButton" +- "migrate Card to ModernCard" +- "component migration" (will prompt for details) diff --git a/.cursor/rules/storybook-create-new-story.mdc b/.cursor/rules/storybook-create-new-story.mdc new file mode 100644 index 000000000000..f77b5713c16e --- /dev/null +++ b/.cursor/rules/storybook-create-new-story.mdc @@ -0,0 +1,177 @@ +--- +description: Create a story in Storybook for a given component +globs: +alwaysApply: false +--- + +# Formbricks Storybook Stories + +## When generating Storybook stories for Formbricks components: + +### 1. **File Structure** +- Create `stories.tsx` (not `.stories.tsx`) in component directory +- Use exact import: `import { Meta, StoryObj } from "@storybook/react-vite";` +- Import component from `"./index"` + +### 2. **Story Structure Template** +```tsx +import { Meta, StoryObj } from "@storybook/react-vite"; +import { ComponentName } from "./index"; + +// For complex components with configurable options +// consider this as an example the options need to reflect the props types +interface StoryOptions { + showIcon: boolean; + numberOfElements: number; + customLabels: string[]; +} + +type StoryProps = React.ComponentProps & StoryOptions; + +const meta: Meta = { + title: "UI/ComponentName", + component: ComponentName, + tags: ["autodocs"], + parameters: { + layout: "centered", + controls: { sort: "alpha", exclude: [] }, + docs: { + description: { + component: "The **ComponentName** component provides [description].", + }, + }, + }, + argTypes: { + // Organize in exactly these categories: Behavior, Appearance, Content + }, +}; + +export default meta; +type Story = StoryObj & { args: StoryOptions }; +``` + +### 3. **ArgTypes Organization** +Organize ALL argTypes into exactly three categories: +- **Behavior**: disabled, variant, onChange, etc. +- **Appearance**: size, color, layout, styling, etc. +- **Content**: text, icons, numberOfElements, etc. + +Format: +```tsx +argTypes: { + propName: { + control: "select" | "boolean" | "text" | "number", + options: ["option1", "option2"], // for select + description: "Clear description", + table: { + category: "Behavior" | "Appearance" | "Content", + type: { summary: "string" }, + defaultValue: { summary: "default" }, + }, + order: 1, + }, +} +``` + +### 4. **Required Stories** +Every component must include: +- `Default`: Most common use case +- `Disabled`: If component supports disabled state +- `WithIcon`: If component supports icons +- Variant stories for each variant (Primary, Secondary, Error, etc.) +- Edge case stories (ManyElements, LongText, CustomStyling) + +### 5. **Story Format** +```tsx +export const Default: Story = { + args: { + // Props with realistic values + }, +}; + +export const EdgeCase: Story = { + args: { /* ... */ }, + parameters: { + docs: { + description: { + story: "Use this when [specific scenario].", + }, + }, + }, +}; +``` + +### 6. **Dynamic Content Pattern** +For components with dynamic content, create render function: +```tsx +const renderComponent = (args: StoryProps) => { + const { numberOfElements, showIcon, customLabels } = args; + + // Generate dynamic content + const elements = Array.from({ length: numberOfElements }, (_, i) => ({ + id: `element-${i}`, + label: customLabels[i] || `Element ${i + 1}`, + icon: showIcon ? : undefined, + })); + + return ; +}; + +export const Dynamic: Story = { + render: renderComponent, + args: { + numberOfElements: 3, + showIcon: true, + customLabels: ["First", "Second", "Third"], + }, +}; +``` + +### 7. **State Management** +For interactive components: +```tsx +import { useState } from "react"; + +const ComponentWithState = (args: any) => { + const [value, setValue] = useState(args.defaultValue); + + return ( + { + setValue(newValue); + args.onChange?.(newValue); + }} + /> + ); +}; + +export const Interactive: Story = { + render: ComponentWithState, + args: { defaultValue: "initial" }, +}; +``` + +### 8. **Quality Requirements** +- Include component description in parameters.docs +- Add story documentation for non-obvious use cases +- Test edge cases (overflow, empty states, many elements) +- Ensure no TypeScript errors +- Use realistic prop values +- Include at least 3-5 story variants +- Example values need to be in the context of survey application + +### 9. **Naming Conventions** +- **Story titles**: "UI/ComponentName" +- **Story exports**: PascalCase (Default, WithIcon, ManyElements) +- **Categories**: "Behavior", "Appearance", "Content" (exact spelling) +- **Props**: camelCase matching component props + +### 10. **Special Cases** +- **Generic components**: Remove `component` from meta if type conflicts +- **Form components**: Include Invalid, WithValue stories +- **Navigation**: Include ManyItems stories +- **Modals, Dropdowns and Popups **: Include trigger and content structure + +## Generate stories that are comprehensive, well-documented, and reflect all component states and edge cases. diff --git a/.cursor/rules/testing-patterns.mdc b/.cursor/rules/testing-patterns.mdc new file mode 100644 index 000000000000..b79a9b01da2e --- /dev/null +++ b/.cursor/rules/testing-patterns.mdc @@ -0,0 +1,322 @@ +--- +description: +globs: +alwaysApply: false +--- +# Testing Patterns & Best Practices + +## Running Tests + +### Test Commands +From the **root directory** (formbricks/): +- `npm test` - Run all tests across all packages (recommended for CI/full testing) +- `npm run test:coverage` - Run all tests with coverage reports +- `npm run test:e2e` - Run end-to-end tests with Playwright + +From the **apps/web directory** (apps/web/): +- `npm run test` - Run only web app tests (fastest for development) +- `npm run test:coverage` - Run web app tests with coverage +- `npm run test -- ` - Run specific test files + +### Examples +```bash +# Run all tests from root (takes ~3 minutes, runs 790 test files with 5334+ tests) +npm test + +# Run specific test file from apps/web (fastest for development) +npm run test -- modules/cache/lib/service.test.ts + +# Run tests matching pattern from apps/web +npm run test -- modules/ee/license-check/lib/license.test.ts + +# Run with coverage from root +npm run test:coverage + +# Run specific test with watch mode from apps/web (for development) +npm run test -- --watch modules/cache/lib/service.test.ts + +# Run tests for a specific directory from apps/web +npm run test -- modules/cache/ +``` + +### Performance Tips +- **For development**: Use `apps/web` directory commands to run only web app tests +- **For CI/validation**: Use root directory commands to run all packages +- **For specific features**: Use file patterns to target specific test files +- **For debugging**: Use `--watch` mode for continuous testing during development + +### Test File Organization +- Place test files in the **same directory** as the source file +- Use `.test.ts` for utility/service tests (Node environment) +- Use `.test.tsx` for React component tests (jsdom environment) + +## 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: [], + responseStatus: "all", + }, + 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(); + }); + + 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" diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc new file mode 100644 index 000000000000..92ac2c1c29dc --- /dev/null +++ b/.cursor/rules/testing.mdc @@ -0,0 +1,7 @@ +--- +description: Whenever the user asks to write or update a test file for .tsx or .ts files. +globs: +alwaysApply: false +--- +Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md). +After writing the tests, run them and check if there's any issue with the tests and if all of them are passing. Fix the issues and rerun the tests until all pass. \ No newline at end of file diff --git a/.env.example b/.env.example index 047f0cfc5e62..2def3fa0bace 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,12 @@ WEBAPP_URL=http://localhost:3000 # Required for next-auth. Should be the same as WEBAPP_URL NEXTAUTH_URL=http://localhost:3000 +# Custom Port Configurations for On-Host Dev +PORT_APP_WEB=3000 +PORT_APP_DEMO=3001 +PORT_APP_DEMO_REACT_NATIVE=3002 +PORT_APP_STORYBOOK=6006 + # Encryption keys # Please set both for now, we will change this in the future @@ -53,7 +59,7 @@ SMTP_PASSWORD=smtpPassword # If set to 0, the server will not require SMTP_USER and SMTP_PASSWORD(default is 1) # SMTP_AUTHENTICATED= -# If set to 0, the server will accept connections without requiring authorization from the list of supplied CAs (default is 1). +# If set to 0, the server will accept connections without requiring authorization from the list of supplied CAs (default is 1). # SMTP_REJECT_UNAUTHORIZED_TLS=0 ######################################################################## @@ -80,8 +86,8 @@ S3_ENDPOINT_URL= # Force path style for S3 compatible storage (0 for disabled, 1 for enabled) S3_FORCE_PATH_STYLE=0 -# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL) -# SURVEY_URL=https://survey.example.com +# Set this URL to add a public domain for all your client facing routes(default is WEBAPP_URL) +# PUBLIC_URL=https://survey.example.com ##################### # Disable Features # @@ -93,10 +99,6 @@ EMAIL_VERIFICATION_DISABLED=1 # Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too. PASSWORD_RESET_DISABLED=1 -# Signup. Disable the ability for new users to create an account. -# Note: This variable is only available to the SaaS setup of Formbricks Cloud. Signup is disable by default for self-hosting. -# SIGNUP_DISABLED=1 - # Email login. Disable the ability for users to login with email. # EMAIL_AUTH_DISABLED=1 @@ -120,6 +122,10 @@ IMPRINT_ADDRESS= # TURNSTILE_SITE_KEY= # TURNSTILE_SECRET_KEY= +# Google reCAPTCHA v3 keys +RECAPTCHA_SITE_KEY= +RECAPTCHA_SECRET_KEY= + # Configure Github Login GITHUB_ID= GITHUB_SECRET= @@ -154,10 +160,6 @@ NOTION_OAUTH_CLIENT_SECRET= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= -# Configure Formbricks usage within Formbricks -FORMBRICKS_API_HOST= -FORMBRICKS_ENVIRONMENT_ID= - # Oauth credentials for Google sheet integration GOOGLE_SHEETS_CLIENT_ID= GOOGLE_SHEETS_CLIENT_SECRET= @@ -176,8 +178,8 @@ ENTERPRISE_LICENSE_KEY= # Automatically assign new users to a specific organization and role within that organization # Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn) # (Role Management is an Enterprise feature) -# DEFAULT_ORGANIZATION_ID= -# DEFAULT_ORGANIZATION_ROLE=owner +# AUTH_SSO_DEFAULT_TEAM_ID= +# AUTH_SKIP_INVITE_FOR_SSO= # Send new users to Brevo # BREVO_API_KEY= @@ -193,25 +195,11 @@ ENTERPRISE_LICENSE_KEY= UNSPLASH_ACCESS_KEY= # The below is used for Next Caching (uses In-Memory from Next Cache if not provided) -# You can also add more configuration to Redis using the redis.conf file in the root directory REDIS_URL=redis://localhost:6379 -REDIS_DEFAULT_TTL=86400 # 1 day # The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this) # REDIS_HTTP_URL: -# The below is used for Rate Limiting for management API -UNKEY_ROOT_KEY= - -# Disable custom cache handler if necessary (e.g. if deployed on Vercel) -# CUSTOM_CACHE_DISABLED=1 - -# Azure AI settings -# AI_AZURE_RESSOURCE_NAME= -# AI_AZURE_API_KEY= -# AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID= -# AI_AZURE_LLM_DEPLOYMENT_ID= - # INTERCOM_APP_ID= # INTERCOM_SECRET_KEY= @@ -219,3 +207,22 @@ UNKEY_ROOT_KEY= # PROMETHEUS_ENABLED= # PROMETHEUS_EXPORTER_PORT= +# The SENTRY_DSN is used for error tracking and performance monitoring with Sentry. +# SENTRY_DSN= +# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin. +# It's used automatically by Sentry during the build for authentication when uploading source maps. +# SENTRY_AUTH_TOKEN= +# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard +# SENTRY_ENVIRONMENT= + +# Configure the minimum role for user management from UI(owner, manager, disabled) +# USER_MANAGEMENT_MINIMUM_ROLE="manager" + + +# Configure the maximum age for the session in seconds. Default is 86400 (24 hours) +# SESSION_MAX_AGE=86400 + +# Audit logs options. Default 0. +# AUDIT_LOG_ENABLED=0 +# If the ip should be added in the log or not. Default 0 +# AUDIT_LOG_GET_USER_IP=0 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2bf24b01c307..e396898efd9a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,7 @@ name: Bug report description: "Found a bug? Please fill out the sections below. \U0001F44D" type: bug +projects: "formbricks/8" labels: ["bug"] body: - type: textarea diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 431621e3d854..11768ae27bd8 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,4 @@ -blank_issues_enabled: false +blank_issues_enabled: true contact_links: - name: Questions url: https://github.com/formbricks/formbricks/discussions diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index b8ca17944b26..a10189cc8519 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,7 @@ name: Feature request description: "Suggest an idea for this project \U0001F680" type: feature +projects: "formbricks/21" body: - type: textarea id: problem-description diff --git a/.github/ISSUE_TEMPLATE/task.yml b/.github/ISSUE_TEMPLATE/task.yml deleted file mode 100644 index f2e12d24e1da..000000000000 --- a/.github/ISSUE_TEMPLATE/task.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: Task (internal) -description: "Template for creating a task. Used by the Formbricks Team only \U0001f4e5" -type: task -body: - - type: textarea - id: task-summary - attributes: - label: Task description - description: A clear detailed-rich description of the task. - validations: - required: true diff --git a/.github/actions/cache-build-web/action.yml b/.github/actions/cache-build-web/action.yml index f6e72d7f686f..4db8d682c67b 100644 --- a/.github/actions/cache-build-web/action.yml +++ b/.github/actions/cache-build-web/action.yml @@ -49,7 +49,7 @@ runs: if: steps.cache-build.outputs.cache-hit != 'true' - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 if: steps.cache-build.outputs.cache-hit != 'true' - name: Install dependencies @@ -62,10 +62,12 @@ runs: shell: bash - name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env + env: + E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }} run: | RANDOM_KEY=$(openssl rand -hex 32) sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env - echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env + echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env shell: bash - run: | diff --git a/.github/actions/upload-sentry-sourcemaps/action.yml b/.github/actions/upload-sentry-sourcemaps/action.yml new file mode 100644 index 000000000000..8e54b5c3ebdf --- /dev/null +++ b/.github/actions/upload-sentry-sourcemaps/action.yml @@ -0,0 +1,104 @@ +name: "Upload Sentry Sourcemaps" +description: "Extract sourcemaps from Docker image and upload to Sentry" + +inputs: + docker_image: + description: "Docker image to extract sourcemaps from" + required: true + release_version: + description: "Sentry release version (e.g., v1.2.3)" + required: true + sentry_auth_token: + description: "Sentry authentication token" + required: true + environment: + description: "Sentry environment (e.g., production, staging)" + required: false + default: "staging" + +runs: + using: "composite" + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract sourcemaps from Docker image + shell: bash + env: + DOCKER_IMAGE: ${{ inputs.docker_image }} + run: | + set -euo pipefail + + # Validate docker image format (basic validation) + if [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+$ ]] && [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+@sha256:[A-Fa-f0-9]{64}$ ]]; then + echo "❌ Error: Invalid docker image format. Must be in format 'image:tag' or 'image@sha256:hash'" + echo "Provided: ${DOCKER_IMAGE}" + exit 1 + fi + + echo "📦 Extracting sourcemaps from Docker image: ${DOCKER_IMAGE}" + + # Create temporary container from the image and capture its ID + echo "Creating temporary container..." + CONTAINER_ID=$(docker create "$DOCKER_IMAGE") + echo "Container created with ID: ${CONTAINER_ID}" + + # Set up cleanup function to ensure container is removed on script exit + cleanup_container() { + # Capture the current exit code to preserve it + local original_exit_code=$? + + echo "🧹 Cleaning up Docker container..." + + # Remove the container if it exists (ignore errors if already removed) + if [ -n "$CONTAINER_ID" ]; then + docker rm -f "$CONTAINER_ID" 2>/dev/null || true + echo "Container ${CONTAINER_ID} removed" + fi + + # Exit with the original exit code to preserve script success/failure status + exit $original_exit_code + } + + # Register cleanup function to run on script exit (success or failure) + trap cleanup_container EXIT + + # Extract .next directory containing sourcemaps + docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next + + # Verify sourcemaps exist + if [ ! -d "./extracted-next/static/chunks" ]; then + echo "❌ Error: .next/static/chunks directory not found in Docker image" + echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/" + exit 1 + fi + + sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l) + echo "✅ Found ${sourcemap_count} sourcemap files" + + if [ "$sourcemap_count" -eq 0 ]; then + echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled." + exit 1 + fi + + - name: Create Sentry release and upload sourcemaps + uses: getsentry/action-release@v3 + env: + SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }} + SENTRY_ORG: formbricks + SENTRY_PROJECT: formbricks-cloud + with: + environment: ${{ inputs.environment }} + version: ${{ inputs.release_version }} + sourcemaps: "./extracted-next/" + + - name: Clean up extracted files + shell: bash + if: always() + run: | + set -euo pipefail + # Clean up extracted files + rm -rf ./extracted-next + echo "🧹 Cleaned up extracted files" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000000..2e45d4b8c02a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,32 @@ +# Testing Instructions + +When generating test files inside the "/app/web" path, follow these rules: + +- You are an experienced senior software engineer +- Use vitest +- Ensure 100% code coverage +- Add as few comments as possible +- The test file should be located in the same folder as the original file +- Use the `test` function instead of `it` +- Follow the same test pattern used for other files in the package where the file is located +- All imports should be at the top of the file, not inside individual tests +- For mocking inside "test" blocks use "vi.mocked" +- If the file is located in the "packages/survey" path, use "@testing-library/preact" instead of "@testing-library/react" +- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file +- When using "screen.getByText" check for the tolgee string if it is being used in the file. +- The types for mocked variables can be found in the "packages/types" path. Be sure that every imported type exists before using it. Don't create types that are not already in the codebase. +- When mocking data check if the properties added are part of the type of the object being mocked. Only specify known properties, don't use properties that are not part of the type. + +If it's a test for a ".tsx" file, follow these extra instructions: + +- Add this code inside the "describe" block and before any test: + +afterEach(() => { + cleanup(); +}); + +- The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports. +- For click events, import userEvent from "@testing-library/user-event" +- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components. +- You don't need to mock @tolgee/react +- Use "import "@testing-library/jest-dom/vitest";" \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index d27ee547a786..000000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,84 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - -version: 2 -updates: - - package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem - directory: "/" # Root package.json - schedule: - interval: "weekly" - versioning-strategy: increase - - # Apps directory packages - - package-ecosystem: "npm" - directory: "/apps/demo" - schedule: - interval: "weekly" - - - package-ecosystem: "npm" - directory: "/apps/demo-react-native" - schedule: - interval: "weekly" - - - package-ecosystem: "npm" - directory: "/apps/storybook" - schedule: - interval: "weekly" - - - package-ecosystem: "npm" - directory: "/apps/web" - schedule: - interval: "weekly" - - # Packages directory - - package-ecosystem: "npm" - directory: "/packages/database" - schedule: - interval: "weekly" - - - package-ecosystem: "npm" - directory: "/packages/lib" - schedule: - interval: "weekly" - - - package-ecosystem: "npm" - directory: "/packages/types" - schedule: - interval: "weekly" - - - package-ecosystem: "npm" - directory: "/packages/config-eslint" - schedule: - interval: "weekly" - - - package-ecosystem: "npm" - directory: "/packages/config-prettier" - schedule: - interval: "weekly" - - - package-ecosystem: "npm" - directory: "/packages/config-typescript" - schedule: - interval: "weekly" - - - package-ecosystem: "npm" - directory: "/packages/js-core" - schedule: - interval: "weekly" - - - package-ecosystem: "npm" - directory: "/packages/surveys" - schedule: - interval: "weekly" - - - package-ecosystem: "npm" - directory: "/packages/logger" - schedule: - interval: "weekly" - - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" diff --git a/.github/workflows/apply-issue-labels-to-pr.yml b/.github/workflows/apply-issue-labels-to-pr.yml deleted file mode 100644 index b15d6e987359..000000000000 --- a/.github/workflows/apply-issue-labels-to-pr.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: "Apply issue labels to PR" - -on: - pull_request_target: - types: - - opened - -permissions: - contents: read - -jobs: - label_on_pr: - runs-on: ubuntu-latest - - permissions: - contents: none - issues: read - pull-requests: write - - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 - with: - egress-policy: audit - - - name: Apply labels from linked issue to PR - uses: actions/github-script@211cb3fefb35a799baa5156f9321bb774fe56294 # v5.2.0 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - async function getLinkedIssues(owner, repo, prNumber) { - const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $prNumber) { - closingIssuesReferences(first: 10) { - nodes { - number - labels(first: 10) { - nodes { - name - } - } - } - } - } - } - }`; - - const variables = { - owner: owner, - repo: repo, - prNumber: prNumber, - }; - - const result = await github.graphql(query, variables); - return result.repository.pullRequest.closingIssuesReferences.nodes; - } - - const pr = context.payload.pull_request; - const linkedIssues = await getLinkedIssues( - context.repo.owner, - context.repo.repo, - pr.number - ); - - const labelsToAdd = new Set(); - for (const issue of linkedIssues) { - if (issue.labels && issue.labels.nodes) { - for (const label of issue.labels.nodes) { - labelsToAdd.add(label.name); - } - } - } - - if (labelsToAdd.size) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: Array.from(labelsToAdd), - }); - } diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 19235a397982..80ddf8b0dfcb 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -13,11 +13,11 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: egress-policy: audit - - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: ./.github/actions/dangerous-git-checkout - name: Build & Cache Web Binaries diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index cb0ab7d0c301..2a23e114f301 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -6,13 +6,20 @@ on: - main workflow_dispatch: +permissions: + contents: read + jobs: chromatic: name: Run Chromatic runs-on: ubuntu-latest + permissions: + packages: write + id-token: write + actions: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: egress-policy: audit diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml deleted file mode 100644 index 3781c5765486..000000000000 --- a/.github/workflows/dependency-review.yml +++ /dev/null @@ -1,27 +0,0 @@ -# Dependency Review Action -# -# This Action will scan dependency manifest files that change as part of a Pull Request, -# surfacing known-vulnerable versions of the packages declared or updated in the PR. -# Once installed, if the workflow run is marked as required, -# PRs introducing known-vulnerable packages will be blocked from merging. -# -# Source repository: https://github.com/actions/dependency-review-action -name: 'Dependency Review' -on: [pull_request] - -permissions: - contents: read - -jobs: - dependency-review: - runs-on: ubuntu-latest - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 - with: - egress-policy: audit - - - name: 'Checkout Repository' - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: 'Dependency Review' - uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0 diff --git a/.github/workflows/deploy-formbricks-cloud.yml b/.github/workflows/deploy-formbricks-cloud.yml index bff5c196e34c..a43f6e45011c 100644 --- a/.github/workflows/deploy-formbricks-cloud.yml +++ b/.github/workflows/deploy-formbricks-cloud.yml @@ -4,25 +4,36 @@ on: workflow_dispatch: inputs: VERSION: - description: 'The version of the Docker image to release' + description: "The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0." required: true type: string REPOSITORY: - description: 'The repository to use for the Docker image' + description: "The repository to use for the Docker image" required: false type: string - default: 'ghcr.io/formbricks/formbricks' + default: "ghcr.io/formbricks/formbricks" + ENVIRONMENT: + description: "The environment to deploy to" + required: true + type: choice + options: + - staging + - production workflow_call: inputs: VERSION: - description: 'The version of the Docker image to release' + description: "The version of the Docker image to release" required: true type: string REPOSITORY: - description: 'The repository to use for the Docker image' + description: "The repository to use for the Docker image" required: false type: string - default: 'ghcr.io/formbricks/formbricks' + default: "ghcr.io/formbricks/formbricks" + ENVIRONMENT: + description: "The environment to deploy to" + required: true + type: string permissions: id-token: write @@ -32,11 +43,24 @@ jobs: helmfile-deploy: runs-on: ubuntu-latest steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Tailscale + uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3 + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} + tags: tag:github + args: --accept-routes - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0 with: role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} aws-region: "eu-central-1" @@ -47,7 +71,9 @@ jobs: env: AWS_REGION: eu-central-1 - - uses: helmfile/helmfile-action@v2 + - uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4 + name: Deploy Formbricks Cloud Production + if: inputs.ENVIRONMENT == 'production' env: VERSION: ${{ inputs.VERSION }} REPOSITORY: ${{ inputs.REPOSITORY }} @@ -55,10 +81,69 @@ jobs: FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }} FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }} with: + helmfile-version: "v1.0.0" + helm-plugins: > + https://github.com/databus23/helm-diff, + https://github.com/jkroepke/helm-secrets + helmfile-args: apply -l environment=prod + helmfile-auto-init: "false" + helmfile-workdirectory: infra/formbricks-cloud-helm + + - uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4 + name: Deploy Formbricks Cloud Staging + if: inputs.ENVIRONMENT == 'staging' + env: + VERSION: ${{ inputs.VERSION }} + REPOSITORY: ${{ inputs.REPOSITORY }} + FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }} + FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }} + with: + helmfile-version: "v1.0.0" helm-plugins: > https://github.com/databus23/helm-diff, https://github.com/jkroepke/helm-secrets - helmfile-args: apply + helmfile-args: apply -l environment=stage helmfile-auto-init: "false" helmfile-workdirectory: infra/formbricks-cloud-helm + - name: Purge Cloudflare Cache + if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }} + env: + CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + ENVIRONMENT: ${{ inputs.ENVIRONMENT }} + run: | + # Set hostname based on environment + if [[ "$ENVIRONMENT" == "production" ]]; then + PURGE_HOST="app.formbricks.com" + else + PURGE_HOST="stage.app.formbricks.com" + fi + + echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: $ENVIRONMENT, zone: $CF_ZONE_ID)" + + # Prepare JSON payload for selective cache purge + json_payload=$(cat << EOF + { + "hosts": ["$PURGE_HOST"] + } + EOF + ) + + # Make API call to Cloudflare + response=$(curl -s -X POST \ + "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \ + -H "Authorization: Bearer $CF_API_TOKEN" \ + -H "Content-Type: application/json" \ + --data "$json_payload") + + echo "Cloudflare API response: $response" + + # Verify the operation was successful + if [[ "$(echo "$response" | jq -r .success)" == "true" ]]; then + echo "✅ Successfully purged cache for $PURGE_HOST" + else + echo "❌ Cloudflare cache purge failed" + echo "Error details: $(echo "$response" | jq -r .errors)" + exit 1 + fi diff --git a/.github/workflows/docker-build-validation.yml b/.github/workflows/docker-build-validation.yml index 9fcd4de85cbe..d2d6192dd029 100644 --- a/.github/workflows/docker-build-validation.yml +++ b/.github/workflows/docker-build-validation.yml @@ -39,42 +39,68 @@ jobs: --health-retries 5 steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + - name: Checkout Repository - uses: actions/checkout@v3 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Build Docker Image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + env: + GITHUB_SHA: ${{ github.sha }} with: context: . file: ./apps/web/Dockerfile push: false load: true - tags: formbricks-test:${{ github.sha }} + tags: formbricks-test:${{ env.GITHUB_SHA }} cache-from: type=gha cache-to: type=gha,mode=max secrets: | database_url=${{ secrets.DUMMY_DATABASE_URL }} encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }} - - name: Verify PostgreSQL Connection + - name: Verify and Initialize PostgreSQL run: | echo "Verifying PostgreSQL connection..." # Install PostgreSQL client to test connection sudo apt-get update && sudo apt-get install -y postgresql-client - # Test connection using psql - PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL" + # Test connection using psql with timeout and proper error handling + echo "Testing PostgreSQL connection with 30 second timeout..." + if timeout 30 bash -c 'until PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" >/dev/null 2>&1; do + echo "Waiting for PostgreSQL to be ready..." + sleep 2 + done'; then + echo "✅ PostgreSQL connection successful" + PGPASSWORD=test psql -h localhost -U test -d formbricks -c "SELECT version();" + + # Enable necessary extensions that might be required by migrations + echo "Enabling required PostgreSQL extensions..." + PGPASSWORD=test psql -h localhost -U test -d formbricks -c "CREATE EXTENSION IF NOT EXISTS vector;" || echo "Vector extension already exists or not available" + + else + echo "❌ PostgreSQL connection failed after 30 seconds" + exit 1 + fi # Show network configuration echo "Network configuration:" - ip addr show netstat -tulpn | grep 5432 || echo "No process listening on port 5432" - name: Test Docker Image with Health Check shell: bash + env: + GITHUB_SHA: ${{ github.sha }} + DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }} run: | echo "🧪 Testing if the Docker image starts correctly..." @@ -86,29 +112,12 @@ jobs: $DOCKER_RUN_ARGS \ -p 3000:3000 \ -e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \ - -e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \ - -d formbricks-test:${{ github.sha }} - - # Give it more time to start up - echo "Waiting 45 seconds for application to start..." - sleep 45 + -e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \ + -d "formbricks-test:$GITHUB_SHA" - # Check if the container is running - if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then - echo "❌ Container failed to start properly!" - docker logs formbricks-test - exit 1 - else - echo "✅ Container started successfully!" - fi - - # Try connecting to PostgreSQL from inside the container - echo "Testing PostgreSQL connection from inside container..." - docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"' - - # Try to access the health endpoint - echo "🏥 Testing /health endpoint..." - MAX_RETRIES=10 + # Start health check polling immediately (every 5 seconds for up to 5 minutes) + echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..." + MAX_RETRIES=60 # 60 attempts × 5 seconds = 5 minutes RETRY_COUNT=0 HEALTH_CHECK_SUCCESS=false @@ -116,38 +125,32 @@ jobs: while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do RETRY_COUNT=$((RETRY_COUNT + 1)) - echo "Attempt $RETRY_COUNT of $MAX_RETRIES..." - - # Show container logs before each attempt to help debugging - if [ $RETRY_COUNT -gt 1 ]; then - echo "📋 Current container logs:" - docker logs --tail 20 formbricks-test + + # Check if container is still running + if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test 2>/dev/null)" != "true" ]; then + echo "❌ Container stopped running after $((RETRY_COUNT * 5)) seconds!" + echo "📋 Container logs:" + docker logs formbricks-test + exit 1 fi - - # Get detailed curl output for debugging - HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1) - CURL_EXIT_CODE=$? - - echo "Curl exit code: $CURL_EXIT_CODE" - echo "Curl output: $HTTP_OUTPUT" - - if [ $CURL_EXIT_CODE -eq 0 ]; then - STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+") - echo "Status code detected: $STATUS_CODE" - - if [ "$STATUS_CODE" = "200" ]; then - echo "✅ Health check successful!" - HEALTH_CHECK_SUCCESS=true - break - else - echo "❌ Health check returned non-200 status code: $STATUS_CODE" - fi - else - echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE" + + # Show progress and diagnostic info every 12 attempts (1 minute intervals) + if [ $((RETRY_COUNT % 12)) -eq 0 ] || [ $RETRY_COUNT -eq 1 ]; then + echo "Health check attempt $RETRY_COUNT of $MAX_RETRIES ($(($RETRY_COUNT * 5)) seconds elapsed)..." + echo "📋 Recent container logs:" + docker logs --tail 10 formbricks-test fi - - echo "Waiting 15 seconds before next attempt..." - sleep 15 + + # Try health endpoint with shorter timeout for faster polling + # Use -f flag to make curl fail on HTTP error status codes (4xx, 5xx) + if curl -f -s -m 10 http://localhost:3000/health >/dev/null 2>&1; then + echo "✅ Health check successful after $((RETRY_COUNT * 5)) seconds!" + HEALTH_CHECK_SUCCESS=true + break + fi + + # Wait 5 seconds before next attempt + sleep 5 done # Show full container logs for debugging @@ -160,7 +163,7 @@ jobs: # Exit with failure if health check did not succeed if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then - echo "❌ Health check failed after $MAX_RETRIES attempts" + echo "❌ Health check failed after $((MAX_RETRIES * 5)) seconds (5 minutes)" exit 1 fi diff --git a/.github/workflows/docker-security-scan.yml b/.github/workflows/docker-security-scan.yml new file mode 100644 index 000000000000..7cd66b37c174 --- /dev/null +++ b/.github/workflows/docker-security-scan.yml @@ -0,0 +1,40 @@ +name: Docker Security Scan + +on: + schedule: + - cron: "0 2 * * *" # Daily at 2 AM UTC + workflow_dispatch: + workflow_run: + workflows: ["Docker Release to Github"] + types: [completed] + +permissions: + contents: read + packages: read + security-events: write + +jobs: + scan: + name: Vulnerability Scan + runs-on: ubuntu-latest + steps: + - name: Log in to GitHub Container Registry + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # v0.32.0 + with: + image-ref: "ghcr.io/${{ github.repository }}:latest" + format: "sarif" + output: "trivy-results.sarif" + severity: "CRITICAL,HIGH,MEDIUM,LOW" + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6 + if: ${{ always() && hashFiles('trivy-results.sarif') != '' }} + with: + sarif_file: "trivy-results.sarif" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 524151f73577..81a4d044b48c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -11,6 +11,8 @@ on: required: false PLAYWRIGHT_SERVICE_URL: required: false + ENTERPRISE_LICENSE_KEY: + required: true # Add other secrets if necessary workflow_dispatch: @@ -23,7 +25,6 @@ permissions: id-token: write contents: read actions: read - checks: write jobs: build: @@ -44,19 +45,31 @@ jobs: --health-interval=10s --health-timeout=5s --health-retries=5 + valkey: + image: valkey/valkey:8.1.1 + ports: + - 6379:6379 + options: >- + --entrypoint "valkey-server" + --health-cmd="valkey-cli ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: - egress-policy: audit + egress-policy: allow + allowed-endpoints: | + ee.formbricks.com:443 - - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: ./.github/actions/dangerous-git-checkout - - name: Setup Node.js 20.x + - name: Setup Node.js 22.x uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2 with: - node-version: 20.x + node-version: 22.x - name: Install pnpm uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 @@ -75,7 +88,8 @@ jobs: sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env - sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env + sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env + sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env echo "" >> .env echo "E2E_TESTING=1" >> .env shell: bash @@ -89,8 +103,24 @@ jobs: # pnpm prisma migrate deploy pnpm db:migrate:dev + - name: Run Rate Limiter Load Tests + run: | + echo "Running rate limiter load tests with Redis/Valkey..." + cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts + shell: bash + + - name: Check for Enterprise License + run: | + LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-) + if [ -z "$LICENSE_KEY" ]; then + echo "::error::ENTERPRISE_LICENSE_KEY in .env is empty. Please check your secret configuration." + exit 1 + fi + echo "License key length: ${#LICENSE_KEY}" + - name: Run App run: | + echo "Starting app with enterprise license..." NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 & sleep 10 # Optional: gives some buffer for the app to start for attempt in {1..10}; do diff --git a/.github/workflows/formbricks-release.yml b/.github/workflows/formbricks-release.yml index dca6cfd53f49..73f49787bf33 100644 --- a/.github/workflows/formbricks-release.yml +++ b/.github/workflows/formbricks-release.yml @@ -1,17 +1,22 @@ name: Build, release & deploy Formbricks images on: - workflow_dispatch: - push: - tags: - - "v*" + release: + types: [published] + +permissions: + contents: read + +env: + ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }} jobs: docker-build: - name: Build & release stable docker image - if: startsWith(github.ref, 'refs/tags/v') + name: Build & release docker image uses: ./.github/workflows/release-docker-github.yml secrets: inherit + with: + IS_PRERELEASE: ${{ github.event.release.prerelease }} helm-chart-release: name: Release Helm Chart @@ -30,4 +35,33 @@ jobs: - docker-build - helm-chart-release with: - VERSION: ${{ needs.docker-build.outputs.VERSION }} + VERSION: v${{ needs.docker-build.outputs.VERSION }} + ENVIRONMENT: ${{ env.ENVIRONMENT }} + + upload-sentry-sourcemaps: + name: Upload Sentry Sourcemaps + runs-on: ubuntu-latest + permissions: + contents: read + needs: + - docker-build + - deploy-formbricks-cloud + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Upload Sentry Sourcemaps + uses: ./.github/actions/upload-sentry-sourcemaps + continue-on-error: true + with: + docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }} + release_version: v${{ needs.docker-build.outputs.VERSION }} + sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} + environment: ${{ env.ENVIRONMENT }} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml deleted file mode 100644 index 18b82b4b339a..000000000000 --- a/.github/workflows/labeler.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: "Pull Request Labeler" -on: - - pull_request_target -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true -permissions: - contents: read - -jobs: - labeler: - name: Pull Request Labeler - permissions: - contents: read - pull-requests: write - runs-on: ubuntu-latest - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 - with: - egress-policy: audit - - - uses: actions/labeler@ac9175f8a1f3625fd0d4fb234536d26811351594 # v4.3.0 - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" - # https://github.com/actions/labeler/issues/442#issuecomment-1297359481 - sync-labels: "" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 47e36da4ec14..f751ac41557f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: egress-policy: audit @@ -26,7 +26,7 @@ jobs: node-version: 20.x - name: Install pnpm - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 - name: Install dependencies run: pnpm install --config.platform=linux --config.architecture=x64 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index bc7a6032ad98..87371648d9b6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -10,8 +10,6 @@ permissions: on: pull_request: - branches: - - main merge_group: workflow_dispatch: @@ -51,7 +49,7 @@ jobs: statuses: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 with: egress-policy: audit - name: fail if conditional jobs failed diff --git a/.github/workflows/release-changesets.yml b/.github/workflows/release-changesets.yml deleted file mode 100644 index ea4037dd3b61..000000000000 --- a/.github/workflows/release-changesets.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Release Changesets - -on: - workflow_dispatch: - #push: - # branches: - # - main - -permissions: - contents: write - pull-requests: write - packages: write - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - -jobs: - release: - name: Release - runs-on: ubuntu-latest - timeout-minutes: 15 - env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 - with: - egress-policy: audit - - - name: Checkout Repo - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 - - - name: Setup Node.js 18.x - uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2.5.2 - with: - node-version: 18.x - - - name: Install pnpm - uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd # v2.2.4 - - - name: Install Dependencies - run: pnpm install --config.platform=linux --config.architecture=x64 - - - name: Create Release Pull Request or Publish to npm - id: changesets - uses: changesets/action@c8bada60c408975afd1a20b3db81d6eee6789308 # v1.4.9 - with: - # This expects you to have a script called release which does a build for your packages and calls changeset publish - publish: pnpm release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release-docker-github-experimental.yml b/.github/workflows/release-docker-github-experimental.yml index 25b8e5e61e1e..500226af6532 100644 --- a/.github/workflows/release-docker-github-experimental.yml +++ b/.github/workflows/release-docker-github-experimental.yml @@ -29,14 +29,67 @@ jobs: # with sigstore/fulcio when running outside of PRs. id-token: write + outputs: + DOCKER_IMAGE: ${{ steps.extract_image_info.outputs.DOCKER_IMAGE }} + RELEASE_VERSION: ${{ steps.extract_image_info.outputs.RELEASE_VERSION }} + steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: egress-policy: audit - name: Checkout repository - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Generate SemVer version from branch or tag + id: generate_version + env: + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + run: | + # Get reference name and type from environment variables + echo "Reference type: $REF_TYPE" + echo "Reference name: $REF_NAME" + + if [[ "$REF_TYPE" == "tag" ]]; then + # If running from a tag, use the tag name + if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then + # Tag looks like a SemVer, use it directly (remove 'v' prefix if present) + VERSION=$(echo "$REF_NAME" | sed 's/^v//') + echo "Using SemVer tag: $VERSION" + else + # Tag is not SemVer, treat as prerelease + SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g') + VERSION="0.0.0-$SANITIZED_TAG" + echo "Using tag as prerelease: $VERSION" + fi + else + # Running from branch, use branch name as prerelease + SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g') + VERSION="0.0.0-$SANITIZED_BRANCH" + echo "Using branch as prerelease: $VERSION" + fi + + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "Generated SemVer version: $VERSION" + + - name: Update package.json version + run: | + sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json + cat ./apps/web/package.json | grep version + + - name: Set Sentry environment in .env + run: | + if ! grep -q "^SENTRY_ENVIRONMENT=staging$" .env 2>/dev/null; then + echo "SENTRY_ENVIRONMENT=staging" >> .env + echo "Added SENTRY_ENVIRONMENT=staging to .env file" + else + echo "SENTRY_ENVIRONMENT=staging already exists in .env file" + fi - name: Set up Depot CLI uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0 @@ -45,13 +98,13 @@ jobs: # https://github.com/sigstore/cosign-installer - name: Install cosign if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0 + uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2 # Login against a Docker registry except on PR # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -82,8 +135,21 @@ jobs: secrets: | database_url=${{ secrets.DUMMY_DATABASE_URL }} encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }} - cache-from: type=gha - cache-to: type=gha,mode=max + + - name: Extract image info for sourcemap upload + id: extract_image_info + run: | + # Use the first readable tag from metadata action output + DOCKER_IMAGE=$(echo "${{ steps.meta.outputs.tags }}" | head -n1 | xargs) + echo "DOCKER_IMAGE=$DOCKER_IMAGE" >> $GITHUB_OUTPUT + + # Use the generated version for Sentry release + RELEASE_VERSION="$VERSION" + echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT + + echo "Docker image: $DOCKER_IMAGE" + echo "Release version: $RELEASE_VERSION" + echo "Available tags: ${{ steps.meta.outputs.tags }}" # Sign the resulting Docker image digest except on PRs. # This will only write to the public Rekor transparency log when the Docker @@ -99,3 +165,30 @@ jobs: # This step uses the identity token to provision an ephemeral certificate # against the sigstore community Fulcio instance. run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} + + upload-sentry-sourcemaps: + name: Upload Sentry Sourcemaps + runs-on: ubuntu-latest + permissions: + contents: read + needs: + - build + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Upload Sentry Sourcemaps + uses: ./.github/actions/upload-sentry-sourcemaps + continue-on-error: true + with: + docker_image: ${{ needs.build.outputs.DOCKER_IMAGE }} + release_version: ${{ needs.build.outputs.RELEASE_VERSION }} + sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} + environment: staging diff --git a/.github/workflows/release-docker-github.yml b/.github/workflows/release-docker-github.yml index 457940fb7e42..bf5593ba606f 100644 --- a/.github/workflows/release-docker-github.yml +++ b/.github/workflows/release-docker-github.yml @@ -7,6 +7,12 @@ name: Docker Release to Github on: workflow_call: + inputs: + IS_PRERELEASE: + description: "Whether this is a prerelease (affects latest tag)" + required: false + type: boolean + default: false outputs: VERSION: description: release version @@ -29,29 +35,42 @@ jobs: permissions: contents: read packages: write + id-token: write # This is used to complete the identity challenge # with sigstore/fulcio when running outside of PRs. - id-token: write outputs: VERSION: ${{ steps.extract_release_tag.outputs.VERSION }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: egress-policy: audit - name: Checkout repository - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Get Release Tag id: extract_release_tag run: | - TAG=${{ github.ref }} + # Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3) + TAG="$GITHUB_REF" TAG=${TAG#refs/tags/v} + + # Validate the extracted tag format + if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then + echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)" + echo "Original ref: $GITHUB_REF" + echo "Extracted tag: $TAG" + exit 1 + fi + + # Safely add to environment variables echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV + echo "VERSION=$TAG" >> $GITHUB_OUTPUT + echo "Using tag-based version: $TAG" - name: Update package.json version run: | @@ -65,13 +84,13 @@ jobs: # https://github.com/sigstore/cosign-installer - name: Install cosign if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0 + uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2 # Login against a Docker registry except on PR # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -84,6 +103,13 @@ jobs: uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + # Default semver tags (version, major.minor, major) + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + # Only tag as 'latest' for stable releases (not prereleases) + type=raw,value=latest,enable=${{ inputs.IS_PRERELEASE != 'true' }} # Build and push Docker image with Buildx (don't push on PR) # https://github.com/docker/build-push-action @@ -102,8 +128,6 @@ jobs: secrets: | database_url=${{ secrets.DUMMY_DATABASE_URL }} encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }} - cache-from: type=gha - cache-to: type=gha,mode=max # Sign the resulting Docker image digest except on PRs. # This will only write to the public Rekor transparency log when the Docker diff --git a/.github/workflows/release-helm-chart.yml b/.github/workflows/release-helm-chart.yml index fbe39e160d81..53ecc725a3d9 100644 --- a/.github/workflows/release-helm-chart.yml +++ b/.github/workflows/release-helm-chart.yml @@ -4,7 +4,7 @@ on: workflow_call: inputs: VERSION: - description: 'The version of the Helm chart to release' + description: "The version of the Helm chart to release" required: true type: string @@ -19,15 +19,30 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: egress-policy: audit - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Extract release version - run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV + - name: Validate input version + env: + INPUT_VERSION: ${{ inputs.VERSION }} + run: | + set -euo pipefail + # Validate input version format (expects clean semver without 'v' prefix) + if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then + echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)" + echo "Expected: clean version without 'v' prefix" + echo "Provided: $INPUT_VERSION" + exit 1 + fi + + # Store validated version in environment variable + echo "VERSION<> $GITHUB_ENV + echo "$INPUT_VERSION" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV - name: Set up Helm uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5 @@ -35,15 +50,18 @@ jobs: version: latest - name: Log in to GitHub Container Registry - run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ACTOR: ${{ github.actor }} + run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin - name: Install YQ uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1 - name: Update Chart.yaml with new version run: | - yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml - yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml + yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml + yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml - name: Package Helm chart run: | @@ -51,4 +69,4 @@ jobs: - name: Push Helm chart to GitHub Container Registry run: | - helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts + helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml deleted file mode 100644 index e82bdca819af..000000000000 --- a/.github/workflows/scorecard.yml +++ /dev/null @@ -1,81 +0,0 @@ -# This workflow uses actions that are not certified by GitHub. They are provided -# by a third-party and are governed by separate terms of service, privacy -# policy, and support documentation. - -name: Scorecard supply-chain security -on: - # For Branch-Protection check. Only the default branch is supported. See - # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection - branch_protection_rule: - # To guarantee Maintained check is occasionally updated. See - # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained - schedule: - - cron: "17 17 * * 6" - push: - branches: ["main"] - workflow_dispatch: - -# Declare default permissions as read only. -permissions: read-all - -jobs: - analysis: - name: Scorecard analysis - runs-on: ubuntu-latest - permissions: - # Needed to upload the results to code-scanning dashboard. - security-events: write - # Needed to publish results and get a badge (see publish_results below). - id-token: write - # Add this permission - actions: write # Required for artifact upload - # Uncomment the permissions below if installing in a private repository. - # contents: read - # actions: read - - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 - with: - egress-policy: audit - - - name: "Checkout code" - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - with: - persist-credentials: false - - - name: "Run analysis" - uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 - with: - results_file: results.sarif - results_format: sarif - # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: - # - you want to enable the Branch-Protection check on a *public* repository, or - # - you are installing Scorecard on a *private* repository - # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. - # repo_token: ${{ secrets.SCORECARD_TOKEN }} - - # Public repositories: - # - Publish results to OpenSSF REST API for easy access by consumers - # - Allows the repository to include the Scorecard badge. - # - See https://github.com/ossf/scorecard-action#publishing-results. - # For private repositories: - # - `publish_results` will always be set to `false`, regardless - # of the value entered here. - publish_results: true - - # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF - # format to the repository Actions tab. - - name: "Upload artifact" - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: sarif - path: results.sarif - retention-days: 5 - - # Upload the results to GitHub's code scanning dashboard (optional). - # Commenting out will disable upload of results to your repo's Code Scanning dashboard - - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10 - with: - sarif_file: results.sarif diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml index 99494775a5af..ee93e9282365 100644 --- a/.github/workflows/semantic-pull-requests.yml +++ b/.github/workflows/semantic-pull-requests.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: egress-policy: audit @@ -40,7 +40,7 @@ jobs: revert ossgg - - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 + - uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2 # When the previous steps fails, the workflow would stop. By adding this # condition you can continue the execution with the populated error message. if: always() && (steps.lint_pr_title.outputs.error_message != null) @@ -56,11 +56,3 @@ jobs: ``` ${{ steps.lint_pr_title.outputs.error_message }} ``` - - # Delete a previous comment when the issue has been resolved - - if: ${{ steps.lint_pr_title.outputs.error_message == null }} - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - with: - header: pr-title-lint-error - message: | - Thank you for following the naming conventions for pull request titles! 🙏 diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 35fff1fdfe8f..4c5ba986f3b3 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: egress-policy: audit @@ -29,7 +29,7 @@ jobs: node-version: 22.x - name: Install pnpm - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 - name: Install dependencies run: pnpm install --config.platform=linux --config.architecture=x64 @@ -43,12 +43,13 @@ jobs: sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env + sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env - name: Run tests with coverage run: | pnpm test:coverage - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97 + uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/terraform-plan-and-apply.yml b/.github/workflows/terraform-plan-and-apply.yml new file mode 100644 index 000000000000..d805a3cbd6b4 --- /dev/null +++ b/.github/workflows/terraform-plan-and-apply.yml @@ -0,0 +1,86 @@ +name: "Terraform" + +on: + workflow_dispatch: + # TODO: enable it back when migration is completed. + push: + branches: + - main + paths: + - "infra/terraform/**" + pull_request: + branches: + - main + paths: + - "infra/terraform/**" + +permissions: + contents: read + +jobs: + terraform: + runs-on: ubuntu-latest + permissions: + id-token: write + pull-requests: write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Tailscale + uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3 + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} + tags: tag:github + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0 + with: + role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} + aws-region: "eu-central-1" + + - name: Setup Terraform + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + + - name: Terraform Format + id: fmt + run: terraform fmt -check -recursive + continue-on-error: true + working-directory: infra/terraform + + - name: Terraform Init + id: init + run: terraform init + working-directory: infra/terraform + + - name: Terraform Validate + id: validate + run: terraform validate + working-directory: infra/terraform + + - name: Terraform Plan + id: plan + run: terraform plan -out .planfile + working-directory: infra/terraform + + - name: Post PR comment + uses: borchero/terraform-plan-comment@434458316f8f24dd073cd2561c436cce41dc8f34 # v2.4.1 + if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure') + with: + token: ${{ github.token }} + planfile: .planfile + working-directory: "infra/terraform" + + - name: Terraform Apply + id: apply + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + run: terraform apply .planfile + working-directory: "infra/terraform" diff --git a/.github/workflows/terrafrom-plan-and-apply.yml b/.github/workflows/terrafrom-plan-and-apply.yml deleted file mode 100644 index 78d0c72e6c8c..000000000000 --- a/.github/workflows/terrafrom-plan-and-apply.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: 'Terraform' - -on: - workflow_dispatch: -# TODO: enable it back when migration is completed. - push: - branches: - - main - paths: - - "infra/terraform/**" - pull_request: - branches: - - main - paths: - - "infra/terraform/**" - -permissions: - id-token: write - contents: write - pull-requests: write - -jobs: - terraform: - runs-on: ubuntu-latest - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 - with: - egress-policy: audit - - - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 - with: - role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} - aws-region: "eu-central-1" - - - name: Setup Terraform - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 - - - name: Terraform Format - id: fmt - run: terraform fmt -check -recursive - continue-on-error: true - working-directory: infra/terraform - - - name: Terraform Init - id: init - run: terraform init - working-directory: infra/terraform - - - name: Terraform Validate - id: validate - run: terraform validate - working-directory: infra/terraform - - - name: Terraform Plan - id: plan - run: terraform plan -out .planfile - working-directory: infra/terraform - - - name: Post PR comment - uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0 - if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure') - with: - token: ${{ github.token }} - planfile: .planfile - working-directory: "infra/terraform" - - - name: Terraform Apply - id: apply - if: github.ref == 'refs/heads/main' && github.event_name == 'push' - run: terraform apply .planfile - working-directory: "infra/terraform" - diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f1bad542045f..2af0cda7eea2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,11 +14,11 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: egress-policy: audit - - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: ./.github/actions/dangerous-git-checkout - name: Setup Node.js 20.x @@ -41,6 +41,7 @@ jobs: sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env + sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env - name: Test run: pnpm test diff --git a/.github/workflows/tolgee-missing-key-check.yml b/.github/workflows/tolgee-missing-key-check.yml index 1691860ac33f..7d5a4927ac8b 100644 --- a/.github/workflows/tolgee-missing-key-check.yml +++ b/.github/workflows/tolgee-missing-key-check.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: egress-policy: audit diff --git a/.github/workflows/tolgee.yml b/.github/workflows/tolgee.yml index b6325c3a1364..7ac8b940aefe 100644 --- a/.github/workflows/tolgee.yml +++ b/.github/workflows/tolgee.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 with: egress-policy: audit @@ -27,10 +27,18 @@ jobs: - name: Get source branch name id: branch-name + env: + RAW_BRANCH: ${{ github.head_ref }} run: | - RAW_BRANCH="${{ github.head_ref }}" + # Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g') + # Additional validation - ensure branch name is not empty after sanitization + if [[ -z "$SOURCE_BRANCH" ]]; then + echo "❌ Error: Branch name is empty after sanitization" + echo "Original branch: $RAW_BRANCH" + exit 1 + fi # Safely add to environment variables using GitHub's recommended method # This prevents environment variable injection attacks diff --git a/.github/workflows/upload-sentry-sourcemaps.yml b/.github/workflows/upload-sentry-sourcemaps.yml new file mode 100644 index 000000000000..4e413a24ba55 --- /dev/null +++ b/.github/workflows/upload-sentry-sourcemaps.yml @@ -0,0 +1,48 @@ +name: Upload Sentry Sourcemaps (Manual) + +on: + workflow_dispatch: + inputs: + docker_image: + description: "Docker image to extract sourcemaps from" + required: true + type: string + release_version: + description: "Release version (e.g., v1.2.3)" + required: true + type: string + tag_version: + description: "Docker image tag (leave empty to use release_version)" + required: false + type: string + +permissions: + contents: read + +jobs: + upload-sourcemaps: + name: Upload Sourcemaps to Sentry + runs-on: ubuntu-latest + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Set Docker Image + run: echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> $GITHUB_ENV + env: + DOCKER_IMAGE: ${{ inputs.docker_image }}:${{ inputs.tag_version != '' && inputs.tag_version || inputs.release_version }} + + - name: Upload Sourcemaps to Sentry + uses: ./.github/actions/upload-sentry-sourcemaps + with: + docker_image: ${{ env.DOCKER_IMAGE }} + release_version: ${{ inputs.release_version }} + sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/welcome-new-contributors.yml b/.github/workflows/welcome-new-contributors.yml deleted file mode 100644 index 332a34e04ad1..000000000000 --- a/.github/workflows/welcome-new-contributors.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: "Welcome new contributors" - -on: - issues: - types: opened - pull_request_target: - types: opened - -permissions: - pull-requests: write - issues: write - -jobs: - welcome-message: - name: Welcoming New Users - runs-on: ubuntu-latest - timeout-minutes: 10 - if: github.event.action == 'opened' - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 - with: - egress-policy: audit - - - uses: actions/first-interaction@3c71ce730280171fd1cfb57c00c774f8998586f7 # v1 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - pr-message: |- - Thank you so much for making your first Pull Request and taking the time to improve Formbricks! 🚀🙏❤️ - Feel free to join the conversation on [Github Discussions](https://github.com/formbricks/formbricks/discussions) if you need any help or have any questions. 😊 - issue-message: | - Thank you for opening your first issue! 🙏❤️ One of our team members will review it and get back to you as soon as it possible. 😊 diff --git a/.gitignore b/.gitignore index aa874edc93f4..37511a65364b 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,5 @@ infra/terraform/.terraform/ # IntelliJ IDEA /.idea/ /*.iml +packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata +.cursorrules diff --git a/.husky/pre-commit b/.husky/pre-commit index 51573b039b3c..7c3821438cff 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -16,6 +16,6 @@ if [ -f branch.json ]; then echo "Skipping tolgee-pull: NEXT_PUBLIC_TOLGEE_API_KEY is not set" else pnpm run tolgee-pull - git add packages/lib/messages + git add apps/web/locales fi fi \ No newline at end of file diff --git a/.tolgeerc.json b/.tolgeerc.json index 40b57ad40bfa..ea22f947e079 100644 --- a/.tolgeerc.json +++ b/.tolgeerc.json @@ -4,33 +4,37 @@ "patterns": ["./apps/web/**/*.ts?(x)"], "projectId": 10304, "pull": { - "path": "./packages/lib/messages" + "path": "./apps/web/locales" }, "push": { "files": [ { "language": "en-US", - "path": "./packages/lib/messages/en-US.json" + "path": "./apps/web/locales/en-US.json" }, { "language": "de-DE", - "path": "./packages/lib/messages/de-DE.json" + "path": "./apps/web/locales/de-DE.json" }, { "language": "fr-FR", - "path": "./packages/lib/messages/fr-FR.json" + "path": "./apps/web/locales/fr-FR.json" }, { "language": "pt-BR", - "path": "./packages/lib/messages/pt-BR.json" + "path": "./apps/web/locales/pt-BR.json" }, { "language": "zh-Hant-TW", - "path": "./packages/lib/messages/zh-Hant-TW.json" + "path": "./apps/web/locales/zh-Hant-TW.json" }, { "language": "pt-PT", - "path": "./packages/lib/messages/pt-PT.json" + "path": "./apps/web/locales/pt-PT.json" + }, + { + "language": "ro-RO", + "path": "./apps/web/locales/ro-RO.json" } ], "forceMode": "OVERRIDE" diff --git a/.vscode/settings.json b/.vscode/settings.json index 759352dc653b..10bac75fe3e2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,10 @@ { + "javascript.updateImportsOnFileMove.enabled": "always", "sonarlint.connectedMode.project": { "connectionId": "formbricks", "projectKey": "formbricks_formbricks" }, "typescript.preferences.importModuleSpecifier": "non-relative", - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.updateImportsOnFileMove.enabled": "always" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6563dae2aa51..13e4eb4e5fdb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,17 +14,7 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri ## 🛠 Crafting Pull Requests -Ready to dive into the code and make a real impact? Here's your path: - -1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/developer-docs/contributing/get-started) but will help you save hours 🤓 - -1. **Fork the Repository:** Fork our repository or use [Gitpod](https://gitpod.io) or use [Github Codespaces](https://github.com/features/codespaces) to get started instantly. - -1. **Tweak and Transform:** Work your coding magic and apply your changes. - -1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏 - -Would you prefer a chat before you dive into a lot of work? [Github Discussions](https://github.com/formbricks/formbricks/discussions) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise! +For the time being, we don't have the capacity to properly facilitate community contributions. It's a lot of engineering attention often spent on issues which don't follow our prioritization, so we've decided to only facilitate community code contributions in rare exceptions in the coming months. ## 🚀 Aspiring Features diff --git a/LICENSE b/LICENSE index ba3fe0ff35af..b56a41bfa5d4 100644 --- a/LICENSE +++ b/LICENSE @@ -3,7 +3,7 @@ Copyright (c) 2024 Formbricks GmbH Portions of this software are licensed as follows: - All content that resides under the "apps/web/modules/ee" directory of this repository, if these directories exist, is licensed under the license defined in "apps/web/modules/ee/LICENSE". -- All content that resides under the "packages/js/", "packages/react-native/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages. +- All content that resides under the "packages/js/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages. - All third party components incorporated into the Formbricks Software are licensed under the original license provided by the owner of the applicable component. - Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below. diff --git a/README.md b/README.md index 91345fa99c0f..0672946a3c3f 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ Here are a few options: - Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap. -Please check out [our contribution guide](https://formbricks.com/docs/developer-docs/contributing/get-started) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information. +- Note: For the time being, we can only facilitate code contributions as an exception. ## All Thanks To Our Contributors diff --git a/apps/demo-react-native/.env.example b/apps/demo-react-native/.env.example deleted file mode 100644 index 340aecb34178..000000000000 --- a/apps/demo-react-native/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -EXPO_PUBLIC_APP_URL=http://192.168.0.197:3000 -EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=cm5p0cs7r000819182b32j0a1 \ No newline at end of file diff --git a/apps/demo-react-native/.eslintrc.js b/apps/demo-react-native/.eslintrc.js deleted file mode 100644 index 4d8dbbccec91..000000000000 --- a/apps/demo-react-native/.eslintrc.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - extends: ["@formbricks/eslint-config/react.js"], - parserOptions: { - project: "tsconfig.json", - tsconfigRootDir: __dirname, - }, -}; diff --git a/apps/demo-react-native/.gitignore b/apps/demo-react-native/.gitignore deleted file mode 100644 index 05647d55c752..000000000000 --- a/apps/demo-react-native/.gitignore +++ /dev/null @@ -1,35 +0,0 @@ -# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files - -# dependencies -node_modules/ - -# Expo -.expo/ -dist/ -web-build/ - -# Native -*.orig.* -*.jks -*.p8 -*.p12 -*.key -*.mobileprovision - -# Metro -.metro-health-check* - -# debug -npm-debug.* -yarn-debug.* -yarn-error.* - -# macOS -.DS_Store -*.pem - -# local env files -.env*.local - -# typescript -*.tsbuildinfo diff --git a/apps/demo-react-native/.npmrc b/apps/demo-react-native/.npmrc deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/apps/demo-react-native/app.json b/apps/demo-react-native/app.json deleted file mode 100644 index 31d6cb2a53a8..000000000000 --- a/apps/demo-react-native/app.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "expo": { - "android": { - "adaptiveIcon": { - "backgroundColor": "#ffffff", - "foregroundImage": "./assets/adaptive-icon.png" - } - }, - "assetBundlePatterns": ["**/*"], - "icon": "./assets/icon.png", - "ios": { - "infoPlist": { - "NSCameraUsageDescription": "Take pictures for certain activities.", - "NSMicrophoneUsageDescription": "Need microphone access for recording videos.", - "NSPhotoLibraryUsageDescription": "Select pictures for certain activities." - }, - "supportsTablet": true - }, - "jsEngine": "hermes", - "name": "react-native-demo", - "newArchEnabled": true, - "orientation": "portrait", - "slug": "react-native-demo", - "splash": { - "backgroundColor": "#ffffff", - "image": "./assets/splash.png", - "resizeMode": "contain" - }, - "userInterfaceStyle": "light", - "version": "1.0.0", - "web": { - "favicon": "./assets/favicon.png" - } - } -} diff --git a/apps/demo-react-native/assets/adaptive-icon.png b/apps/demo-react-native/assets/adaptive-icon.png deleted file mode 100644 index 03d6f6b6c672..000000000000 Binary files a/apps/demo-react-native/assets/adaptive-icon.png and /dev/null differ diff --git a/apps/demo-react-native/assets/favicon.png b/apps/demo-react-native/assets/favicon.png deleted file mode 100644 index e75f697b1801..000000000000 Binary files a/apps/demo-react-native/assets/favicon.png and /dev/null differ diff --git a/apps/demo-react-native/assets/icon.png b/apps/demo-react-native/assets/icon.png deleted file mode 100644 index a0b1526fc7b7..000000000000 Binary files a/apps/demo-react-native/assets/icon.png and /dev/null differ diff --git a/apps/demo-react-native/assets/splash.png b/apps/demo-react-native/assets/splash.png deleted file mode 100644 index 0e89705a9436..000000000000 Binary files a/apps/demo-react-native/assets/splash.png and /dev/null differ diff --git a/apps/demo-react-native/babel.config.js b/apps/demo-react-native/babel.config.js deleted file mode 100644 index 29433509d7b8..000000000000 --- a/apps/demo-react-native/babel.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = function babel(api) { - api.cache(true); - return { - presets: ["babel-preset-expo"], - }; -}; diff --git a/apps/demo-react-native/index.js b/apps/demo-react-native/index.js deleted file mode 100644 index c2ccbfc1d6a4..000000000000 --- a/apps/demo-react-native/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import { registerRootComponent } from "expo"; -import { LogBox } from "react-native"; -import App from "./src/app"; - -registerRootComponent(App); - -LogBox.ignoreAllLogs(); diff --git a/apps/demo-react-native/metro.config.js b/apps/demo-react-native/metro.config.js deleted file mode 100644 index 6bd167c023ba..000000000000 --- a/apps/demo-react-native/metro.config.js +++ /dev/null @@ -1,21 +0,0 @@ -// Learn more https://docs.expo.io/guides/customizing-metro -const path = require("node:path"); -const { getDefaultConfig } = require("expo/metro-config"); - -// Find the workspace root, this can be replaced with `find-yarn-workspace-root` -const workspaceRoot = path.resolve(__dirname, "../.."); -const projectRoot = __dirname; - -const config = getDefaultConfig(projectRoot); - -// 1. Watch all files within the monorepo -config.watchFolders = [workspaceRoot]; -// 2. Let Metro know where to resolve packages, and in what order -config.resolver.nodeModulesPaths = [ - path.resolve(projectRoot, "node_modules"), - path.resolve(workspaceRoot, "node_modules"), -]; -// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths` -config.resolver.disableHierarchicalLookup = true; - -module.exports = config; diff --git a/apps/demo-react-native/package.json b/apps/demo-react-native/package.json deleted file mode 100644 index acd06c345104..000000000000 --- a/apps/demo-react-native/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@formbricks/demo-react-native", - "version": "1.0.0", - "main": "./index.js", - "scripts": { - "dev": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", - "web": "expo start --web", - "eject": "expo eject", - "clean": "rimraf .turbo node_modules .expo" - }, - "dependencies": { - "@formbricks/js": "workspace:*", - "@formbricks/react-native": "workspace:*", - "@react-native-async-storage/async-storage": "2.1.0", - "expo": "52.0.28", - "expo-status-bar": "2.0.1", - "react": "18.3.1", - "react-dom": "18.3.1", - "react-native": "0.76.6", - "react-native-webview": "13.12.5" - }, - "devDependencies": { - "@babel/core": "7.26.0", - "@types/react": "18.3.18", - "typescript": "5.7.2" - }, - "private": true -} diff --git a/apps/demo-react-native/src/app.tsx b/apps/demo-react-native/src/app.tsx deleted file mode 100644 index a4816481e312..000000000000 --- a/apps/demo-react-native/src/app.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { StatusBar } from "expo-status-bar"; -import React, { type JSX } from "react"; -import { Button, LogBox, StyleSheet, Text, View } from "react-native"; -import Formbricks, { - logout, - setAttribute, - setAttributes, - setLanguage, - setUserId, - track, -} from "@formbricks/react-native"; - -LogBox.ignoreAllLogs(); - -export default function App(): JSX.Element { - if (!process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID) { - throw new Error("EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID is required"); - } - - if (!process.env.EXPO_PUBLIC_APP_URL) { - throw new Error("EXPO_PUBLIC_APP_URL is required"); - } - - return ( - - Formbricks React Native SDK Demo - - - - - -
-
-
-

1. Setup .env

-

- Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env -

- fb setup - -
-

You're connected with env:

-
- - {process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID} - - - - - -
-
-
-
-

2. Widget Logs

-

- Look at the logs to understand how the widget works.{" "} - Open your browser console to see the logs. -

-
-
- -
-
-

- Set a user ID / pull data from Formbricks app -

-

- On formbricks.setUserId() the user state will be fetched from Formbricks and - the local state gets updated with the user state. -

- -

- If you made a change in Formbricks app and it does not seem to work, hit 'Reset' and - try again. -

-
- -
-
- -
-
-

- This button sends a{" "} - - No Code Action - {" "} - as long as you created it beforehand in the Formbricks App.{" "} - - Here are instructions on how to do it. - -

-
-
- -
-
- -
-
-

- This button sets the{" "} - - attribute - {" "} - 'Plan' to 'Free'. If the attribute does not exist, it creates it. -

-
-
-
-
- -
-
-

- This button sets the{" "} - - attribute - {" "} - 'Plan' to 'Paid'. If the attribute does not exist, it creates it. -

-
-
-
-
- -
-
-

- This button sets the{" "} - - user email - {" "} - 'test@web.com' -

-
-
- -
-
- -
-
-

- This button sets the{" "} - - user attributes - {" "} - to 'one', 'two', 'three'. -

-
-
- -
-
- -
-
-

- This button sets the{" "} - - language - {" "} - to 'de'. -

-
-
- -
-
- -
-
-

- This button sends a{" "} - - Code Action - {" "} - as long as you created it beforehand in the Formbricks App.{" "} - - Here are instructions on how to do it. - -

-
-
- -
-
- -
-
-

- This button logs out the user and syncs the local state with Formbricks. (Only works if a - userId is set) -

-
-
-
-
- - ); -} diff --git a/apps/demo/postcss.config.js b/apps/demo/postcss.config.js deleted file mode 100644 index 483f378543c0..000000000000 --- a/apps/demo/postcss.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - plugins: { - "@tailwindcss/postcss": {}, - }, -}; diff --git a/apps/demo/public/favicon.ico b/apps/demo/public/favicon.ico deleted file mode 100644 index 2b175954392e..000000000000 Binary files a/apps/demo/public/favicon.ico and /dev/null differ diff --git a/apps/demo/public/fb-setup.png b/apps/demo/public/fb-setup.png deleted file mode 100644 index 73d50516f0de..000000000000 Binary files a/apps/demo/public/fb-setup.png and /dev/null differ diff --git a/apps/demo/public/next.svg b/apps/demo/public/next.svg deleted file mode 100644 index 5174b28c565c..000000000000 --- a/apps/demo/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/demo/public/thirteen.svg b/apps/demo/public/thirteen.svg deleted file mode 100644 index 8977c1bd123c..000000000000 --- a/apps/demo/public/thirteen.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/demo/public/vercel.svg b/apps/demo/public/vercel.svg deleted file mode 100644 index d2f84222734f..000000000000 --- a/apps/demo/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/demo/tsconfig.json b/apps/demo/tsconfig.json deleted file mode 100644 index d000509d66b5..000000000000 --- a/apps/demo/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "exclude": ["node_modules"], - "extends": "@formbricks/config-typescript/nextjs.json", - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] -} diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 68129e5354ff..fa597f552f3f 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -1,23 +1,25 @@ import type { StorybookConfig } from "@storybook/react-vite"; +import { createRequire } from "module"; import { dirname, join } from "path"; +const require = createRequire(import.meta.url); + /** * This function is used to resolve the absolute path of a package. * It is needed in projects that use Yarn PnP or are set up within a monorepo. */ -const getAbsolutePath = (value: string) => { +function getAbsolutePath(value: string): any { return dirname(require.resolve(join(value, "package.json"))); -}; +} const config: StorybookConfig = { stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"], addons: [ getAbsolutePath("@storybook/addon-onboarding"), getAbsolutePath("@storybook/addon-links"), - getAbsolutePath("@storybook/addon-essentials"), getAbsolutePath("@chromatic-com/storybook"), - getAbsolutePath("@storybook/addon-interactions"), getAbsolutePath("@storybook/addon-a11y"), + getAbsolutePath("@storybook/addon-docs"), ], framework: { name: getAbsolutePath("@storybook/react-vite"), diff --git a/apps/storybook/.storybook/preview.ts b/apps/storybook/.storybook/preview.ts index dfdb46f022ac..a7780cc71e3e 100644 --- a/apps/storybook/.storybook/preview.ts +++ b/apps/storybook/.storybook/preview.ts @@ -1,5 +1,21 @@ -import type { Preview } from "@storybook/react"; +import type { Preview } from "@storybook/react-vite"; +import { TolgeeProvider } from "@tolgee/react"; +import React from "react"; import "../../web/modules/ui/globals.css"; +import { TolgeeBase } from "../../web/tolgee/shared"; + +// Create a Storybook-specific Tolgee decorator +const withTolgee = (Story: any) => { + const tolgee = TolgeeBase().init({ + tagNewKeys: [], // No branch tagging in Storybook + }); + + return React.createElement( + TolgeeProvider, + { tolgee, fallback: "Loading", ssr: { language: "en", staticData: {} } }, + React.createElement(Story) + ); +}; const preview: Preview = { parameters: { @@ -10,6 +26,7 @@ const preview: Preview = { }, }, }, + decorators: [withTolgee], }; export default preview; diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 96966a04f452..33298b1feda0 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -6,35 +6,27 @@ "scripts": { "lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", - "storybook": "storybook dev -p 6006", + "storybook": "PORT_APP_STORYBOOK=`dotenv -e ../../.env -p PORT_APP_STORYBOOK` && storybook dev -p $PORT_APP_STORYBOOK", "build-storybook": "storybook build", "clean": "rimraf .turbo node_modules dist storybook-static" }, "dependencies": { - "eslint-plugin-react-refresh": "0.4.19", - "react": "19.1.0", - "react-dom": "19.1.0" + "eslint-plugin-react-refresh": "0.4.20" }, "devDependencies": { - "@chromatic-com/storybook": "3.2.6", - "@formbricks/config-typescript": "workspace:*", - "@storybook/addon-a11y": "8.6.12", - "@storybook/addon-essentials": "8.6.12", - "@storybook/addon-interactions": "8.6.12", - "@storybook/addon-links": "8.6.12", - "@storybook/addon-onboarding": "8.6.12", - "@storybook/blocks": "8.6.12", - "@storybook/react": "8.6.12", - "@storybook/react-vite": "8.6.12", - "@storybook/test": "8.6.12", - "@typescript-eslint/eslint-plugin": "8.29.1", - "@typescript-eslint/parser": "8.29.1", - "@vitejs/plugin-react": "4.3.4", - "esbuild": "0.25.2", - "eslint-plugin-storybook": "0.12.0", + "@chromatic-com/storybook": "^4.0.1", + "@storybook/addon-a11y": "9.0.15", + "@storybook/addon-links": "9.0.15", + "@storybook/addon-onboarding": "9.0.15", + "@storybook/react-vite": "9.0.15", + "@typescript-eslint/eslint-plugin": "8.32.0", + "@typescript-eslint/parser": "8.32.0", + "@vitejs/plugin-react": "4.4.1", + "esbuild": "0.25.4", + "eslint-plugin-storybook": "9.0.15", "prop-types": "15.8.1", - "storybook": "8.6.12", - "tsup": "8.4.0", - "vite": "6.2.5" + "storybook": "9.0.15", + "vite": "6.3.5", + "@storybook/addon-docs": "9.0.15" } } diff --git a/apps/storybook/src/stories/Configure.mdx b/apps/storybook/src/stories/Configure.mdx index 6969c2bbd543..22d53458f801 100644 --- a/apps/storybook/src/stories/Configure.mdx +++ b/apps/storybook/src/stories/Configure.mdx @@ -1,4 +1,4 @@ -import { Meta } from "@storybook/blocks"; +import { Meta } from "@storybook/addon-docs/blocks"; import Accessibility from "./assets/accessibility.png"; import AddonLibrary from "./assets/addon-library.png"; diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js index 64a6e2985281..8b5dc0e00e5b 100644 --- a/apps/web/.eslintrc.js +++ b/apps/web/.eslintrc.js @@ -1,3 +1,20 @@ module.exports = { extends: ["@formbricks/eslint-config/legacy-next.js"], + ignorePatterns: ["**/package.json", "**/tsconfig.json"], + overrides: [ + { + files: ["locales/*.json"], + plugins: ["i18n-json"], + rules: { + "i18n-json/identical-keys": [ + "error", + { + filePath: require("path").join(__dirname, "locales", "en-US.json"), + checkExtraKeys: false, + checkMissingKeys: true, + }, + ], + }, + }, + ], }; diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 09190cb1b230..8a8922548e28 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -50,4 +50,4 @@ uploads/ .sentryclirc # SAML Preloaded Connections -saml-connection/ \ No newline at end of file +saml-connection/ diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 8805434826b8..48dadbeb2a03 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS base +FROM node:22-alpine3.21 AS base # ## step 1: Prune monorepo @@ -18,27 +18,16 @@ FROM node:22-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a1 FROM base AS installer # Enable corepack and prepare pnpm -RUN npm install -g corepack@latest +RUN npm install --ignore-scripts -g corepack@latest RUN corepack enable +RUN corepack prepare pnpm@9.15.9 --activate # Install necessary build tools and compilers RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3 -# BuildKit secret handling without hardcoded fallback values -# This approach relies entirely on secrets passed from GitHub Actions -RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \ - echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \ - echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \ - echo 'else' >> /tmp/read-secrets.sh && \ - echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \ - echo 'fi' >> /tmp/read-secrets.sh && \ - echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \ - echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \ - echo 'else' >> /tmp/read-secrets.sh && \ - echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \ - echo 'fi' >> /tmp/read-secrets.sh && \ - echo 'exec "$@"' >> /tmp/read-secrets.sh && \ - chmod +x /tmp/read-secrets.sh +# Copy the secrets handling script +COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh +RUN chmod +x /tmp/read-secrets.sh # Increase Node.js memory limit as a regular build argument ARG NODE_OPTIONS="--max_old_space_size=4096" @@ -59,7 +48,10 @@ COPY . . RUN touch apps/web/.env # Install the dependencies -RUN pnpm install +RUN pnpm install --ignore-scripts + +# Build the database package first +RUN pnpm build --filter=@formbricks/database # Build the project using our secret reader script # This mounts the secrets only during this build step without storing them in layers @@ -75,19 +67,20 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver # FROM base AS runner -RUN npm install -g corepack@latest +RUN npm install --ignore-scripts -g corepack@latest RUN corepack enable RUN apk add --no-cache curl \ && apk add --no-cache supercronic \ # && addgroup --system --gid 1001 nodejs \ - && adduser --system --uid 1001 nextjs + && addgroup -S nextjs \ + && adduser -S -u 1001 -G nextjs nextjs WORKDIR /home/nextjs # Ensure no write permissions are assigned to the copied resources -COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./ -RUN chmod -R 755 ./ +COPY --from=installer /app/apps/web/.next/standalone ./ +RUN chown -R nextjs:nextjs ./ && chmod -R 755 ./ COPY --from=installer /app/apps/web/next.config.mjs . RUN chmod 644 ./next.config.mjs @@ -95,38 +88,26 @@ RUN chmod 644 ./next.config.mjs COPY --from=installer /app/apps/web/package.json . RUN chmod 644 ./package.json -COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static -RUN chmod -R 755 ./apps/web/.next/static - -COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public -RUN chmod -R 755 ./apps/web/public - -COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma -RUN chmod 644 ./packages/database/schema.prisma +COPY --from=installer /app/apps/web/.next/static ./apps/web/.next/static +RUN chown -R nextjs:nextjs ./apps/web/.next/static && chmod -R 755 ./apps/web/.next/static -COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json -RUN chmod 644 ./packages/database/package.json +COPY --from=installer /app/apps/web/public ./apps/web/public +RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public -COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration -RUN chmod -R 755 ./packages/database/migration +COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma +RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma -COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src -RUN chmod -R 755 ./packages/database/src +COPY --from=installer /app/packages/database/dist ./packages/database/dist +RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist -COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules -RUN chmod -R 755 ./packages/database/node_modules +COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client +RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client -COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist -RUN chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist +COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma +RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma -COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client -RUN chmod -R 755 ./node_modules/@prisma/client - -COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma -RUN chmod -R 755 ./node_modules/.prisma - -COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt . -RUN chmod 644 ./prisma_version.txt +COPY --from=installer /prisma_version.txt . +RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt COPY /docker/cronjobs /app/docker/cronjobs RUN chmod -R 755 /app/docker/cronjobs @@ -140,12 +121,15 @@ RUN chmod -R 755 ./node_modules/@noble/hashes COPY --from=installer /app/node_modules/zod ./node_modules/zod RUN chmod -R 755 ./node_modules/zod -RUN npm install -g tsx typescript prisma pino-pretty +RUN npm install -g prisma + +# Create a startup script to handle the conditional logic +COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh +RUN chown nextjs:nextjs /home/nextjs/start.sh && chmod +x /home/nextjs/start.sh EXPOSE 3000 -ENV HOSTNAME "0.0.0.0" -ENV NODE_ENV="production" -# USER nextjs +ENV HOSTNAME="0.0.0.0" +USER nextjs # Prepare volume for uploads RUN mkdir -p /home/nextjs/apps/web/uploads/ @@ -155,12 +139,4 @@ VOLUME /home/nextjs/apps/web/uploads/ RUN mkdir -p /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection -CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \ - echo "Starting cron jobs..."; \ - supercronic -quiet /app/docker/cronjobs & \ - else \ - echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \ - fi; \ - (cd packages/database && npm run db:migrate:deploy) && \ - (cd packages/database && npm run db:create-saml-database:deploy) && \ - exec node apps/web/server.js \ No newline at end of file +CMD ["/home/nextjs/start.sh"] \ No newline at end of file diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks.test.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks.test.tsx new file mode 100644 index 000000000000..a0d9d0e4ef06 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks.test.tsx @@ -0,0 +1,79 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ConnectWithFormbricks } from "./ConnectWithFormbricks"; + +// Mocks before import +const pushMock = vi.fn(); +const refreshMock = vi.fn(); +vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) })); +vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock, refresh: refreshMock })) })); +vi.mock("./OnboardingSetupInstructions", () => ({ + OnboardingSetupInstructions: () =>
, +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("ConnectWithFormbricks", () => { + const environment = { id: "env1" } as any; + const webAppUrl = "http://app"; + const channel = {} as any; + + test("renders waiting state when widgetSetupCompleted is false", () => { + render( + + ); + expect(screen.getByTestId("instructions")).toBeInTheDocument(); + expect(screen.getByText("environments.connect.waiting_for_your_signal")).toBeInTheDocument(); + }); + + test("renders success state when widgetSetupCompleted is true", () => { + render( + + ); + expect(screen.getByText("environments.connect.congrats")).toBeInTheDocument(); + expect(screen.getByText("environments.connect.connection_successful_message")).toBeInTheDocument(); + }); + + test("clicking finish button navigates to surveys", async () => { + render( + + ); + const button = screen.getByRole("button", { name: "environments.connect.finish_onboarding" }); + await userEvent.click(button); + expect(pushMock).toHaveBeenCalledWith(`/environments/${environment.id}/surveys`); + }); + + test("refresh is called on visibilitychange to visible", () => { + render( + + ); + Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true }); + document.dispatchEvent(new Event("visibilitychange")); + expect(refreshMock).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks.tsx index 1010f5a939a5..f0c6b91bf634 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks.tsx +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks.tsx @@ -1,25 +1,25 @@ "use client"; +import { cn } from "@/lib/cn"; import { Button } from "@/modules/ui/components/button"; import { useTranslate } from "@tolgee/react"; import { ArrowRight } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; -import { cn } from "@formbricks/lib/cn"; import { TEnvironment } from "@formbricks/types/environment"; import { TProjectConfigChannel } from "@formbricks/types/project"; import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions"; interface ConnectWithFormbricksProps { environment: TEnvironment; - webAppUrl: string; + publicDomain: string; widgetSetupCompleted: boolean; channel: TProjectConfigChannel; } export const ConnectWithFormbricks = ({ environment, - webAppUrl, + publicDomain, widgetSetupCompleted, channel, }: ConnectWithFormbricksProps) => { @@ -49,7 +49,7 @@ export const ConnectWithFormbricks = ({
diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.test.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.test.tsx index d26c801406f7..5d2d3967f208 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.test.tsx +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.test.tsx @@ -33,7 +33,7 @@ describe("OnboardingSetupInstructions", () => { // Provide some default props for testing const defaultProps = { environmentId: "env-123", - webAppUrl: "https://example.com", + publicDomain: "https://example.com", channel: "app" as const, // Assuming channel is either "app" or "website" widgetSetupCompleted: false, }; diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx index 7ceb44322ac3..c9aba6021645 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/components/OnboardingSetupInstructions.tsx @@ -18,14 +18,14 @@ const tabs = [ interface OnboardingSetupInstructionsProps { environmentId: string; - webAppUrl: string; + publicDomain: string; channel: TProjectConfigChannel; widgetSetupCompleted: boolean; } export const OnboardingSetupInstructions = ({ environmentId, - webAppUrl, + publicDomain, channel, widgetSetupCompleted, }: OnboardingSetupInstructionsProps) => { @@ -34,7 +34,7 @@ export const OnboardingSetupInstructions = ({ const htmlSnippetForAppSurveys = ` @@ -44,7 +44,7 @@ export const OnboardingSetupInstructions = ({ const htmlSnippetForWebsiteSurveys = ` @@ -57,7 +57,7 @@ export const OnboardingSetupInstructions = ({ if (typeof window !== "undefined") { formbricks.setup({ environmentId: "${environmentId}", - appUrl: "${webAppUrl}", + appUrl: "${publicDomain}", }); } @@ -75,7 +75,7 @@ export const OnboardingSetupInstructions = ({ if (typeof window !== "undefined") { formbricks.setup({ environmentId: "${environmentId}", - appUrl: "${webAppUrl}", + appUrl: "${publicDomain}", }); } diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/page.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/page.tsx index cee212d799c0..c15171888403 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/page.tsx +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/connect/page.tsx @@ -1,12 +1,12 @@ import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks"; +import { getEnvironment } from "@/lib/environment/service"; +import { getPublicDomain } from "@/lib/getPublicUrl"; +import { getProjectByEnvironmentId } from "@/lib/project/service"; import { Button } from "@/modules/ui/components/button"; import { Header } from "@/modules/ui/components/header"; import { getTranslate } from "@/tolgee/server"; import { XIcon } from "lucide-react"; import Link from "next/link"; -import { WEBAPP_URL } from "@formbricks/lib/constants"; -import { getEnvironment } from "@formbricks/lib/environment/service"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; interface ConnectPageProps { params: Promise<{ @@ -30,6 +30,8 @@ const Page = async (props: ConnectPageProps) => { const channel = project.config.channel || null; + const publicDomain = getPublicDomain(); + return (
@@ -39,7 +41,7 @@ const Page = async (props: ConnectPageProps) => {
diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/layout.test.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/layout.test.tsx new file mode 100644 index 000000000000..a74d0204df4d --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/layout.test.tsx @@ -0,0 +1,147 @@ +import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; +import { cleanup, render, screen } from "@testing-library/react"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import OnboardingLayout from "./layout"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + IS_PRODUCTION: false, + IS_DEVELOPMENT: true, + E2E_TESTING: false, + WEBAPP_URL: "http://localhost:3000", + PUBLIC_URL: "http://localhost:3000/survey", + ENCRYPTION_KEY: "mock-encryption-key", + CRON_SECRET: "mock-cron-secret", + DEFAULT_BRAND_COLOR: "#64748b", + FB_LOGO_URL: "https://mock-logo-url.com/logo.png", + PRIVACY_URL: "http://localhost:3000/privacy", + TERMS_URL: "http://localhost:3000/terms", + IMPRINT_URL: "http://localhost:3000/imprint", + IMPRINT_ADDRESS: "Mock Address", + PASSWORD_RESET_DISABLED: false, + EMAIL_VERIFICATION_DISABLED: false, + GOOGLE_OAUTH_ENABLED: false, + GITHUB_OAUTH_ENABLED: false, + AZURE_OAUTH_ENABLED: false, + OIDC_OAUTH_ENABLED: false, + SAML_OAUTH_ENABLED: false, + SAML_XML_DIR: "./mock-saml-connection", + SIGNUP_ENABLED: true, + EMAIL_AUTH_ENABLED: true, + INVITE_DISABLED: false, + SLACK_CLIENT_SECRET: "mock-slack-secret", + SLACK_CLIENT_ID: "mock-slack-id", + SLACK_AUTH_URL: "https://mock-slack-auth-url.com", + GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id", + GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret", + GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect", + NOTION_OAUTH_CLIENT_ID: "mock-notion-id", + NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret", + NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect", + NOTION_AUTH_URL: "https://mock-notion-auth-url.com", + AIRTABLE_CLIENT_ID: "mock-airtable-id", + SMTP_HOST: "mock-smtp-host", + SMTP_PORT: "587", + SMTP_SECURE_ENABLED: false, + SMTP_USER: "mock-smtp-user", + SMTP_PASSWORD: "mock-smtp-password", + SMTP_AUTHENTICATED: true, + SMTP_REJECT_UNAUTHORIZED_TLS: true, + MAIL_FROM: "mock@mail.com", + MAIL_FROM_NAME: "Mock Mail", + NEXTAUTH_SECRET: "mock-nextauth-secret", + ITEMS_PER_PAGE: 30, + SURVEYS_PER_PAGE: 12, + RESPONSES_PER_PAGE: 25, + TEXT_RESPONSES_PER_PAGE: 5, + INSIGHTS_PER_PAGE: 10, + DOCUMENTS_PER_PAGE: 10, + MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500, + MAX_OTHER_OPTION_LENGTH: 250, + ENTERPRISE_LICENSE_KEY: "ABC", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "mock-github-secret", + GITHUB_OAUTH_URL: "https://mock-github-auth-url.com", + AZURE_ID: "mock-azure-id", + AZUREAD_CLIENT_ID: "mock-azure-client-id", + AZUREAD_CLIENT_SECRET: "mock-azure-client-secret", + GOOGLE_CLIENT_ID: "mock-google-client-id", + GOOGLE_CLIENT_SECRET: "mock-google-client-secret", + GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com", + AZURE_OAUTH_URL: "https://mock-azure-auth-url.com", + OIDC_ID: "mock-oidc-id", + OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com", + SAML_ID: "mock-saml-id", + SAML_OAUTH_URL: "https://mock-saml-auth-url.com", + SAML_METADATA_URL: "https://mock-saml-metadata-url.com", + AZUREAD_TENANT_ID: "mock-azure-tenant-id", + AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com", + OIDC_DISPLAY_NAME: "Mock OIDC", + OIDC_CLIENT_ID: "mock-oidc-client-id", + OIDC_CLIENT_SECRET: "mock-oidc-client-secret", + OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect", + OIDC_AUTH_URL: "https://mock-oidc-auth-url.com", + OIDC_ISSUER: "https://mock-oidc-issuer.com", + OIDC_SIGNING_ALGORITHM: "RS256", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: true, +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("@/lib/environment/auth", () => ({ + hasUserEnvironmentAccess: vi.fn(), +})); + +describe("OnboardingLayout", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("redirects to login if session is missing", async () => { + vi.mocked(getServerSession).mockResolvedValueOnce(null); + + await OnboardingLayout({ + params: { environmentId: "env1" }, + children:
Test Content
, + }); + + expect(redirect).toHaveBeenCalledWith("/auth/login"); + }); + + test("throws AuthorizationError if user lacks access", async () => { + vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } }); + vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false); + + await expect( + OnboardingLayout({ + params: { environmentId: "env1" }, + children:
Test Content
, + }) + ).rejects.toThrow("User is not authorized to access this environment"); + }); + + test("renders children if user has access", async () => { + vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } }); + vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true); + + const result = await OnboardingLayout({ + params: { environmentId: "env1" }, + children:
Test Content
, + }); + + render(result); + + expect(screen.getByTestId("child")).toHaveTextContent("Test Content"); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/layout.tsx index 9e9b19810bb8..ad9c6e813c13 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/layout.tsx +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/layout.tsx @@ -1,7 +1,7 @@ +import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { AuthorizationError } from "@formbricks/types/errors"; const OnboardingLayout = async (props) => { @@ -16,7 +16,7 @@ const OnboardingLayout = async (props) => { const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId); if (!isAuthorized) { - throw AuthorizationError; + throw new AuthorizationError("User is not authorized to access this environment"); } return
{children}
; diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList.test.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList.test.tsx new file mode 100644 index 000000000000..b6c2fc638551 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList.test.tsx @@ -0,0 +1,76 @@ +import { createSurveyAction } from "@/modules/survey/components/template-list/actions"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { XMTemplateList } from "./XMTemplateList"; + +// Prepare push mock and module mocks before importing component +const pushMock = vi.fn(); +vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) })); +vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock })) })); +vi.mock("react-hot-toast", () => ({ default: { error: vi.fn() } })); +vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates", () => ({ + getXMTemplates: (t: any) => [ + { id: 1, name: "tmpl1" }, + { id: 2, name: "tmpl2" }, + ], +})); +vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils", () => ({ + replacePresetPlaceholders: (template: any, project: any) => ({ ...template, projectId: project.id }), +})); +vi.mock("@/modules/survey/components/template-list/actions", () => ({ createSurveyAction: vi.fn() })); +vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" })); +vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({ + OnboardingOptionsContainer: ({ options }: { options: any[] }) => ( +
+ {options.map((opt, idx) => ( + + ))} +
+ ), +})); + +// Reset mocks between tests +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("XMTemplateList component", () => { + const project = { id: "proj1" } as any; + const user = { id: "user1" } as any; + const environmentId = "env1"; + + test("creates survey and navigates on success", async () => { + // Mock successful survey creation + vi.mocked(createSurveyAction).mockResolvedValue({ data: { id: "survey1" } } as any); + + render(); + + const option0 = screen.getByTestId("option-0"); + await userEvent.click(option0); + + expect(createSurveyAction).toHaveBeenCalledWith({ + environmentId, + surveyBody: expect.objectContaining({ id: 1, projectId: "proj1", type: "link", createdBy: "user1" }), + }); + expect(pushMock).toHaveBeenCalledWith(`/environments/${environmentId}/surveys/survey1/edit?mode=cx`); + }); + + test("shows error toast on failure", async () => { + // Mock failed survey creation + vi.mocked(createSurveyAction).mockResolvedValue({ error: "err" } as any); + + render(); + + const option1 = screen.getByTestId("option-1"); + await userEvent.click(option1); + + expect(createSurveyAction).toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith("formatted-error"); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils.test.ts b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils.test.ts new file mode 100644 index 000000000000..817dbec6cabc --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils.test.ts @@ -0,0 +1,80 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { TProject } from "@formbricks/types/project"; +import { TXMTemplate } from "@formbricks/types/templates"; +import { replacePresetPlaceholders } from "./utils"; + +// Mock data +const mockProject: TProject = { + id: "project1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Project", + organizationId: "org1", + styling: { + allowStyleOverwrite: true, + brandColor: { light: "#FFFFFF" }, + }, + recontactDays: 30, + inAppSurveyBranding: true, + linkSurveyBranding: true, + config: { + channel: "link" as const, + industry: "eCommerce" as "eCommerce" | "saas" | "other" | null, + }, + placement: "bottomRight", + clickOutsideClose: true, + darkOverlay: false, + environments: [], + languages: [], + logo: null, +}; +const mockTemplate: TXMTemplate = { + name: "$[projectName] Survey", + questions: [ + { + id: "q1", + inputType: "text", + type: "email" as any, + headline: { default: "$[projectName] Question" }, + required: false, + charLimit: { enabled: true, min: 400, max: 1000 }, + }, + ], + endings: [ + { + id: "e1", + type: "endScreen", + headline: { default: "Thank you for completing the survey!" }, + }, + ], + styling: { + brandColor: { light: "#0000FF" }, + questionColor: { light: "#00FF00" }, + inputColor: { light: "#FF0000" }, + }, +}; + +describe("replacePresetPlaceholders", () => { + afterEach(() => { + cleanup(); + }); + + test("replaces projectName placeholder in template name", () => { + const result = replacePresetPlaceholders(mockTemplate, mockProject); + expect(result.name).toBe("Test Project Survey"); + }); + + test("replaces projectName placeholder in question headline", () => { + const result = replacePresetPlaceholders(mockTemplate, mockProject); + expect(result.questions[0].headline.default).toBe("Test Project Question"); + }); + + test("returns a new object without mutating the original template", () => { + const originalTemplate = structuredClone(mockTemplate); + const result = replacePresetPlaceholders(mockTemplate, mockProject); + expect(result).not.toBe(mockTemplate); + expect(mockTemplate).toEqual(originalTemplate); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils.ts b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils.ts index 6940e1ed327a..f45fdc11bffc 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils.ts +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils.ts @@ -1,4 +1,4 @@ -import { replaceQuestionPresetPlaceholders } from "@formbricks/lib/utils/templates"; +import { replaceQuestionPresetPlaceholders } from "@/lib/utils/templates"; import { TProject } from "@formbricks/types/project"; import { TXMTemplate } from "@formbricks/types/templates"; diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.test.ts b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.test.ts new file mode 100644 index 000000000000..215f72ae29fb --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.test.ts @@ -0,0 +1,60 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/preact"; +import { TFnType } from "@tolgee/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { getXMSurveyDefault, getXMTemplates } from "./xm-templates"; + +vi.mock("@formbricks/logger", () => ({ + logger: { error: vi.fn() }, +})); + +describe("xm-templates", () => { + afterEach(() => { + cleanup(); + }); + + test("getXMSurveyDefault returns default survey template", () => { + const tMock = vi.fn((key) => key) as TFnType; + const result = getXMSurveyDefault(tMock); + + expect(result).toEqual({ + name: "", + endings: expect.any(Array), + questions: [], + styling: { + overwriteThemeStyling: true, + }, + }); + expect(result.endings).toHaveLength(1); + }); + + test("getXMTemplates returns all templates", () => { + const tMock = vi.fn((key) => key) as TFnType; + const result = getXMTemplates(tMock); + + expect(result).toHaveLength(6); + expect(result[0].name).toBe("templates.nps_survey_name"); + expect(result[1].name).toBe("templates.star_rating_survey_name"); + expect(result[2].name).toBe("templates.csat_survey_name"); + expect(result[3].name).toBe("templates.cess_survey_name"); + expect(result[4].name).toBe("templates.smileys_survey_name"); + expect(result[5].name).toBe("templates.enps_survey_name"); + }); + + test("getXMTemplates handles errors gracefully", async () => { + const tMock = vi.fn(() => { + throw new Error("Test error"); + }) as TFnType; + + const result = getXMTemplates(tMock); + + // Dynamically import the mocked logger + const { logger } = await import("@formbricks/logger"); + + expect(result).toEqual([]); + expect(logger.error).toHaveBeenCalledWith( + expect.any(Error), + "Unable to load XM templates, returning empty array" + ); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts index fa11c26f11d1..5fb64cfabc91 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates.ts @@ -1,8 +1,13 @@ -import { getDefaultEndingCard } from "@/app/lib/templates"; +import { + buildCTAQuestion, + buildNPSQuestion, + buildOpenTextQuestion, + buildRatingQuestion, + getDefaultEndingCard, +} from "@/app/lib/survey-builder"; import { createId } from "@paralleldrive/cuid2"; import { TFnType } from "@tolgee/react"; import { logger } from "@formbricks/logger"; -import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { TXMTemplate } from "@formbricks/types/templates"; export const getXMSurveyDefault = (t: TFnType): TXMTemplate => { @@ -26,35 +31,26 @@ const npsSurvey = (t: TFnType): TXMTemplate => { ...getXMSurveyDefault(t), name: t("templates.nps_survey_name"), questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.NPS, - headline: { default: t("templates.nps_survey_question_1_headline") }, + buildNPSQuestion({ + headline: t("templates.nps_survey_question_1_headline"), required: true, - lowerLabel: { default: t("templates.nps_survey_question_1_lower_label") }, - upperLabel: { default: t("templates.nps_survey_question_1_upper_label") }, + lowerLabel: t("templates.nps_survey_question_1_lower_label"), + upperLabel: t("templates.nps_survey_question_1_upper_label"), isColorCodingEnabled: true, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: t("templates.nps_survey_question_2_headline") }, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.nps_survey_question_2_headline"), required: false, inputType: "text", - charLimit: { - enabled: false, - }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: t("templates.nps_survey_question_3_headline") }, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.nps_survey_question_3_headline"), required: false, inputType: "text", - charLimit: { - enabled: false, - }, - }, + t, + }), ], }; }; @@ -67,9 +63,8 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => { ...defaultSurvey, name: t("templates.star_rating_survey_name"), questions: [ - { + buildRatingQuestion({ id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.Rating, logic: [ { id: createId(), @@ -102,16 +97,15 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => { ], range: 5, scale: "number", - headline: { default: t("templates.star_rating_survey_question_1_headline") }, + headline: t("templates.star_rating_survey_question_1_headline"), required: true, - lowerLabel: { default: t("templates.star_rating_survey_question_1_lower_label") }, - upperLabel: { default: t("templates.star_rating_survey_question_1_upper_label") }, - isColorCodingEnabled: false, - }, - { + lowerLabel: t("templates.star_rating_survey_question_1_lower_label"), + upperLabel: t("templates.star_rating_survey_question_1_upper_label"), + t, + }), + buildCTAQuestion({ id: reusableQuestionIds[1], - html: { default: t("templates.star_rating_survey_question_2_html") }, - type: TSurveyQuestionTypeEnum.CTA, + html: t("templates.star_rating_survey_question_2_html"), logic: [ { id: createId(), @@ -138,25 +132,23 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => { ], }, ], - headline: { default: t("templates.star_rating_survey_question_2_headline") }, + headline: t("templates.star_rating_survey_question_2_headline"), required: true, buttonUrl: "https://formbricks.com/github", - buttonLabel: { default: t("templates.star_rating_survey_question_2_button_label") }, + buttonLabel: t("templates.star_rating_survey_question_2_button_label"), buttonExternal: true, - }, - { + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: t("templates.star_rating_survey_question_3_headline") }, + headline: t("templates.star_rating_survey_question_3_headline"), required: true, - subheader: { default: t("templates.star_rating_survey_question_3_subheader") }, - buttonLabel: { default: t("templates.star_rating_survey_question_3_button_label") }, - placeholder: { default: t("templates.star_rating_survey_question_3_placeholder") }, + subheader: t("templates.star_rating_survey_question_3_subheader"), + buttonLabel: t("templates.star_rating_survey_question_3_button_label"), + placeholder: t("templates.star_rating_survey_question_3_placeholder"), inputType: "text", - charLimit: { - enabled: false, - }, - }, + t, + }), ], }; }; @@ -169,9 +161,8 @@ const csatSurvey = (t: TFnType): TXMTemplate => { ...defaultSurvey, name: t("templates.csat_survey_name"), questions: [ - { + buildRatingQuestion({ id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.Rating, logic: [ { id: createId(), @@ -204,15 +195,14 @@ const csatSurvey = (t: TFnType): TXMTemplate => { ], range: 5, scale: "smiley", - headline: { default: t("templates.csat_survey_question_1_headline") }, + headline: t("templates.csat_survey_question_1_headline"), required: true, - lowerLabel: { default: t("templates.csat_survey_question_1_lower_label") }, - upperLabel: { default: t("templates.csat_survey_question_1_upper_label") }, - isColorCodingEnabled: false, - }, - { + lowerLabel: t("templates.csat_survey_question_1_lower_label"), + upperLabel: t("templates.csat_survey_question_1_upper_label"), + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.OpenText, logic: [ { id: createId(), @@ -239,25 +229,20 @@ const csatSurvey = (t: TFnType): TXMTemplate => { ], }, ], - headline: { default: t("templates.csat_survey_question_2_headline") }, + headline: t("templates.csat_survey_question_2_headline"), required: false, - placeholder: { default: t("templates.csat_survey_question_2_placeholder") }, + placeholder: t("templates.csat_survey_question_2_placeholder"), inputType: "text", - charLimit: { - enabled: false, - }, - }, - { + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: t("templates.csat_survey_question_3_headline") }, + headline: t("templates.csat_survey_question_3_headline"), required: false, - placeholder: { default: t("templates.csat_survey_question_3_placeholder") }, + placeholder: t("templates.csat_survey_question_3_placeholder"), inputType: "text", - charLimit: { - enabled: false, - }, - }, + t, + }), ], }; }; @@ -267,28 +252,22 @@ const cessSurvey = (t: TFnType): TXMTemplate => { ...getXMSurveyDefault(t), name: t("templates.cess_survey_name"), questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, + buildRatingQuestion({ range: 5, scale: "number", - headline: { default: t("templates.cess_survey_question_1_headline") }, + headline: t("templates.cess_survey_question_1_headline"), required: true, - lowerLabel: { default: t("templates.cess_survey_question_1_lower_label") }, - upperLabel: { default: t("templates.cess_survey_question_1_upper_label") }, - isColorCodingEnabled: false, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: t("templates.cess_survey_question_2_headline") }, + lowerLabel: t("templates.cess_survey_question_1_lower_label"), + upperLabel: t("templates.cess_survey_question_1_upper_label"), + t, + }), + buildOpenTextQuestion({ + headline: t("templates.cess_survey_question_2_headline"), required: true, - placeholder: { default: t("templates.cess_survey_question_2_placeholder") }, + placeholder: t("templates.cess_survey_question_2_placeholder"), inputType: "text", - charLimit: { - enabled: false, - }, - }, + t, + }), ], }; }; @@ -301,9 +280,8 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => { ...defaultSurvey, name: t("templates.smileys_survey_name"), questions: [ - { + buildRatingQuestion({ id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.Rating, logic: [ { id: createId(), @@ -336,16 +314,15 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => { ], range: 5, scale: "smiley", - headline: { default: t("templates.smileys_survey_question_1_headline") }, + headline: t("templates.smileys_survey_question_1_headline"), required: true, - lowerLabel: { default: t("templates.smileys_survey_question_1_lower_label") }, - upperLabel: { default: t("templates.smileys_survey_question_1_upper_label") }, - isColorCodingEnabled: false, - }, - { + lowerLabel: t("templates.smileys_survey_question_1_lower_label"), + upperLabel: t("templates.smileys_survey_question_1_upper_label"), + t, + }), + buildCTAQuestion({ id: reusableQuestionIds[1], - html: { default: t("templates.smileys_survey_question_2_html") }, - type: TSurveyQuestionTypeEnum.CTA, + html: t("templates.smileys_survey_question_2_html"), logic: [ { id: createId(), @@ -372,25 +349,23 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => { ], }, ], - headline: { default: t("templates.smileys_survey_question_2_headline") }, + headline: t("templates.smileys_survey_question_2_headline"), required: true, buttonUrl: "https://formbricks.com/github", - buttonLabel: { default: t("templates.smileys_survey_question_2_button_label") }, + buttonLabel: t("templates.smileys_survey_question_2_button_label"), buttonExternal: true, - }, - { + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: t("templates.smileys_survey_question_3_headline") }, + headline: t("templates.smileys_survey_question_3_headline"), required: true, - subheader: { default: t("templates.smileys_survey_question_3_subheader") }, - buttonLabel: { default: t("templates.smileys_survey_question_3_button_label") }, - placeholder: { default: t("templates.smileys_survey_question_3_placeholder") }, + subheader: t("templates.smileys_survey_question_3_subheader"), + buttonLabel: t("templates.smileys_survey_question_3_button_label"), + placeholder: t("templates.smileys_survey_question_3_placeholder"), inputType: "text", - charLimit: { - enabled: false, - }, - }, + t, + }), ], }; }; @@ -400,37 +375,26 @@ const enpsSurvey = (t: TFnType): TXMTemplate => { ...getXMSurveyDefault(t), name: t("templates.enps_survey_name"), questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.NPS, - headline: { - default: t("templates.enps_survey_question_1_headline"), - }, + buildNPSQuestion({ + headline: t("templates.enps_survey_question_1_headline"), required: false, - lowerLabel: { default: t("templates.enps_survey_question_1_lower_label") }, - upperLabel: { default: t("templates.enps_survey_question_1_upper_label") }, + lowerLabel: t("templates.enps_survey_question_1_lower_label"), + upperLabel: t("templates.enps_survey_question_1_upper_label"), isColorCodingEnabled: true, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: t("templates.enps_survey_question_2_headline") }, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.enps_survey_question_2_headline"), required: false, inputType: "text", - charLimit: { - enabled: false, - }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: t("templates.enps_survey_question_3_headline") }, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.enps_survey_question_3_headline"), required: false, inputType: "text", - charLimit: { - enabled: false, - }, - }, + t, + }), ], }; }; diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/page.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/page.tsx index e86869eb232f..bed5a872ca09 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/page.tsx +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/page.tsx @@ -1,4 +1,7 @@ import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList"; +import { getEnvironment } from "@/lib/environment/service"; +import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service"; +import { getUser } from "@/lib/user/service"; import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { Button } from "@/modules/ui/components/button"; @@ -7,9 +10,6 @@ import { getTranslate } from "@/tolgee/server"; import { XIcon } from "lucide-react"; import { getServerSession } from "next-auth"; import Link from "next/link"; -import { getEnvironment } from "@formbricks/lib/environment/service"; -import { getProjectByEnvironmentId, getUserProjects } from "@formbricks/lib/project/service"; -import { getUser } from "@formbricks/lib/user/service"; interface XMTemplatePageProps { params: Promise<{ diff --git a/apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts b/apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts new file mode 100644 index 000000000000..6980cdf04655 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts @@ -0,0 +1,44 @@ +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { getTeamsByOrganizationId } from "./onboarding"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + team: { + findMany: vi.fn(), + }, + }, +})); + +describe("getTeamsByOrganizationId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns mapped teams", async () => { + const mockTeams = [ + { id: "t1", name: "Team 1" }, + { id: "t2", name: "Team 2" }, + ]; + vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams); + const result = await getTeamsByOrganizationId("org1"); + expect(result).toEqual([ + { id: "t1", name: "Team 1" }, + { id: "t2", name: "Team 2" }, + ]); + }); + + test("throws DatabaseError on Prisma error", async () => { + vi.mocked(prisma.team.findMany).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" }) + ); + await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError); + }); + + test("throws error on unknown error", async () => { + vi.mocked(prisma.team.findMany).mockRejectedValueOnce(new Error("fail")); + await expect(getTeamsByOrganizationId("org1")).rejects.toThrow("fail"); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/lib/onboarding.ts b/apps/web/app/(app)/(onboarding)/lib/onboarding.ts index 6d3e27761153..fb6bd666185b 100644 --- a/apps/web/app/(app)/(onboarding)/lib/onboarding.ts +++ b/apps/web/app/(app)/(onboarding)/lib/onboarding.ts @@ -1,48 +1,39 @@ "use server"; import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding"; -import { teamCache } from "@/lib/cache/team"; +import { validateInputs } from "@/lib/utils/validate"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { validateInputs } from "@formbricks/lib/utils/validate"; import { ZId } from "@formbricks/types/common"; import { DatabaseError } from "@formbricks/types/errors"; export const getTeamsByOrganizationId = reactCache( - async (organizationId: string): Promise => - cache( - async () => { - validateInputs([organizationId, ZId]); - try { - const teams = await prisma.team.findMany({ - where: { - organizationId, - }, - select: { - id: true, - name: true, - }, - }); + async (organizationId: string): Promise => { + validateInputs([organizationId, ZId]); + try { + const teams = await prisma.team.findMany({ + where: { + organizationId, + }, + select: { + id: true, + name: true, + }, + }); - const projectTeams = teams.map((team) => ({ - id: team.id, - name: team.name, - })); + const projectTeams = teams.map((team) => ({ + id: team.id, + name: team.name, + })); - return projectTeams; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } - }, - [`getTeamsByOrganizationId-${organizationId}`], - { - tags: [teamCache.tag.byOrganizationId(organizationId)], + return projectTeams; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )() + + throw error; + } + } ); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx new file mode 100644 index 000000000000..fb33d1991c89 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx @@ -0,0 +1,100 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { LandingSidebar } from "./landing-sidebar"; + +// Mock constants that this test needs +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + WEBAPP_URL: "http://localhost:3000", +})); + +// Mock server actions that this test needs +vi.mock("@/modules/auth/actions/sign-out", () => ({ + logSignOutAction: vi.fn().mockResolvedValue(undefined), +})); + +// Module mocks must be declared before importing the component +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ t: (key: string) => key, isLoading: false }), +})); + +// Mock our useSignOut hook +const mockSignOut = vi.fn(); +vi.mock("@/modules/auth/hooks/use-sign-out", () => ({ + useSignOut: () => ({ + signOut: mockSignOut, + }), +})); + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({ + CreateOrganizationModal: ({ open }: { open: boolean }) => ( +
+ ), +})); +vi.mock("@/modules/ui/components/avatars", () => ({ + ProfileAvatar: ({ userId }: { userId: string }) =>
{userId}
, +})); + +// Ensure mocks are reset between tests +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("LandingSidebar component", () => { + const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any; + const organization = { id: "o1", name: "orgOne" } as any; + const organizations = [ + { id: "o2", name: "betaOrg" }, + { id: "o1", name: "alphaOrg" }, + ] as any; + + test("renders logo, avatar, and initial modal closed", () => { + render( + + ); + + // Formbricks logo + expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument(); + // Profile avatar + expect(screen.getByTestId("avatar")).toHaveTextContent("u1"); + // CreateOrganizationModal should be closed initially + expect(screen.getByTestId("modal-closed")).toBeInTheDocument(); + }); + + test("clicking logout triggers signOut", async () => { + render( + + ); + + // Open user dropdown by clicking on avatar trigger + const trigger = screen.getByTestId("avatar").parentElement; + if (trigger) await userEvent.click(trigger); + + // Click logout menu item + const logoutItem = await screen.findByText("common.logout"); + await userEvent.click(logoutItem); + + expect(mockSignOut).toHaveBeenCalledWith({ + reason: "user_initiated", + redirectUrl: "/auth/login", + organizationId: "o1", + redirect: true, + callbackUrl: "/auth/login", + clearEnvironmentId: true, + }); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx index 02c893c9577d..4eecda8abd46 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx @@ -1,7 +1,9 @@ "use client"; -import { formbricksLogout } from "@/app/lib/formbricks"; import FBLogo from "@/images/formbricks-wordmark.svg"; +import { cn } from "@/lib/cn"; +import { capitalizeFirstLetter } from "@/lib/utils/strings"; +import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal"; import { ProfileAvatar } from "@/modules/ui/components/avatars"; import { @@ -19,13 +21,10 @@ import { } from "@/modules/ui/components/dropdown-menu"; import { useTranslate } from "@tolgee/react"; import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react"; -import { signOut } from "next-auth/react"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; -import { cn } from "@formbricks/lib/cn"; -import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings"; import { TOrganization } from "@formbricks/types/organizations"; import { TUser } from "@formbricks/types/user"; @@ -45,6 +44,7 @@ export const LandingSidebar = ({ const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false); const { t } = useTranslate(); + const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); const router = useRouter(); @@ -80,25 +80,25 @@ export const LandingSidebar = ({ -
+ className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none"> +
<> -
+

{user?.name ? {user?.name} : {user?.email}}

+ className="truncate text-sm text-slate-500"> {capitalizeFirstLetter(organization?.name)}

- +
@@ -112,7 +112,7 @@ export const LandingSidebar = ({ {/* Dropdown Items */} {dropdownNavigation.map((link) => ( - + {link.label} @@ -124,8 +124,14 @@ export const LandingSidebar = ({ { - await signOut({ callbackUrl: "/auth/login" }); - await formbricksLogout(); + await signOutWithAudit({ + reason: "user_initiated", + redirectUrl: "/auth/login", + organizationId: organization.id, + redirect: true, + callbackUrl: "/auth/login", + clearEnvironmentId: true, + }); }} icon={}> {t("common.logout")} diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.test.tsx new file mode 100644 index 000000000000..ff66ea3e34f4 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.test.tsx @@ -0,0 +1,187 @@ +import { getEnvironments } from "@/lib/environment/service"; +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getUserProjects } from "@/lib/project/service"; +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/preact"; +import { getServerSession } from "next-auth"; +import { notFound, redirect } from "next/navigation"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import LandingLayout from "./layout"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + IS_PRODUCTION: false, + IS_DEVELOPMENT: true, + E2E_TESTING: false, + WEBAPP_URL: "http://localhost:3000", + PUBLIC_URL: "http://localhost:3000/survey", + ENCRYPTION_KEY: "mock-encryption-key", + CRON_SECRET: "mock-cron-secret", + DEFAULT_BRAND_COLOR: "#64748b", + FB_LOGO_URL: "https://mock-logo-url.com/logo.png", + PRIVACY_URL: "http://localhost:3000/privacy", + TERMS_URL: "http://localhost:3000/terms", + IMPRINT_URL: "http://localhost:3000/imprint", + IMPRINT_ADDRESS: "Mock Address", + PASSWORD_RESET_DISABLED: false, + EMAIL_VERIFICATION_DISABLED: false, + GOOGLE_OAUTH_ENABLED: false, + GITHUB_OAUTH_ENABLED: false, + AZURE_OAUTH_ENABLED: false, + OIDC_OAUTH_ENABLED: false, + SAML_OAUTH_ENABLED: false, + SAML_XML_DIR: "./mock-saml-connection", + SIGNUP_ENABLED: true, + EMAIL_AUTH_ENABLED: true, + INVITE_DISABLED: false, + SLACK_CLIENT_SECRET: "mock-slack-secret", + SLACK_CLIENT_ID: "mock-slack-id", + SLACK_AUTH_URL: "https://mock-slack-auth-url.com", + GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id", + GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret", + GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect", + NOTION_OAUTH_CLIENT_ID: "mock-notion-id", + NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret", + NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect", + NOTION_AUTH_URL: "https://mock-notion-auth-url.com", + AIRTABLE_CLIENT_ID: "mock-airtable-id", + SMTP_HOST: "mock-smtp-host", + SMTP_PORT: "587", + SMTP_SECURE_ENABLED: false, + SMTP_USER: "mock-smtp-user", + SMTP_PASSWORD: "mock-smtp-password", + SMTP_AUTHENTICATED: true, + SMTP_REJECT_UNAUTHORIZED_TLS: true, + MAIL_FROM: "mock@mail.com", + MAIL_FROM_NAME: "Mock Mail", + NEXTAUTH_SECRET: "mock-nextauth-secret", + ITEMS_PER_PAGE: 30, + SURVEYS_PER_PAGE: 12, + RESPONSES_PER_PAGE: 25, + TEXT_RESPONSES_PER_PAGE: 5, + INSIGHTS_PER_PAGE: 10, + DOCUMENTS_PER_PAGE: 10, + MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500, + MAX_OTHER_OPTION_LENGTH: 250, + ENTERPRISE_LICENSE_KEY: "ABC", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "mock-github-secret", + GITHUB_OAUTH_URL: "https://mock-github-auth-url.com", + AZURE_ID: "mock-azure-id", + AZUREAD_CLIENT_ID: "mock-azure-client-id", + AZUREAD_CLIENT_SECRET: "mock-azure-client-secret", + GOOGLE_CLIENT_ID: "mock-google-client-id", + GOOGLE_CLIENT_SECRET: "mock-google-client-secret", + GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com", + AZURE_OAUTH_URL: "https://mock-azure-auth-url.com", + OIDC_ID: "mock-oidc-id", + OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com", + SAML_ID: "mock-saml-id", + SAML_OAUTH_URL: "https://mock-saml-auth-url.com", + SAML_METADATA_URL: "https://mock-saml-metadata-url.com", + AZUREAD_TENANT_ID: "mock-azure-tenant-id", + AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com", + OIDC_DISPLAY_NAME: "Mock OIDC", + OIDC_CLIENT_ID: "mock-oidc-client-id", + OIDC_CLIENT_SECRET: "mock-oidc-client-secret", + OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect", + OIDC_AUTH_URL: "https://mock-oidc-auth-url.com", + OIDC_ISSUER: "https://mock-oidc-issuer.com", + OIDC_SIGNING_ALGORITHM: "RS256", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: true, +})); + +vi.mock("@/lib/environment/service"); +vi.mock("@/lib/membership/service"); +vi.mock("@/lib/project/service"); +vi.mock("next-auth"); +vi.mock("next/navigation"); + +afterEach(() => { + cleanup(); +}); + +describe("LandingLayout", () => { + test("redirects to login if no session exists", async () => { + vi.mocked(getServerSession).mockResolvedValue(null); + + const props = { params: { organizationId: "org-123" }, children:
Child Content
}; + + await LandingLayout(props); + + expect(vi.mocked(redirect)).toHaveBeenCalledWith("/auth/login"); + }); + + test("returns notFound if no membership is found", async () => { + vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } }); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null); + + const props = { params: { organizationId: "org-123" }, children:
Child Content
}; + + await LandingLayout(props); + + expect(vi.mocked(notFound)).toHaveBeenCalled(); + }); + + test("redirects to production environment if available", async () => { + vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } }); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ + organizationId: "org-123", + userId: "user-123", + accepted: true, + role: "owner", + }); + vi.mocked(getUserProjects).mockResolvedValue([ + { + id: "proj-123", + organizationId: "org-123", + createdAt: new Date("2023-01-01"), + updatedAt: new Date("2023-01-02"), + name: "Project 1", + styling: { allowStyleOverwrite: true }, + recontactDays: 30, + inAppSurveyBranding: true, + linkSurveyBranding: true, + } as any, + ]); + vi.mocked(getEnvironments).mockResolvedValue([ + { + id: "env-123", + type: "production", + projectId: "proj-123", + createdAt: new Date("2023-01-01"), + updatedAt: new Date("2023-01-02"), + appSetupCompleted: true, + }, + ]); + + const props = { params: { organizationId: "org-123" }, children:
Child Content
}; + + await LandingLayout(props); + + expect(vi.mocked(redirect)).toHaveBeenCalledWith("/environments/env-123/"); + }); + + test("renders children if no projects or production environment exist", async () => { + vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } }); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ + organizationId: "org-123", + userId: "user-123", + accepted: true, + role: "owner", + }); + vi.mocked(getUserProjects).mockResolvedValue([]); + + const props = { params: { organizationId: "org-123" }, children:
Child Content
}; + + const result = await LandingLayout(props); + + expect(result).toEqual( + <> +
Child Content
+ + ); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.tsx index 3a9f9dcc6773..54c40b9ae4e3 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.tsx @@ -1,9 +1,9 @@ +import { getEnvironments } from "@/lib/environment/service"; +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getUserProjects } from "@/lib/project/service"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { getServerSession } from "next-auth"; import { notFound, redirect } from "next/navigation"; -import { getEnvironments } from "@formbricks/lib/environment/service"; -import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; -import { getUserProjects } from "@formbricks/lib/project/service"; const LandingLayout = async (props) => { const params = await props.params; diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.test.tsx new file mode 100644 index 000000000000..6a9bc6ba0ed1 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.test.tsx @@ -0,0 +1,199 @@ +import { getOrganizationsByUserId } from "@/lib/organization/service"; +import { getUser } from "@/lib/user/service"; +import { getOrganizationAuth } from "@/modules/organization/lib/utils"; +import { getTranslate } from "@/tolgee/server"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { notFound, redirect } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: true, + features: { isMultiOrgEnabled: true }, + lastChecked: new Date(), + isPendingDowngrade: false, + fallbackLevel: "live", + }), +})); + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + IS_PRODUCTION: false, + IS_DEVELOPMENT: true, + E2E_TESTING: false, + WEBAPP_URL: "http://localhost:3000", + ENCRYPTION_KEY: "mock-encryption-key", + CRON_SECRET: "mock-cron-secret", + DEFAULT_BRAND_COLOR: "#64748b", + FB_LOGO_URL: "https://mock-logo-url.com/logo.png", + PRIVACY_URL: "http://localhost:3000/privacy", + TERMS_URL: "http://localhost:3000/terms", + IMPRINT_URL: "http://localhost:3000/imprint", + IMPRINT_ADDRESS: "Mock Address", + PASSWORD_RESET_DISABLED: false, + EMAIL_VERIFICATION_DISABLED: false, + GOOGLE_OAUTH_ENABLED: false, + GITHUB_OAUTH_ENABLED: false, + AZURE_OAUTH_ENABLED: false, + OIDC_OAUTH_ENABLED: false, + SAML_OAUTH_ENABLED: false, + SAML_XML_DIR: "./mock-saml-connection", + SIGNUP_ENABLED: true, + EMAIL_AUTH_ENABLED: true, + INVITE_DISABLED: false, + SLACK_CLIENT_SECRET: "mock-slack-secret", + SLACK_CLIENT_ID: "mock-slack-id", + SLACK_AUTH_URL: "https://mock-slack-auth-url.com", + GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id", + GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret", + GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect", + NOTION_OAUTH_CLIENT_ID: "mock-notion-id", + NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret", + NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect", + NOTION_AUTH_URL: "https://mock-notion-auth-url.com", + AIRTABLE_CLIENT_ID: "mock-airtable-id", + SMTP_HOST: "mock-smtp-host", + SMTP_PORT: "587", + SMTP_SECURE_ENABLED: false, + SMTP_USER: "mock-smtp-user", + SMTP_PASSWORD: "mock-smtp-password", + SMTP_AUTHENTICATED: true, + SMTP_REJECT_UNAUTHORIZED_TLS: true, + MAIL_FROM: "mock@mail.com", + MAIL_FROM_NAME: "Mock Mail", + NEXTAUTH_SECRET: "mock-nextauth-secret", + ITEMS_PER_PAGE: 30, + SURVEYS_PER_PAGE: 12, + RESPONSES_PER_PAGE: 25, + TEXT_RESPONSES_PER_PAGE: 5, + INSIGHTS_PER_PAGE: 10, + DOCUMENTS_PER_PAGE: 10, + MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500, + MAX_OTHER_OPTION_LENGTH: 250, + ENTERPRISE_LICENSE_KEY: "ABC", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "mock-github-secret", + GITHUB_OAUTH_URL: "https://mock-github-auth-url.com", + AZURE_ID: "mock-azure-id", + AZUREAD_CLIENT_ID: "mock-azure-client-id", + AZUREAD_CLIENT_SECRET: "mock-azure-client-secret", + GOOGLE_CLIENT_ID: "mock-google-client-id", + GOOGLE_CLIENT_SECRET: "mock-google-client-secret", + GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com", + AZURE_OAUTH_URL: "https://mock-azure-auth-url.com", + OIDC_ID: "mock-oidc-id", + OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com", + SAML_ID: "mock-saml-id", + SAML_OAUTH_URL: "https://mock-saml-auth-url.com", + SAML_METADATA_URL: "https://mock-saml-metadata-url.com", + AZUREAD_TENANT_ID: "mock-azure-tenant-id", + AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com", + OIDC_DISPLAY_NAME: "Mock OIDC", + OIDC_CLIENT_ID: "mock-oidc-client-id", + OIDC_CLIENT_SECRET: "mock-oidc-client-secret", + OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect", + OIDC_AUTH_URL: "https://mock-oidc-auth-url.com", + OIDC_ISSUER: "https://mock-oidc-issuer.com", + OIDC_SIGNING_ALGORITHM: "RS256", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: true, +})); + +vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({ + LandingSidebar: () =>
, +})); +vi.mock("@/modules/organization/lib/utils"); +vi.mock("@/lib/user/service"); +vi.mock("@/lib/organization/service"); +vi.mock("@/tolgee/server"); +vi.mock("next/navigation", () => ({ + redirect: vi.fn(() => "REDIRECT_STUB"), + notFound: vi.fn(() => "NOT_FOUND_STUB"), +})); + +// Mock the React cache function +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { + ...actual, + cache: (fn: any) => fn, + }; +}); + +describe("Page component", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + beforeEach(() => { + vi.resetModules(); + }); + + test("redirects to login if no user session", async () => { + vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: true, + features: { isMultiOrgEnabled: true }, + lastChecked: new Date(), + isPendingDowngrade: false, + fallbackLevel: "live", + }), + })); + const { default: Page } = await import("./page"); + const result = await Page({ params: { organizationId: "org1" } }); + expect(redirect).toHaveBeenCalledWith("/auth/login"); + expect(result).toBe("REDIRECT_STUB"); + }); + + test("returns notFound if user does not exist", async () => { + vi.mocked(getOrganizationAuth).mockResolvedValue({ + session: { user: { id: "user1" } }, + organization: {}, + } as any); + vi.mocked(getUser).mockResolvedValue(null); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: true, + features: { isMultiOrgEnabled: true }, + lastChecked: new Date(), + isPendingDowngrade: false, + fallbackLevel: "live", + }), + })); + const { default: Page } = await import("./page"); + const result = await Page({ params: { organizationId: "org1" } }); + expect(notFound).toHaveBeenCalled(); + expect(result).toBe("NOT_FOUND_STUB"); + }); + + test("renders header and sidebar for authenticated user", async () => { + vi.mocked(getOrganizationAuth).mockResolvedValue({ + session: { user: { id: "user1" } }, + organization: { id: "org1" }, + } as any); + vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any); + vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]); + vi.mocked(getTranslate).mockResolvedValue((props: any) => + typeof props === "string" ? props : props.key || "" + ); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: true, + features: { isMultiOrgEnabled: true }, + lastChecked: new Date(), + isPendingDowngrade: false, + fallbackLevel: "live", + }), + })); + const { default: Page } = await import("./page"); + const element = await Page({ params: { organizationId: "org1" } }); + render(element as React.ReactElement); + expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument(); + expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument(); + expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx index 5bc2b635e725..677cc9939cca 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.tsx @@ -1,11 +1,11 @@ import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar"; -import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils"; +import { getOrganizationsByUserId } from "@/lib/organization/service"; +import { getUser } from "@/lib/user/service"; +import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { Header } from "@/modules/ui/components/header"; import { getTranslate } from "@/tolgee/server"; import { notFound, redirect } from "next/navigation"; -import { getOrganizationsByUserId } from "@formbricks/lib/organization/service"; -import { getUser } from "@formbricks/lib/user/service"; const Page = async (props) => { const params = await props.params; diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.test.tsx index 850229ddca34..d055ba8dd675 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.test.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.test.tsx @@ -1,19 +1,19 @@ +import { canUserAccessOrganization } from "@/lib/organization/auth"; +import { getOrganization } from "@/lib/organization/service"; +import { getUser } from "@/lib/user/service"; import "@testing-library/jest-dom/vitest"; import { act, cleanup, render, screen } from "@testing-library/react"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; import React from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { canUserAccessOrganization } from "@formbricks/lib/organization/auth"; -import { getOrganization } from "@formbricks/lib/organization/service"; -import { getUser } from "@formbricks/lib/user/service"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { TOrganization } from "@formbricks/types/organizations"; import { TUser } from "@formbricks/types/user"; import ProjectOnboardingLayout from "./layout"; // Mock all the modules and functions that this layout uses: -vi.mock("@formbricks/lib/constants", () => ({ +vi.mock("@/lib/constants", () => ({ IS_FORMBRICKS_CLOUD: false, POSTHOG_API_KEY: "mock-posthog-api-key", POSTHOG_HOST: "mock-posthog-host", @@ -34,6 +34,9 @@ vi.mock("@formbricks/lib/constants", () => ({ OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", WEBAPP_URL: "test-webapp-url", IS_PRODUCTION: false, + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: true, })); vi.mock("next-auth", () => ({ @@ -42,13 +45,13 @@ vi.mock("next-auth", () => ({ vi.mock("next/navigation", () => ({ redirect: vi.fn(), })); -vi.mock("@formbricks/lib/organization/auth", () => ({ +vi.mock("@/lib/organization/auth", () => ({ canUserAccessOrganization: vi.fn(), })); -vi.mock("@formbricks/lib/organization/service", () => ({ +vi.mock("@/lib/organization/service", () => ({ getOrganization: vi.fn(), })); -vi.mock("@formbricks/lib/user/service", () => ({ +vi.mock("@/lib/user/service", () => ({ getUser: vi.fn(), })); vi.mock("@/tolgee/server", () => ({ @@ -71,7 +74,7 @@ describe("ProjectOnboardingLayout", () => { cleanup(); }); - it("redirects to /auth/login if there is no session", async () => { + test("redirects to /auth/login if there is no session", async () => { // Mock no session vi.mocked(getServerSession).mockResolvedValueOnce(null); @@ -85,7 +88,7 @@ describe("ProjectOnboardingLayout", () => { expect(layoutElement).toBeUndefined(); }); - it("throws an error if user does not exist", async () => { + test("throws an error if user does not exist", async () => { vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" }, }); @@ -99,7 +102,7 @@ describe("ProjectOnboardingLayout", () => { ).rejects.toThrow("common.user_not_found"); }); - it("throws AuthorizationError if user cannot access organization", async () => { + test("throws AuthorizationError if user cannot access organization", async () => { vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser); vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(false); @@ -112,7 +115,7 @@ describe("ProjectOnboardingLayout", () => { ).rejects.toThrow("common.not_authorized"); }); - it("throws an error if organization does not exist", async () => { + test("throws an error if organization does not exist", async () => { vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser); vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true); @@ -126,7 +129,7 @@ describe("ProjectOnboardingLayout", () => { ).rejects.toThrow("common.organization_not_found"); }); - it("renders child content plus PosthogIdentify & ToasterClient if everything is valid", async () => { + test("renders child content plus PosthogIdentify & ToasterClient if everything is valid", async () => { // Provide valid data vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", name: "Test User" } as TUser); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx index 6c16a24140a2..ecbf50a87f70 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx @@ -1,13 +1,13 @@ import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify"; +import { IS_POSTHOG_CONFIGURED } from "@/lib/constants"; +import { canUserAccessOrganization } from "@/lib/organization/auth"; +import { getOrganization } from "@/lib/organization/service"; +import { getUser } from "@/lib/user/service"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { getTranslate } from "@/tolgee/server"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; -import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants"; -import { canUserAccessOrganization } from "@formbricks/lib/organization/auth"; -import { getOrganization } from "@formbricks/lib/organization/service"; -import { getUser } from "@formbricks/lib/user/service"; import { AuthorizationError } from "@formbricks/types/errors"; const ProjectOnboardingLayout = async (props) => { diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.test.tsx new file mode 100644 index 000000000000..5bf53e2d4350 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.test.tsx @@ -0,0 +1,88 @@ +import { getUserProjects } from "@/lib/project/service"; +import { getOrganizationAuth } from "@/modules/organization/lib/utils"; +import { getTranslate } from "@/tolgee/server"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { redirect } from "next/navigation"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +const mockTranslate = vi.fn((key) => key); + +// Module mocks must be declared before importing the component +vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() })); +vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() })); +vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() })); +vi.mock("next/navigation", () => ({ redirect: vi.fn(() => "REDIRECT_STUB") })); +vi.mock("@/modules/ui/components/header", () => ({ + Header: ({ title, subtitle }: { title: string; subtitle: string }) => ( +
+

{title}

+

{subtitle}

+
+ ), +})); +vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({ + OnboardingOptionsContainer: ({ options }: { options: any[] }) => ( +
{options.map((o) => o.title).join(",")}
+ ), +})); +vi.mock("next/link", () => ({ + default: ({ href, children }: { href: string; children: React.ReactNode }) => {children}, +})); + +describe("Page component", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const params = Promise.resolve({ organizationId: "org1" }); + + test("redirects to login if no user session", async () => { + vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {} } as any); + + const result = await Page({ params }); + + expect(redirect).toHaveBeenCalledWith("/auth/login"); + expect(result).toBe("REDIRECT_STUB"); + }); + + test("renders header, options, and close button when projects exist", async () => { + vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any); + vi.mocked(getTranslate).mockResolvedValue(mockTranslate); + vi.mocked(getUserProjects).mockResolvedValue([{ id: 1 }] as any); + + const element = await Page({ params }); + render(element as React.ReactElement); + + // Header title and subtitle + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "organizations.projects.new.channel.channel_select_title" + ); + expect( + screen.getByText("organizations.projects.new.channel.channel_select_subtitle") + ).toBeInTheDocument(); + + // Options container with correct titles + expect(screen.getByTestId("options")).toHaveTextContent( + "organizations.projects.new.channel.link_and_email_surveys," + + "organizations.projects.new.channel.in_product_surveys" + ); + + // Close button link rendered when projects >=1 + const closeLink = screen.getByRole("link"); + expect(closeLink).toHaveAttribute("href", "/"); + }); + + test("does not render close button when no projects", async () => { + vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any); + vi.mocked(getTranslate).mockResolvedValue(mockTranslate); + vi.mocked(getUserProjects).mockResolvedValue([]); + + const element = await Page({ params }); + render(element as React.ReactElement); + + expect(screen.queryByRole("link")).toBeNull(); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.tsx index 4309addd101f..13da215193d5 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/channel/page.tsx @@ -1,4 +1,5 @@ import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; +import { getUserProjects } from "@/lib/project/service"; import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { Button } from "@/modules/ui/components/button"; import { Header } from "@/modules/ui/components/header"; @@ -6,7 +7,6 @@ import { getTranslate } from "@/tolgee/server"; import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; -import { getUserProjects } from "@formbricks/lib/project/service"; interface ChannelPageProps { params: Promise<{ diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.test.tsx new file mode 100644 index 000000000000..c99156edb8d0 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.test.tsx @@ -0,0 +1,223 @@ +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getOrganization } from "@/lib/organization/service"; +import { getOrganizationProjectsCount } from "@/lib/project/service"; +import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { getServerSession } from "next-auth"; +import { notFound, redirect } from "next/navigation"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TMembership } from "@formbricks/types/memberships"; +import { TOrganization } from "@formbricks/types/organizations"; +import OnboardingLayout from "./layout"; + +// Mock environment variables +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: true, +})); + +// Mock dependencies +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("@/lib/membership/service", () => ({ + getMembershipByUserIdOrganizationId: vi.fn(), +})); + +vi.mock("@/lib/organization/service", () => ({ + getOrganization: vi.fn(), +})); + +vi.mock("@/lib/project/service", () => ({ + getOrganizationProjectsCount: vi.fn(), +})); + +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getOrganizationProjectsLimit: vi.fn(), +})); + +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); + +describe("OnboardingLayout", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("redirects to login if no session", async () => { + vi.mocked(getServerSession).mockResolvedValue(null); + + const props = { + params: { organizationId: "test-org-id" }, + children:
Test Child
, + }; + + await OnboardingLayout(props); + expect(redirect).toHaveBeenCalledWith("/auth/login"); + }); + + test("returns not found if user is member or billing", async () => { + const mockSession = { + user: { id: "test-user-id" }, + }; + vi.mocked(getServerSession).mockResolvedValue(mockSession as any); + + const mockMembership: TMembership = { + organizationId: "test-org-id", + userId: "test-user-id", + accepted: true, + role: "member", + }; + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + + const props = { + params: { organizationId: "test-org-id" }, + children:
Test Child
, + }; + + await OnboardingLayout(props); + expect(notFound).toHaveBeenCalled(); + }); + + test("throws error if organization is not found", async () => { + const mockSession = { + user: { id: "test-user-id" }, + }; + vi.mocked(getServerSession).mockResolvedValue(mockSession as any); + + const mockMembership: TMembership = { + organizationId: "test-org-id", + userId: "test-user-id", + accepted: true, + role: "owner", + }; + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getOrganization).mockResolvedValue(null); + + const props = { + params: { organizationId: "test-org-id" }, + children:
Test Child
, + }; + + await expect(OnboardingLayout(props)).rejects.toThrow("common.organization_not_found"); + }); + + test("redirects to home if project limit is reached", async () => { + const mockSession = { + user: { id: "test-user-id" }, + }; + vi.mocked(getServerSession).mockResolvedValue(mockSession as any); + + const mockMembership: TMembership = { + organizationId: "test-org-id", + userId: "test-user-id", + accepted: true, + role: "owner", + }; + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + + const mockOrganization: TOrganization = { + id: "test-org-id", + name: "Test Org", + createdAt: new Date(), + updatedAt: new Date(), + isAIEnabled: false, + billing: { + stripeCustomerId: null, + plan: "free", + period: "monthly", + limits: { + projects: 3, + monthly: { + responses: 1500, + miu: 2000, + }, + }, + periodStart: new Date(), + }, + }; + vi.mocked(getOrganization).mockResolvedValue(mockOrganization); + vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3); + vi.mocked(getOrganizationProjectsCount).mockResolvedValue(3); + + const props = { + params: { organizationId: "test-org-id" }, + children:
Test Child
, + }; + + await OnboardingLayout(props); + expect(redirect).toHaveBeenCalledWith("/"); + }); + + test("renders children when all conditions are met", async () => { + const mockSession = { + user: { id: "test-user-id" }, + }; + vi.mocked(getServerSession).mockResolvedValue(mockSession as any); + + const mockMembership: TMembership = { + organizationId: "test-org-id", + userId: "test-user-id", + accepted: true, + role: "owner", + }; + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + + const mockOrganization: TOrganization = { + id: "test-org-id", + name: "Test Org", + createdAt: new Date(), + updatedAt: new Date(), + isAIEnabled: false, + billing: { + stripeCustomerId: null, + plan: "free", + period: "monthly", + limits: { + projects: 3, + monthly: { + responses: 1500, + miu: 2000, + }, + }, + periodStart: new Date(), + }, + }; + vi.mocked(getOrganization).mockResolvedValue(mockOrganization); + vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3); + vi.mocked(getOrganizationProjectsCount).mockResolvedValue(2); + + const props = { + params: { organizationId: "test-org-id" }, + children:
Test Child
, + }; + + const result = await OnboardingLayout(props); + expect(result).toEqual(<>{props.children}); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.tsx index 3abbc14d635c..191bc448db71 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.tsx @@ -1,12 +1,12 @@ +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getAccessFlags } from "@/lib/membership/utils"; +import { getOrganization } from "@/lib/organization/service"; +import { getOrganizationProjectsCount } from "@/lib/project/service"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; import { getTranslate } from "@/tolgee/server"; import { getServerSession } from "next-auth"; import { notFound, redirect } from "next/navigation"; -import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; -import { getOrganization } from "@formbricks/lib/organization/service"; -import { getOrganizationProjectsCount } from "@formbricks/lib/project/service"; const OnboardingLayout = async (props) => { const params = await props.params; diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.test.tsx new file mode 100644 index 000000000000..b7b71e1c649b --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.test.tsx @@ -0,0 +1,72 @@ +import { getUserProjects } from "@/lib/project/service"; +import { getOrganizationAuth } from "@/modules/organization/lib/utils"; +import { getTranslate } from "@/tolgee/server"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { redirect } from "next/navigation"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +const mockTranslate = vi.fn((key) => key); + +vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() })); +vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() })); +vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() })); +vi.mock("next/navigation", () => ({ redirect: vi.fn() })); +vi.mock("next/link", () => ({ + __esModule: true, + default: ({ href, children }: any) => {children}, +})); +vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({ + OnboardingOptionsContainer: ({ options }: any) => ( +
{options.map((o: any) => o.title).join(",")}
+ ), +})); +vi.mock("@/modules/ui/components/header", () => ({ Header: ({ title }: any) =>

{title}

})); +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, ...props }: any) => , +})); + +describe("Mode Page", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const params = Promise.resolve({ organizationId: "org1" }); + + test("redirects to login if no session user", async () => { + vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any); + await Page({ params }); + expect(redirect).toHaveBeenCalledWith("/auth/login"); + }); + + test("renders header and options without close link when no projects", async () => { + vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any); + vi.mocked(getTranslate).mockResolvedValue(mockTranslate); + vi.mocked(getUserProjects).mockResolvedValueOnce([] as any); + + const element = await Page({ params }); + render(element as React.ReactElement); + + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "organizations.projects.new.mode.what_are_you_here_for" + ); + expect(screen.getByTestId("options")).toHaveTextContent( + "organizations.projects.new.mode.formbricks_surveys," + "organizations.projects.new.mode.formbricks_cx" + ); + expect(screen.queryByRole("link")).toBeNull(); + }); + + test("renders close link when projects exist", async () => { + vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any); + vi.mocked(getTranslate).mockResolvedValue(mockTranslate); + vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" } as any]); + + const element = await Page({ params }); + render(element as React.ReactElement); + + const link = screen.getByRole("link"); + expect(link).toHaveAttribute("href", "/"); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.tsx index a570a6ed894e..f572d023de03 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/mode/page.tsx @@ -1,4 +1,5 @@ import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; +import { getUserProjects } from "@/lib/project/service"; import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { Button } from "@/modules/ui/components/button"; import { Header } from "@/modules/ui/components/header"; @@ -6,7 +7,6 @@ import { getTranslate } from "@/tolgee/server"; import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; -import { getUserProjects } from "@formbricks/lib/project/service"; interface ModePageProps { params: Promise<{ diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.test.tsx new file mode 100644 index 000000000000..56e929042d8e --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.test.tsx @@ -0,0 +1,124 @@ +import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { toast } from "react-hot-toast"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ProjectSettings } from "./ProjectSettings"; + +// Mocks before imports +const pushMock = vi.fn(); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) })); +vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) })); +vi.mock("react-hot-toast", () => ({ toast: { error: vi.fn() } })); +vi.mock("@/app/(app)/environments/[environmentId]/actions", () => ({ createProjectAction: vi.fn() })); +vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" })); +vi.mock("@/modules/ui/components/color-picker", () => ({ + ColorPicker: ({ color, onChange }: any) => ( + + ), +})); +vi.mock("@/modules/ui/components/input", () => ({ + Input: ({ value, onChange, placeholder }: any) => ( + onChange((e.target as any).value)} /> + ), +})); +vi.mock("@/modules/ui/components/multi-select", () => ({ + MultiSelect: ({ value, options, onChange }: any) => ( + + ), +})); +vi.mock("@/modules/ui/components/survey", () => ({ + SurveyInline: () =>
, +})); +vi.mock("@/lib/templates", () => ({ previewSurvey: () => ({}) })); +vi.mock("@/modules/ee/teams/team-list/components/create-team-modal", () => ({ + CreateTeamModal: ({ open }: any) =>
, +})); + +// Clean up after each test +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + localStorage.clear(); +}); + +describe("ProjectSettings component", () => { + const baseProps = { + organizationId: "org1", + projectMode: "cx", + industry: "ind", + defaultBrandColor: "#fff", + organizationTeams: [], + isAccessControlAllowed: false, + userProjectsCount: 0, + } as any; + + const fillAndSubmit = async () => { + const nameInput = screen.getByPlaceholderText("e.g. Formbricks"); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, "TestProject"); + const nextButton = screen.getByRole("button", { name: "common.next" }); + await userEvent.click(nextButton); + }; + + test("successful createProject for link channel navigates to surveys and clears localStorage", async () => { + (createProjectAction as any).mockResolvedValue({ + data: { environments: [{ id: "env123", type: "production" }] }, + }); + render(); + await fillAndSubmit(); + expect(createProjectAction).toHaveBeenCalledWith({ + organizationId: "org1", + data: expect.objectContaining({ teamIds: [] }), + }); + expect(pushMock).toHaveBeenCalledWith("/environments/env123/surveys"); + expect(localStorage.getItem("FORMBRICKS_SURVEYS_FILTERS_KEY_LS")).toBeNull(); + }); + + test("successful createProject for app channel navigates to connect", async () => { + (createProjectAction as any).mockResolvedValue({ + data: { environments: [{ id: "env456", type: "production" }] }, + }); + render(); + await fillAndSubmit(); + expect(pushMock).toHaveBeenCalledWith("/environments/env456/connect"); + }); + + test("successful createProject for cx mode navigates to xm-templates when channel is neither link nor app", async () => { + (createProjectAction as any).mockResolvedValue({ + data: { environments: [{ id: "env789", type: "production" }] }, + }); + render(); + await fillAndSubmit(); + expect(pushMock).toHaveBeenCalledWith("/environments/env789/xm-templates"); + }); + + test("shows error toast on createProject error response", async () => { + (createProjectAction as any).mockResolvedValue({ error: "err" }); + render(); + await fillAndSubmit(); + expect(toast.error).toHaveBeenCalledWith("formatted-error"); + }); + + test("shows error toast on exception", async () => { + (createProjectAction as any).mockImplementation(() => { + throw new Error("fail"); + }); + render(); + await fillAndSubmit(); + expect(toast.error).toHaveBeenCalledWith("organizations.projects.new.settings.project_creation_failed"); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx index cf85a9fd7861..ac8b023dc616 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx @@ -2,6 +2,7 @@ import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions"; import { previewSurvey } from "@/app/lib/templates"; +import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team"; import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal"; @@ -26,7 +27,6 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; -import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage"; import { TProjectConfigChannel, TProjectConfigIndustry, @@ -42,7 +42,7 @@ interface ProjectSettingsProps { industry: TProjectConfigIndustry; defaultBrandColor: string; organizationTeams: TOrganizationTeam[]; - canDoRoleManagement: boolean; + isAccessControlAllowed: boolean; userProjectsCount: number; } @@ -53,7 +53,7 @@ export const ProjectSettings = ({ industry, defaultBrandColor, organizationTeams, - canDoRoleManagement = false, + isAccessControlAllowed = false, userProjectsCount, }: ProjectSettingsProps) => { const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false); @@ -174,7 +174,7 @@ export const ProjectSettings = ({ )} /> - {canDoRoleManagement && userProjectsCount > 0 && ( + {isAccessControlAllowed && userProjectsCount > 0 && ( ({ DEFAULT_BRAND_COLOR: "#fff" })); +// Mocks before component import +vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() })); +vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() })); +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getAccessControlPermission: vi.fn() })); +vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() })); +vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) })); +vi.mock("next/navigation", () => ({ redirect: vi.fn() })); +vi.mock("next/link", () => ({ + __esModule: true, + default: ({ href, children }: any) => {children}, +})); +vi.mock("@/modules/ui/components/header", () => ({ + Header: ({ title, subtitle }: any) => ( +
+

{title}

+

{subtitle}

+
+ ), +})); +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, ...props }: any) => , +})); +vi.mock( + "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings", + () => ({ + ProjectSettings: (props: any) =>
, + }) +); + +// Cleanup after each test +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("ProjectSettingsPage", () => { + const params = Promise.resolve({ organizationId: "org1" }); + const searchParams = Promise.resolve({ channel: "link", industry: "other", mode: "cx" } as any); + + test("redirects to login when no session user", async () => { + vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any); + await Page({ params, searchParams }); + expect(redirect).toHaveBeenCalledWith("/auth/login"); + }); + + test("throws when teams not found", async () => { + vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ + session: { user: { id: "u1" } }, + organization: { billing: { plan: "basic" } }, + } as any); + vi.mocked(getUserProjects).mockResolvedValueOnce([] as any); + vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any); + vi.mocked(getAccessControlPermission).mockResolvedValueOnce(false as any); + + await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found"); + }); + + test("renders header, settings and close link when projects exist", async () => { + vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ + session: { user: { id: "u1" } }, + organization: { billing: { plan: "basic" } }, + } as any); + vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any); + vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any); + vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any); + + const element = await Page({ params, searchParams }); + render(element as React.ReactElement); + + // Header + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "organizations.projects.new.settings.project_settings_title" + ); + // ProjectSettings stub receives mode prop + expect(screen.getByTestId("project-settings")).toHaveAttribute("data-mode", "cx"); + // Close link for existing projects + const link = screen.getByRole("link"); + expect(link).toHaveAttribute("href", "/"); + }); + + test("renders without close link when no projects", async () => { + vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ + session: { user: { id: "u1" } }, + organization: { billing: { plan: "basic" } }, + } as any); + vi.mocked(getUserProjects).mockResolvedValueOnce([] as any); + vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any); + vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any); + + const element = await Page({ params, searchParams }); + render(element as React.ReactElement); + + expect(screen.queryByRole("link")).toBeNull(); + }); +}); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx index 5a6098b3d383..cdd755bd947e 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/page.tsx @@ -1,6 +1,8 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings"; -import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils"; +import { DEFAULT_BRAND_COLOR } from "@/lib/constants"; +import { getUserProjects } from "@/lib/project/service"; +import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { Button } from "@/modules/ui/components/button"; import { Header } from "@/modules/ui/components/header"; @@ -8,8 +10,6 @@ import { getTranslate } from "@/tolgee/server"; import { XIcon } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; -import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants"; -import { getUserProjects } from "@formbricks/lib/project/service"; import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project"; interface ProjectSettingsPageProps { @@ -41,7 +41,7 @@ const Page = async (props: ProjectSettingsPageProps) => { const organizationTeams = await getTeamsByOrganizationId(params.organizationId); - const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); + const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan); if (!organizationTeams) { throw new Error(t("common.organization_teams_not_found")); @@ -60,7 +60,7 @@ const Page = async (props: ProjectSettingsPageProps) => { industry={industry} defaultBrandColor={DEFAULT_BRAND_COLOR} organizationTeams={organizationTeams} - canDoRoleManagement={canDoRoleManagement} + isAccessControlAllowed={isAccessControlAllowed} userProjectsCount={projects.length} /> {projects.length >= 1 && ( diff --git a/apps/web/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer.test.tsx new file mode 100644 index 000000000000..9bea2c55d193 --- /dev/null +++ b/apps/web/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer.test.tsx @@ -0,0 +1,106 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Home, Settings } from "lucide-react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { OnboardingOptionsContainer } from "./OnboardingOptionsContainer"; + +describe("OnboardingOptionsContainer", () => { + afterEach(() => { + cleanup(); + }); + + test("renders options with links", () => { + const options = [ + { + title: "Test Option", + description: "Test Description", + icon: Home, + href: "/test", + }, + ]; + + render(); + expect(screen.getByText("Test Option")).toBeInTheDocument(); + expect(screen.getByText("Test Description")).toBeInTheDocument(); + }); + + test("renders options with onClick handler", () => { + const onClickMock = vi.fn(); + const options = [ + { + title: "Click Option", + description: "Click Description", + icon: Home, + onClick: onClickMock, + }, + ]; + + render(); + expect(screen.getByText("Click Option")).toBeInTheDocument(); + expect(screen.getByText("Click Description")).toBeInTheDocument(); + }); + + test("renders options with iconText", () => { + const options = [ + { + title: "Icon Text Option", + description: "Icon Text Description", + icon: Home, + iconText: "Custom Icon Text", + }, + ]; + + render(); + expect(screen.getByText("Custom Icon Text")).toBeInTheDocument(); + }); + + test("renders options with loading state", () => { + const options = [ + { + title: "Loading Option", + description: "Loading Description", + icon: Home, + isLoading: true, + }, + ]; + + render(); + expect(screen.getByText("Loading Option")).toBeInTheDocument(); + }); + + test("renders multiple options", () => { + const options = [ + { + title: "First Option", + description: "First Description", + icon: Home, + }, + { + title: "Second Option", + description: "Second Description", + icon: Settings, + }, + ]; + + render(); + expect(screen.getByText("First Option")).toBeInTheDocument(); + expect(screen.getByText("Second Option")).toBeInTheDocument(); + }); + + test("calls onClick handler when clicking an option", async () => { + const onClickMock = vi.fn(); + const options = [ + { + title: "Click Option", + description: "Click Description", + icon: Home, + onClick: onClickMock, + }, + ]; + + render(); + await userEvent.click(screen.getByText("Click Option")); + expect(onClickMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.test.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.test.tsx index bab96ce9cf3f..543bea179852 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.test.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.test.tsx @@ -1,9 +1,9 @@ +import { getEnvironment } from "@/lib/environment/service"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import { cleanup, render, screen } from "@testing-library/react"; import { Session } from "next-auth"; import { redirect } from "next/navigation"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { getEnvironment } from "@formbricks/lib/environment/service"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { TEnvironment } from "@formbricks/types/environment"; import { TOrganization } from "@formbricks/types/organizations"; import { TUser } from "@formbricks/types/user"; @@ -28,7 +28,7 @@ vi.mock("@/modules/ui/components/dev-environment-banner", () => ({ vi.mock("@/modules/environments/lib/utils", () => ({ environmentIdLayoutChecks: vi.fn(), })); -vi.mock("@formbricks/lib/environment/service", () => ({ +vi.mock("@/lib/environment/service", () => ({ getEnvironment: vi.fn(), })); vi.mock("next/navigation", () => ({ @@ -41,7 +41,7 @@ describe("SurveyEditorEnvironmentLayout", () => { vi.clearAllMocks(); }); - it("renders successfully when environment is found", async () => { + test("renders successfully when environment is found", async () => { vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test session: { user: { id: "user1" } } as Session, @@ -62,7 +62,7 @@ describe("SurveyEditorEnvironmentLayout", () => { expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content"); }); - it("throws an error when environment is not found", async () => { + test("throws an error when environment is not found", async () => { vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ t: ((key: string) => key) as any, session: { user: { id: "user1" } } as Session, @@ -79,7 +79,7 @@ describe("SurveyEditorEnvironmentLayout", () => { ).rejects.toThrow("common.environment_not_found"); }); - it("calls redirect when session is null", async () => { + test("calls redirect when session is null", async () => { vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ t: ((key: string) => key) as any, session: undefined as unknown as Session, @@ -98,7 +98,7 @@ describe("SurveyEditorEnvironmentLayout", () => { ).rejects.toThrow("Redirect called"); }); - it("throws error if user is null", async () => { + test("throws error if user is null", async () => { vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ t: ((key: string) => key) as any, session: { user: { id: "user1" } } as Session, diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx index 846492fc9cda..e0717a73b936 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/layout.tsx @@ -1,8 +1,8 @@ +import { getEnvironment } from "@/lib/environment/service"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner"; import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout"; import { redirect } from "next/navigation"; -import { getEnvironment } from "@formbricks/lib/environment/service"; const SurveyEditorEnvironmentLayout = async (props) => { const params = await props.params; diff --git a/apps/web/app/(app)/components/FormbricksClient.test.tsx b/apps/web/app/(app)/components/FormbricksClient.test.tsx deleted file mode 100644 index a0c67294a2de..000000000000 --- a/apps/web/app/(app)/components/FormbricksClient.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { render } from "@testing-library/react"; -import { describe, expect, test, vi } from "vitest"; -import formbricks from "@formbricks/js"; -import { FormbricksClient } from "./FormbricksClient"; - -// Mock next/navigation hooks. -vi.mock("next/navigation", () => ({ - usePathname: () => "/test-path", - useSearchParams: () => new URLSearchParams("foo=bar"), -})); - -// Mock the flag that enables Formbricks. -vi.mock("@/app/lib/formbricks", () => ({ - formbricksEnabled: true, -})); - -// Mock the Formbricks SDK module. -vi.mock("@formbricks/js", () => ({ - __esModule: true, - default: { - setup: vi.fn(), - setUserId: vi.fn(), - setEmail: vi.fn(), - registerRouteChange: vi.fn(), - }, -})); - -describe("FormbricksClient", () => { - test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => { - const mockSetup = vi.spyOn(formbricks, "setup"); - const mockSetUserId = vi.spyOn(formbricks, "setUserId"); - const mockSetEmail = vi.spyOn(formbricks, "setEmail"); - const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange"); - - render( - - ); - - // Expect the first effect to call setup and assign the provided user details. - expect(mockSetup).toHaveBeenCalledWith({ - environmentId: "env-test", - appUrl: "https://api.test.com", - }); - expect(mockSetUserId).toHaveBeenCalledWith("user-123"); - expect(mockSetEmail).toHaveBeenCalledWith("test@example.com"); - - // And the second effect should always register the route change when Formbricks is enabled. - expect(mockRegisterRouteChange).toHaveBeenCalled(); - }); - - test("does not call setup, setUserId, or setEmail if userId is not provided yet still calls registerRouteChange", () => { - const mockSetup = vi.spyOn(formbricks, "setup"); - const mockSetUserId = vi.spyOn(formbricks, "setUserId"); - const mockSetEmail = vi.spyOn(formbricks, "setEmail"); - const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange"); - - render( - - ); - - // Since userId is falsy, the first effect should not call setup or assign user details. - expect(mockSetup).not.toHaveBeenCalled(); - expect(mockSetUserId).not.toHaveBeenCalled(); - expect(mockSetEmail).not.toHaveBeenCalled(); - - // The second effect only checks formbricksEnabled, so registerRouteChange should be called. - expect(mockRegisterRouteChange).toHaveBeenCalled(); - }); -}); diff --git a/apps/web/app/(app)/components/FormbricksClient.tsx b/apps/web/app/(app)/components/FormbricksClient.tsx deleted file mode 100644 index dc9a7e6ab6b8..000000000000 --- a/apps/web/app/(app)/components/FormbricksClient.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import { usePathname, useSearchParams } from "next/navigation"; -import { useEffect } from "react"; -import formbricks from "@formbricks/js"; - -interface FormbricksClientProps { - userId: string; - email: string; - formbricksEnvironmentId?: string; - formbricksApiHost?: string; - formbricksEnabled?: boolean; -} - -export const FormbricksClient = ({ - userId, - email, - formbricksEnvironmentId, - formbricksApiHost, - formbricksEnabled, -}: FormbricksClientProps) => { - const pathname = usePathname(); - const searchParams = useSearchParams(); - - useEffect(() => { - if (formbricksEnabled && userId) { - formbricks.setup({ - environmentId: formbricksEnvironmentId ?? "", - appUrl: formbricksApiHost ?? "", - }); - - formbricks.setUserId(userId); - formbricks.setEmail(email); - } - }, [userId, email, formbricksEnvironmentId, formbricksApiHost, formbricksEnabled]); - - useEffect(() => { - if (formbricksEnabled) { - formbricks.registerRouteChange(); - } - }, [pathname, searchParams, formbricksEnabled]); - - return null; -}; diff --git a/apps/web/app/(app)/components/LoadingCard.tsx b/apps/web/app/(app)/components/LoadingCard.tsx index be6d80c07358..521a6bfa6487 100644 --- a/apps/web/app/(app)/components/LoadingCard.tsx +++ b/apps/web/app/(app)/components/LoadingCard.tsx @@ -1,5 +1,5 @@ import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; -import { cn } from "@formbricks/lib/cn"; +import { cn } from "@/lib/cn"; export const LoadingCard = ({ title, diff --git a/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/[contactId]/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/[contactId]/page.test.tsx new file mode 100644 index 000000000000..0076f2b96153 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/[contactId]/page.test.tsx @@ -0,0 +1,43 @@ +import { SingleContactPage } from "@/modules/ee/contacts/[contactId]/page"; +import { describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +// mock constants +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + ENCRYPTION_KEY: "test", + ENTERPRISE_LICENSE_KEY: "test", + GITHUB_ID: "test", + GITHUB_SECRET: "test", + GOOGLE_CLIENT_ID: "test", + GOOGLE_CLIENT_SECRET: "test", + AZUREAD_CLIENT_ID: "mock-azuread-client-id", + AZUREAD_CLIENT_SECRET: "mock-azure-client-secret", + AZUREAD_TENANT_ID: "mock-azuread-tenant-id", + OIDC_CLIENT_ID: "mock-oidc-client-id", + OIDC_CLIENT_SECRET: "mock-oidc-client-secret", + OIDC_ISSUER: "mock-oidc-issuer", + OIDC_DISPLAY_NAME: "mock-oidc-display-name", + OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm", + WEBAPP_URL: "mock-webapp-url", + IS_PRODUCTION: true, + FB_LOGO_URL: "https://example.com/mock-logo.png", + SMTP_HOST: "mock-smtp-host", + SMTP_PORT: "mock-smtp-port", + IS_POSTHOG_CONFIGURED: true, + SESSION_MAX_AGE: 1000, + AUDIT_LOG_ENABLED: 1, + REDIS_URL: undefined, +})); + +vi.mock("@/lib/env", () => ({ + env: { + PUBLIC_URL: "https://public-domain.com", + }, +})); + +describe("Contact Page Re-export", () => { + test("should re-export SingleContactPage", () => { + expect(Page).toBe(SingleContactPage); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/page.test.tsx new file mode 100644 index 000000000000..921bf9edf3ed --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/page.test.tsx @@ -0,0 +1,15 @@ +import { ContactsPage } from "@/modules/ee/contacts/page"; +import { describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +// Mock the actual ContactsPage component +vi.mock("@/modules/ee/contacts/page", () => ({ + ContactsPage: () =>
Mock Contacts Page
, +})); + +describe("Contacts Page Re-export", () => { + test("should re-export ContactsPage from the EE module", () => { + // Assert that the default export 'Page' is the same as the mocked 'ContactsPage' + expect(Page).toBe(ContactsPage); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/(contacts)/segments/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/(contacts)/segments/page.test.tsx new file mode 100644 index 000000000000..97a4e0ca217c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/(contacts)/segments/page.test.tsx @@ -0,0 +1,18 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import SegmentsPageWrapper from "./page"; + +vi.mock("@/modules/ee/contacts/segments/page", () => ({ + SegmentsPage: vi.fn(() =>
SegmentsPageMock
), +})); + +describe("SegmentsPageWrapper", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the SegmentsPage component", () => { + render(); + expect(screen.getByText("SegmentsPageMock")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions.ts index 850f6ab96574..b5ac06ec7682 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions.ts @@ -1,16 +1,18 @@ "use server"; +import { getOrganization } from "@/lib/organization/service"; +import { getOrganizationProjectsCount } from "@/lib/project/service"; +import { updateUser } from "@/lib/user/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { + getAccessControlPermission, getOrganizationProjectsLimit, - getRoleManagementPermission, } from "@/modules/ee/license-check/lib/utils"; import { createProject } from "@/modules/projects/settings/lib/project"; import { z } from "zod"; -import { getOrganization } from "@formbricks/lib/organization/service"; -import { getOrganizationProjectsCount } from "@formbricks/lib/project/service"; -import { updateUser } from "@formbricks/lib/user/service"; import { ZId } from "@formbricks/types/common"; import { OperationNotAllowedError } from "@formbricks/types/errors"; import { ZProjectUpdateInput } from "@formbricks/types/project"; @@ -20,62 +22,65 @@ const ZCreateProjectAction = z.object({ data: ZProjectUpdateInput, }); -export const createProjectAction = authenticatedActionClient - .schema(ZCreateProjectAction) - .action(async ({ parsedInput, ctx }) => { - const { user } = ctx; +export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action( + withAuditLogging( + "created", + "project", + async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => { + const { user } = ctx; - const organizationId = parsedInput.organizationId; + const organizationId = parsedInput.organizationId; - await checkAuthorizationUpdated({ - userId: user.id, - organizationId: parsedInput.organizationId, - access: [ - { - data: parsedInput.data, - schema: ZProjectUpdateInput, - type: "organization", - roles: ["owner", "manager"], - }, - ], - }); + await checkAuthorizationUpdated({ + userId: user.id, + organizationId: parsedInput.organizationId, + access: [ + { + data: parsedInput.data, + schema: ZProjectUpdateInput, + type: "organization", + roles: ["owner", "manager"], + }, + ], + }); - const organization = await getOrganization(organizationId); + const organization = await getOrganization(organizationId); - if (!organization) { - throw new Error("Organization not found"); - } + if (!organization) { + throw new Error("Organization not found"); + } - const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits); - const organizationProjectsCount = await getOrganizationProjectsCount(organization.id); + const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits); + const organizationProjectsCount = await getOrganizationProjectsCount(organization.id); - if (organizationProjectsCount >= organizationProjectsLimit) { - throw new OperationNotAllowedError("Organization project limit reached"); - } + if (organizationProjectsCount >= organizationProjectsLimit) { + throw new OperationNotAllowedError("Organization project limit reached"); + } - if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) { - const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan); + if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) { + const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan); - if (!canDoRoleManagement) { - throw new OperationNotAllowedError("You do not have permission to manage roles"); + if (!isAccessControlAllowed) { + throw new OperationNotAllowedError("You do not have permission to manage roles"); + } } - } - const project = await createProject(parsedInput.organizationId, parsedInput.data); - const updatedNotificationSettings = { - ...user.notificationSettings, - alert: { - ...user.notificationSettings?.alert, - }, - weeklySummary: { - ...user.notificationSettings?.weeklySummary, - [project.id]: true, - }, - }; + const project = await createProject(parsedInput.organizationId, parsedInput.data); + const updatedNotificationSettings = { + ...user.notificationSettings, + alert: { + ...user.notificationSettings?.alert, + }, + }; - await updateUser(user.id, { - notificationSettings: updatedNotificationSettings, - }); + await updateUser(user.id, { + notificationSettings: updatedNotificationSettings, + }); - return project; - }); + ctx.auditLoggingCtx.organizationId = organizationId; + ctx.auditLoggingCtx.projectId = project.id; + ctx.auditLoggingCtx.newObject = project; + return project; + } + ) +); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions/actions.ts index 420343820bb5..119a51e2cdd1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions/actions.ts @@ -1,12 +1,13 @@ "use server"; +import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service"; +import { getSurveysByActionClassId } from "@/lib/survey/service"; import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { z } from "zod"; -import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service"; -import { cache } from "@formbricks/lib/cache"; -import { getSurveysByActionClassId } from "@formbricks/lib/survey/service"; import { ZActionClassInput } from "@formbricks/types/action-classes"; import { ZId } from "@formbricks/types/common"; import { ResourceNotFoundError } from "@formbricks/types/errors"; @@ -15,63 +16,80 @@ const ZDeleteActionClassAction = z.object({ actionClassId: ZId, }); -export const deleteActionClassAction = authenticatedActionClient - .schema(ZDeleteActionClassAction) - .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "projectTeam", - minPermission: "readWrite", - projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId), - }, - ], - }); - - await deleteActionClass(parsedInput.actionClassId); - }); +export const deleteActionClassAction = authenticatedActionClient.schema(ZDeleteActionClassAction).action( + withAuditLogging( + "deleted", + "actionClass", + async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => { + const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId); + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + minPermission: "readWrite", + projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId), + }, + ], + }); + ctx.auditLoggingCtx.organizationId = organizationId; + ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId; + ctx.auditLoggingCtx.oldObject = await getActionClass(parsedInput.actionClassId); + return await deleteActionClass(parsedInput.actionClassId); + } + ) +); const ZUpdateActionClassAction = z.object({ actionClassId: ZId, updatedAction: ZActionClassInput, }); -export const updateActionClassAction = authenticatedActionClient - .schema(ZUpdateActionClassAction) - .action(async ({ ctx, parsedInput }) => { - const actionClass = await getActionClass(parsedInput.actionClassId); - if (actionClass === null) { - throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId); - } +export const updateActionClassAction = authenticatedActionClient.schema(ZUpdateActionClassAction).action( + withAuditLogging( + "updated", + "actionClass", + async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => { + const actionClass = await getActionClass(parsedInput.actionClassId); + if (actionClass === null) { + throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId); + } - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "projectTeam", - minPermission: "readWrite", - projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId), - }, - ], - }); + const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId); - return await updateActionClass( - actionClass.environmentId, - parsedInput.actionClassId, - parsedInput.updatedAction - ); - }); + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + minPermission: "readWrite", + projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId), + }, + ], + }); + ctx.auditLoggingCtx.organizationId = organizationId; + ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId; + ctx.auditLoggingCtx.oldObject = actionClass; + const result = await updateActionClass( + actionClass.environmentId, + parsedInput.actionClassId, + parsedInput.updatedAction + ); + ctx.auditLoggingCtx.newObject = result; + return result; + } + ) +); const ZGetActiveInactiveSurveysAction = z.object({ actionClassId: ZId, @@ -104,31 +122,24 @@ export const getActiveInactiveSurveysAction = authenticatedActionClient return response; }); -const getLatestStableFbRelease = async (): Promise => - cache( - async () => { - try { - const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases"); - const releases = await res.json(); +const getLatestStableFbRelease = async (): Promise => { + try { + const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases"); + const releases = await res.json(); - if (Array.isArray(releases)) { - const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0] - ?.tag_name as string; - if (latestStableReleaseTag) { - return latestStableReleaseTag; - } - } - - return null; - } catch (err) { - return null; + if (Array.isArray(releases)) { + const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0] + ?.tag_name as string; + if (latestStableReleaseTag) { + return latestStableReleaseTag; } - }, - ["latest-fb-release"], - { - revalidate: 60 * 60 * 24, // 24 hours } - )(); + + return null; + } catch (err) { + return null; + } +}; export const getLatestStableFbReleaseAction = actionClient.action(async () => { return await getLatestStableFbRelease(); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.test.tsx new file mode 100644 index 000000000000..68165b03d095 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.test.tsx @@ -0,0 +1,343 @@ +import { createActionClassAction } from "@/modules/survey/editor/actions"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TActionClass } from "@formbricks/types/action-classes"; +import { TEnvironment } from "@formbricks/types/environment"; +import { getActiveInactiveSurveysAction } from "../actions"; +import { ActionActivityTab } from "./ActionActivityTab"; + +// Mock dependencies +vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({ + ACTION_TYPE_ICON_LOOKUP: { + noCode:
NoCodeIcon
, + automatic:
AutomaticIcon
, + code:
CodeIcon
, + }, +})); + +vi.mock("@/lib/time", () => ({ + convertDateTimeStringShort: (dateString: string) => `formatted-${dateString}`, +})); + +vi.mock("@/lib/utils/helper", () => ({ + getFormattedErrorMessage: (error: any) => `Formatted error: ${error?.message || "Unknown error"}`, +})); + +vi.mock("@/lib/utils/strings", () => ({ + capitalizeFirstLetter: (str: string) => str.charAt(0).toUpperCase() + str.slice(1), +})); + +vi.mock("@/modules/survey/editor/actions", () => ({ + createActionClassAction: vi.fn(), +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick, variant, ...props }: any) => ( + + ), +})); + +vi.mock("@/modules/ui/components/error-component", () => ({ + ErrorComponent: () =>
ErrorComponent
, +})); + +vi.mock("@/modules/ui/components/label", () => ({ + Label: ({ children, ...props }: any) => , +})); + +vi.mock("@/modules/ui/components/loading-spinner", () => ({ + LoadingSpinner: () =>
LoadingSpinner
, +})); + +vi.mock("../actions", () => ({ + getActiveInactiveSurveysAction: vi.fn(), +})); + +const mockActionClass = { + id: "action1", + createdAt: new Date("2023-01-01T10:00:00Z"), + updatedAt: new Date("2023-01-10T11:00:00Z"), + name: "Test Action", + description: "Test Description", + type: "noCode", + environmentId: "env1_dev", + noCodeConfig: { + /* ... */ + } as any, +} as unknown as TActionClass; + +const mockEnvironmentDev = { + id: "env1_dev", + createdAt: new Date(), + updatedAt: new Date(), + type: "development", +} as unknown as TEnvironment; + +const mockEnvironmentProd = { + id: "env1_prod", + createdAt: new Date(), + updatedAt: new Date(), + type: "production", +} as unknown as TEnvironment; + +const mockOtherEnvActionClasses: TActionClass[] = [ + { + id: "action2", + createdAt: new Date(), + updatedAt: new Date(), + name: "Existing Action Prod", + type: "noCode", + environmentId: "env1_prod", + } as unknown as TActionClass, + { + id: "action3", + createdAt: new Date(), + updatedAt: new Date(), + name: "Existing Code Action Prod", + type: "code", + key: "existing-key", + environmentId: "env1_prod", + } as unknown as TActionClass, +]; + +describe("ActionActivityTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getActiveInactiveSurveysAction).mockResolvedValue({ + data: { + activeSurveys: ["Active Survey 1"], + inactiveSurveys: ["Inactive Survey 1", "Inactive Survey 2"], + }, + }); + }); + + afterEach(() => { + cleanup(); + }); + + test("renders loading state initially", () => { + // Don't resolve the promise immediately + vi.mocked(getActiveInactiveSurveysAction).mockReturnValue(new Promise(() => {})); + render( + + ); + expect(screen.getByText("LoadingSpinner")).toBeInTheDocument(); + }); + + test("renders error state if fetching surveys fails", async () => { + vi.mocked(getActiveInactiveSurveysAction).mockResolvedValue({ + data: undefined, + }); + render( + + ); + // Wait for the component to update after the promise resolves + await screen.findByText("ErrorComponent"); + expect(screen.getByText("ErrorComponent")).toBeInTheDocument(); + }); + + test("renders survey lists and action details correctly", async () => { + render( + + ); + + // Wait for loading to finish + await screen.findByText("common.active_surveys"); + + // Check survey lists + expect(screen.getByText("Active Survey 1")).toBeInTheDocument(); + expect(screen.getByText("Inactive Survey 1")).toBeInTheDocument(); + expect(screen.getByText("Inactive Survey 2")).toBeInTheDocument(); + + // Check action details + // Use the actual Date.toString() output that the mock receives + expect(screen.getByText(`formatted-${mockActionClass.createdAt.toString()}`)).toBeInTheDocument(); // Created on + expect(screen.getByText(`formatted-${mockActionClass.updatedAt.toString()}`)).toBeInTheDocument(); // Last updated + expect(screen.getByText("NoCodeIcon")).toBeInTheDocument(); // Type icon + expect(screen.getByText("NoCode")).toBeInTheDocument(); // Type text + expect(screen.getByText("Development")).toBeInTheDocument(); // Environment + expect(screen.getByText("Copy to Production")).toBeInTheDocument(); // Copy button text + }); + + test("calls copyAction with correct data on button click", async () => { + vi.mocked(createActionClassAction).mockResolvedValue({ data: { id: "newAction" } as any }); + render( + + ); + + await screen.findByText("Copy to Production"); + const copyButton = screen.getByText("Copy to Production"); + await userEvent.click(copyButton); + + expect(createActionClassAction).toHaveBeenCalledTimes(1); + // Include the extra properties that the component sends due to spreading mockActionClass + const expectedActionInput = { + ...mockActionClass, // Spread the original object + name: "Test Action", // Keep the original name as it doesn't conflict + environmentId: "env1_prod", // Target environment ID + }; + // Remove properties not expected by the action call itself, even if sent by component + delete (expectedActionInput as any).id; + delete (expectedActionInput as any).createdAt; + delete (expectedActionInput as any).updatedAt; + + // The assertion now checks against the structure sent by the component + expect(createActionClassAction).toHaveBeenCalledWith({ + action: { + ...mockActionClass, // Include id, createdAt, updatedAt etc. + name: "Test Action", + environmentId: "env1_prod", + }, + }); + expect(toast.success).toHaveBeenCalledWith("environments.actions.action_copied_successfully"); + }); + + test("handles name conflict during copy", async () => { + vi.mocked(createActionClassAction).mockResolvedValue({ data: { id: "newAction" } as any }); + const conflictingActionClass = { ...mockActionClass, name: "Existing Action Prod" }; + render( + + ); + + await screen.findByText("Copy to Production"); + const copyButton = screen.getByText("Copy to Production"); + await userEvent.click(copyButton); + + expect(createActionClassAction).toHaveBeenCalledTimes(1); + + // The assertion now checks against the structure sent by the component + expect(createActionClassAction).toHaveBeenCalledWith({ + action: { + ...conflictingActionClass, // Include id, createdAt, updatedAt etc. + name: "Existing Action Prod (copy)", + environmentId: "env1_prod", + }, + }); + expect(toast.success).toHaveBeenCalledWith("environments.actions.action_copied_successfully"); + }); + + test("handles key conflict during copy for 'code' type", async () => { + const codeActionClass: TActionClass = { + ...mockActionClass, + id: "codeAction1", + type: "code", + key: "existing-key", // Conflicting key + noCodeConfig: { + /* ... */ + } as any, + }; + render( + + ); + + await screen.findByText("Copy to Production"); + const copyButton = screen.getByText("Copy to Production"); + await userEvent.click(copyButton); + + expect(createActionClassAction).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith("environments.actions.action_with_key_already_exists"); + }); + + test("shows error if copy action fails server-side", async () => { + vi.mocked(createActionClassAction).mockResolvedValue({ data: undefined }); + render( + + ); + + await screen.findByText("Copy to Production"); + const copyButton = screen.getByText("Copy to Production"); + await userEvent.click(copyButton); + + expect(createActionClassAction).toHaveBeenCalledTimes(1); + expect(toast.error).toHaveBeenCalledWith("environments.actions.action_copy_failed"); + }); + + test("shows error and prevents copy if user is read-only", async () => { + render( + + ); + + await screen.findByText("Copy to Production"); + const copyButton = screen.getByText("Copy to Production"); + await userEvent.click(copyButton); + + expect(createActionClassAction).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith("common.you_are_not_authorised_to_perform_this_action"); + }); + + test("renders correct copy button text for production environment", async () => { + render( + + ); + await screen.findByText("Copy to Development"); + expect(screen.getByText("Copy to Development")).toBeInTheDocument(); + expect(screen.getByText("Production")).toBeInTheDocument(); // Environment text + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.tsx index b6ccbedbf0ec..13d63c2ab6a9 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionActivityTab.tsx @@ -1,7 +1,9 @@ "use client"; import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils"; +import { convertDateTimeStringShort } from "@/lib/time"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { capitalizeFirstLetter } from "@/lib/utils/strings"; import { createActionClassAction } from "@/modules/survey/editor/actions"; import { Button } from "@/modules/ui/components/button"; import { ErrorComponent } from "@/modules/ui/components/error-component"; @@ -10,8 +12,6 @@ import { LoadingSpinner } from "@/modules/ui/components/loading-spinner"; import { useTranslate } from "@tolgee/react"; import { useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; -import { convertDateTimeStringShort } from "@formbricks/lib/time"; -import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings"; import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes"; import { TEnvironment } from "@formbricks/types/environment"; import { getActiveInactiveSurveysAction } from "../actions"; diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.test.tsx new file mode 100644 index 000000000000..6e8212fe5076 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.test.tsx @@ -0,0 +1,122 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TActionClass } from "@formbricks/types/action-classes"; +import { TEnvironment } from "@formbricks/types/environment"; +import { ActionClassesTable } from "./ActionClassesTable"; + +// Mock the ActionDetailModal +vi.mock("./ActionDetailModal", () => ({ + ActionDetailModal: ({ open, actionClass, setOpen }: any) => + open ? ( +
+ Modal for {actionClass.name} + +
+ ) : null, +})); + +const mockActionClasses: TActionClass[] = [ + { id: "1", name: "Action 1", type: "noCode", environmentId: "env1" } as TActionClass, + { id: "2", name: "Action 2", type: "code", environmentId: "env1" } as TActionClass, +]; + +const mockEnvironment: TEnvironment = { + id: "env1", + name: "Test Environment", + type: "development", +} as unknown as TEnvironment; +const mockOtherEnvironment: TEnvironment = { + id: "env2", + name: "Other Environment", + type: "production", +} as unknown as TEnvironment; + +const mockTableHeading =
Table Heading
; +const mockActionRows = mockActionClasses.map((action) => ( +
+ {action.name} Row +
+)); + +describe("ActionClassesTable", () => { + afterEach(() => { + cleanup(); + }); + + test("renders table heading and action rows when actions exist", () => { + render( + + {[mockTableHeading, mockActionRows]} + + ); + + expect(screen.getByTestId("table-heading")).toBeInTheDocument(); + expect(screen.getByTestId("action-row-1")).toBeInTheDocument(); + expect(screen.getByTestId("action-row-2")).toBeInTheDocument(); + expect(screen.queryByText("No actions found")).not.toBeInTheDocument(); + }); + + test("renders 'No actions found' message when no actions exist", () => { + render( + + {[mockTableHeading, []]} + + ); + + expect(screen.getByTestId("table-heading")).toBeInTheDocument(); + expect(screen.getByText("No actions found")).toBeInTheDocument(); + expect(screen.queryByTestId("action-row-1")).not.toBeInTheDocument(); + }); + + test("opens ActionDetailModal with correct action when a row is clicked", async () => { + render( + + {[mockTableHeading, mockActionRows]} + + ); + + // Modal should not be open initially + expect(screen.queryByTestId("action-detail-modal")).not.toBeInTheDocument(); + + // Find the button wrapping the first action row + const actionButton1 = screen.getByTitle("Action 1"); + await userEvent.click(actionButton1); + + // Modal should now be open with the correct action name + const modal = screen.getByTestId("action-detail-modal"); + expect(modal).toBeInTheDocument(); + expect(modal).toHaveTextContent("Modal for Action 1"); + + // Close the modal + await userEvent.click(screen.getByText("Close Modal")); + expect(screen.queryByTestId("action-detail-modal")).not.toBeInTheDocument(); + + // Click the second action button + const actionButton2 = screen.getByTitle("Action 2"); + await userEvent.click(actionButton2); + + // Modal should open for the second action + const modal2 = screen.getByTestId("action-detail-modal"); + expect(modal2).toBeInTheDocument(); + expect(modal2).toHaveTextContent("Modal for Action 2"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx index 6a73091bcf31..1f074a670737 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable.tsx @@ -24,14 +24,17 @@ export const ActionClassesTable = ({ otherEnvActionClasses, otherEnvironment, }: ActionClassesTableProps) => { - const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false); + const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false); const [activeActionClass, setActiveActionClass] = useState(); - const handleOpenActionDetailModalClick = (e, actionClass: TActionClass) => { + const handleOpenActionDetailModalClick = ( + e: React.MouseEvent, + actionClass: TActionClass + ) => { e.preventDefault(); setActiveActionClass(actionClass); - setActionDetailModalOpen(true); + setIsActionDetailModalOpen(true); }; return ( @@ -42,7 +45,7 @@ export const ActionClassesTable = ({ {actionClasses.length > 0 ? ( actionClasses.map((actionClass, index) => ( +
+ ) : null, + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogBody: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("./ActionActivityTab", () => ({ + ActionActivityTab: vi.fn(() =>
ActionActivityTab
), +})); + +vi.mock("./ActionSettingsTab", () => ({ + ActionSettingsTab: vi.fn(() =>
ActionSettingsTab
), +})); + +// Mock the utils file to control ACTION_TYPE_ICON_LOOKUP +vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({ + ACTION_TYPE_ICON_LOOKUP: { + code:
Code Icon Mock
, + noCode:
No Code Icon Mock
, + // Add other types if needed by other tests or default props + }, +})); + +// Mock useTranslate +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => { + const translations = { + "common.activity": "Activity", + "common.settings": "Settings", + "common.no_code": "No Code", + "common.action": "Action", + "common.code": "Code", + }; + return translations[key] || key; + }, + }), +})); + +const mockEnvironmentId = "test-env-id"; +const mockSetOpen = vi.fn(); + +const mockEnvironment = { + id: mockEnvironmentId, + createdAt: new Date(), + updatedAt: new Date(), + type: "production", // Use string literal as TEnvironmentType is not exported + appSetupCompleted: false, +} as unknown as TEnvironment; + +const mockActionClass: TActionClass = { + id: "action-class-1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Action", + description: "This is a test action", + type: "code", // Ensure this matches a key in the mocked ACTION_TYPE_ICON_LOOKUP + environmentId: mockEnvironmentId, + noCodeConfig: null, + key: "test-action-key", +}; + +const mockActionClasses: TActionClass[] = [mockActionClass]; +const mockOtherEnvActionClasses: TActionClass[] = []; +const mockOtherEnvironment = { ...mockEnvironment, id: "other-env-id", name: "Other Environment" }; + +const defaultProps = { + environmentId: mockEnvironmentId, + environment: mockEnvironment, + open: true, + setOpen: mockSetOpen, + actionClass: mockActionClass, + actionClasses: mockActionClasses, + isReadOnly: false, + otherEnvironment: mockOtherEnvironment, + otherEnvActionClasses: mockOtherEnvActionClasses, +}; + +describe("ActionDetailModal", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); // Clear mocks after each test + }); + + test("renders correctly when open", () => { + render(); + + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action"); + expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test action"); + expect(screen.getByTestId("code-icon")).toBeInTheDocument(); + expect(screen.getByText("Activity")).toBeInTheDocument(); + expect(screen.getByText("Settings")).toBeInTheDocument(); + // Only the first tab (Activity) should be active initially + expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument(); + }); + + test("does not render when open is false", () => { + render(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + }); + + test("switches tabs correctly", async () => { + const user = userEvent.setup(); + render(); + + // Initially shows activity tab (first tab is active) + expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument(); + + // Click settings tab + const settingsTab = screen.getByText("Settings"); + await user.click(settingsTab); + + // Now shows settings tab content + expect(screen.queryByTestId("action-activity-tab")).not.toBeInTheDocument(); + expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument(); + + // Click activity tab again + const activityTab = screen.getByText("Activity"); + await user.click(activityTab); + + // Back to activity tab content + expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument(); + }); + + test("resets to first tab when modal is reopened", async () => { + const user = userEvent.setup(); + const { rerender } = render(); + + // Switch to settings tab + const settingsTab = screen.getByText("Settings"); + await user.click(settingsTab); + expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument(); + + // Close modal + rerender(); + + // Reopen modal + rerender(); + + // Should be back to activity tab (first tab) + expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument(); + }); + + test("renders correct icon based on action type", () => { + // Test with 'noCode' type + const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass; + render(); + + expect(screen.getByTestId("nocode-icon")).toBeInTheDocument(); + expect(screen.queryByTestId("code-icon")).not.toBeInTheDocument(); + }); + + test("handles action without description", () => { + const actionWithoutDescription = { ...mockActionClass, description: "" }; + render(); + + expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action"); + expect(screen.getByTestId("dialog-description")).toHaveTextContent("Code action"); + }); + + test("passes correct props to ActionActivityTab", () => { + render(); + + const mockedActionActivityTab = vi.mocked(ActionActivityTab); + expect(mockedActionActivityTab).toHaveBeenCalledWith( + { + otherEnvActionClasses: mockOtherEnvActionClasses, + otherEnvironment: mockOtherEnvironment, + isReadOnly: false, + environment: mockEnvironment, + actionClass: mockActionClass, + environmentId: mockEnvironmentId, + }, + undefined + ); + }); + + test("passes correct props to ActionSettingsTab when tab is active", async () => { + const user = userEvent.setup(); + render(); + + // ActionSettingsTab should not be called initially since first tab is active + const mockedActionSettingsTab = vi.mocked(ActionSettingsTab); + expect(mockedActionSettingsTab).not.toHaveBeenCalled(); + + // Click the settings tab to activate ActionSettingsTab + const settingsTab = screen.getByText("Settings"); + await user.click(settingsTab); + + // Now ActionSettingsTab should be called with correct props + expect(mockedActionSettingsTab).toHaveBeenCalledWith( + { + actionClass: mockActionClass, + actionClasses: mockActionClasses, + setOpen: mockSetOpen, + isReadOnly: false, + }, + undefined + ); + }); + + test("passes isReadOnly prop correctly", () => { + render(); + + const mockedActionActivityTab = vi.mocked(ActionActivityTab); + expect(mockedActionActivityTab).toHaveBeenCalledWith( + expect.objectContaining({ + isReadOnly: true, + }), + undefined + ); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx index 8a3f169abc77..276f7ef52823 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionDetailModal.tsx @@ -59,16 +59,24 @@ export const ActionDetailModal = ({ }, ]; + const typeDescription = () => { + if (actionClass.description) return actionClass.description; + else + return ( + (actionClass.type && actionClass.type === "noCode" ? t("common.no_code") : t("common.code")) + + " " + + t("common.action").toLowerCase() + ); + }; + return ( - <> - - + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.test.tsx new file mode 100644 index 000000000000..1d443063637c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.test.tsx @@ -0,0 +1,63 @@ +import { timeSince } from "@/lib/time"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TActionClass } from "@formbricks/types/action-classes"; +import { ActionClassDataRow } from "./ActionRowData"; + +vi.mock("@/lib/time", () => ({ + timeSince: vi.fn(), +})); + +const mockActionClass: TActionClass = { + id: "testId", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Action", + description: "This is a test action", + type: "code", + noCodeConfig: null, + environmentId: "envId", + key: null, +}; + +const locale = "en-US"; +const timeSinceOutput = "2 hours ago"; + +describe("ActionClassDataRow", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders code action correctly", () => { + vi.mocked(timeSince).mockReturnValue(timeSinceOutput); + const actionClass = { ...mockActionClass, type: "code" } as TActionClass; + render(); + + expect(screen.getByText(actionClass.name)).toBeInTheDocument(); + expect(screen.getByText(actionClass.description!)).toBeInTheDocument(); + expect(screen.getByText(timeSinceOutput)).toBeInTheDocument(); + expect(timeSince).toHaveBeenCalledWith(actionClass.createdAt.toString(), locale); + }); + + test("renders no-code action correctly", () => { + vi.mocked(timeSince).mockReturnValue(timeSinceOutput); + const actionClass = { ...mockActionClass, type: "noCode" } as TActionClass; + render(); + + expect(screen.getByText(actionClass.name)).toBeInTheDocument(); + expect(screen.getByText(actionClass.description!)).toBeInTheDocument(); + expect(screen.getByText(timeSinceOutput)).toBeInTheDocument(); + expect(timeSince).toHaveBeenCalledWith(actionClass.createdAt.toString(), locale); + }); + + test("renders without description", () => { + vi.mocked(timeSince).mockReturnValue(timeSinceOutput); + const actionClass = { ...mockActionClass, description: undefined } as unknown as TActionClass; + render(); + + expect(screen.getByText(actionClass.name)).toBeInTheDocument(); + expect(screen.queryByText("This is a test action")).not.toBeInTheDocument(); + expect(screen.getByText(timeSinceOutput)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.tsx index ecc90f9a4e9c..e4b4c12c6e3a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.tsx @@ -1,5 +1,5 @@ import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils"; -import { timeSince } from "@formbricks/lib/time"; +import { timeSince } from "@/lib/time"; import { TActionClass } from "@formbricks/types/action-classes"; import { TUserLocale } from "@formbricks/types/user"; @@ -11,22 +11,21 @@ export const ActionClassDataRow = ({ locale: TUserLocale; }) => { return ( -
-
-
-
+
+
+
+
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
-
-
{actionClass.name}
-
{actionClass.description}
+
+
{actionClass.name}
+
{actionClass.description}
{timeSince(actionClass.createdAt.toString(), locale)}
-
); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.test.tsx new file mode 100644 index 000000000000..cc89285d0111 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.test.tsx @@ -0,0 +1,409 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { toast } from "react-hot-toast"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TActionClass, TActionClassNoCodeConfig, TActionClassType } from "@formbricks/types/action-classes"; +import { ActionSettingsTab } from "./ActionSettingsTab"; + +// Mock actions +vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({ + deleteActionClassAction: vi.fn(), + updateActionClassAction: vi.fn(), +})); + +// Mock action utils +vi.mock("@/modules/survey/editor/lib/action-utils", () => ({ + useActionClassKeys: vi.fn(() => ["existing-key"]), + createActionClassZodResolver: vi.fn(() => vi.fn()), + validatePermissions: vi.fn(), +})); + +// Mock action builder +vi.mock("@/modules/survey/editor/lib/action-builder", () => ({ + buildActionObject: vi.fn((data, environmentId, t) => ({ + ...data, + environmentId, + })), +})); + +// Mock utils +vi.mock("@/app/lib/actionClass/actionClass", () => ({ + isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"), +})); + +// Mock UI components +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick, variant, loading, ...props }: any) => ( + + ), +})); + +vi.mock("@/modules/ui/components/code-action-form", () => ({ + CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => ( +
+ Code Action Form +
+ ), +})); + +vi.mock("@/modules/ui/components/delete-dialog", () => ({ + DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) => + open ? ( +
+ Delete Dialog + + +
+ ) : null, +})); + +vi.mock("@/modules/ui/components/action-name-description-fields", () => ({ + ActionNameDescriptionFields: ({ isReadOnly, nameInputId, descriptionInputId }: any) => ( +
+ + +
+ ), +})); + +vi.mock("@/modules/ui/components/no-code-action-form", () => ({ + NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => ( +
+ No Code Action Form +
+ ), +})); + +// Mock icons +vi.mock("lucide-react", () => ({ + TrashIcon: () =>
Trash
, +})); + +// Mock react-hook-form +const mockHandleSubmit = vi.fn(); +const mockForm = { + handleSubmit: mockHandleSubmit, + control: {}, + formState: { errors: {} }, +}; + +vi.mock("react-hook-form", async () => { + const actual = await vi.importActual("react-hook-form"); + return { + ...actual, + useForm: vi.fn(() => mockForm), + FormProvider: ({ children }: any) =>
{children}
, + }; +}); + +const mockSetOpen = vi.fn(); +const mockActionClasses: TActionClass[] = [ + { + id: "action1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Existing Action", + description: "An existing action", + type: "noCode", + environmentId: "env1", + noCodeConfig: { type: "click" } as TActionClassNoCodeConfig, + } as unknown as TActionClass, +]; + +const createMockActionClass = (id: string, type: TActionClassType, name: string): TActionClass => + ({ + id, + createdAt: new Date(), + updatedAt: new Date(), + name, + description: `${name} description`, + type, + environmentId: "env1", + ...(type === "code" && { key: `${name}-key` }), + ...(type === "noCode" && { + noCodeConfig: { type: "url", rule: "exactMatch", value: `http://${name}.com` }, + }), + }) as unknown as TActionClass; + +describe("ActionSettingsTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockHandleSubmit.mockImplementation((fn) => fn); + }); + + afterEach(() => { + cleanup(); + }); + + test("renders correctly for 'code' action type", () => { + const actionClass = createMockActionClass("code1", "code", "Code Action"); + render( + + ); + + expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument(); + expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument(); + expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument(); + expect(screen.getByTestId("code-action-form")).toBeInTheDocument(); + expect( + screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base") + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument(); + }); + + test("renders correctly for 'noCode' action type", () => { + const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); + render( + + ); + + expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument(); + expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument(); + }); + + test("renders correctly for other action types (fallback)", () => { + const actionClass = { + ...createMockActionClass("auto1", "noCode", "Auto Action"), + type: "automatic" as any, + }; + render( + + ); + + expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument(); + expect( + screen.getByText( + "environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it" + ) + ).toBeInTheDocument(); + }); + + test("calls utility functions on initialization", async () => { + const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils"); + + const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); + render( + + ); + + expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses); + expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled(); + }); + + test("handles successful form submission", async () => { + const { updateActionClassAction } = await import( + "@/app/(app)/environments/[environmentId]/actions/actions" + ); + const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils"); + + vi.mocked(updateActionClassAction).mockResolvedValue({ data: {} } as any); + + const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); + render( + + ); + + // Check that utility functions were called during component initialization + expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses); + expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled(); + }); + + test("handles permission validation error", async () => { + const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils"); + vi.mocked(actionUtilsMock.validatePermissions).mockImplementation(() => { + throw new Error("Not authorized"); + }); + + const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); + render( + + ); + + const submitButton = screen.getByRole("button", { name: "common.save_changes" }); + + mockHandleSubmit.mockImplementation((fn) => (e) => { + e.preventDefault(); + return fn({ name: "Test", type: "noCode" }); + }); + + await userEvent.click(submitButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Not authorized"); + }); + }); + + test("handles successful deletion", async () => { + const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); + const { deleteActionClassAction } = await import( + "@/app/(app)/environments/[environmentId]/actions/actions" + ); + vi.mocked(deleteActionClassAction).mockResolvedValue({ data: actionClass } as any); + + render( + + ); + + const deleteButtonTrigger = screen.getByRole("button", { name: /common.delete/ }); + await userEvent.click(deleteButtonTrigger); + + expect(screen.getByTestId("delete-dialog")).toBeInTheDocument(); + + const confirmDeleteButton = screen.getByRole("button", { name: "Confirm Delete" }); + await userEvent.click(confirmDeleteButton); + + await waitFor(() => { + expect(deleteActionClassAction).toHaveBeenCalledWith({ actionClassId: actionClass.id }); + expect(toast.success).toHaveBeenCalledWith("environments.actions.action_deleted_successfully"); + expect(mockSetOpen).toHaveBeenCalledWith(false); + }); + }); + + test("handles deletion failure", async () => { + const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); + const { deleteActionClassAction } = await import( + "@/app/(app)/environments/[environmentId]/actions/actions" + ); + vi.mocked(deleteActionClassAction).mockRejectedValue(new Error("Deletion failed")); + + render( + + ); + + const deleteButtonTrigger = screen.getByRole("button", { name: /common.delete/ }); + await userEvent.click(deleteButtonTrigger); + const confirmDeleteButton = screen.getByRole("button", { name: "Confirm Delete" }); + await userEvent.click(confirmDeleteButton); + + await waitFor(() => { + expect(deleteActionClassAction).toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again"); + }); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + test("renders read-only state correctly", () => { + const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); + render( + + ); + + expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeDisabled(); + expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeDisabled(); + expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true"); + expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); + }); + + test("prevents delete when read-only", async () => { + const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); + const { deleteActionClassAction } = await import( + "@/app/(app)/environments/[environmentId]/actions/actions" + ); + + render( + + ); + + expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument(); + expect(deleteActionClassAction).not.toHaveBeenCalled(); + }); + + test("renders docs link correctly", () => { + const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); + render( + + ); + const docsLink = screen.getByRole("link", { name: "common.read_docs" }); + expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code"); + expect(docsLink).toHaveAttribute("target", "_blank"); + }); + + test("uses correct input IDs for ActionNameDescriptionFields", () => { + const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); + render( + + ); + + expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument(); + expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx index cb6da320891a..9952d569db7b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionSettingsTab.tsx @@ -4,14 +4,17 @@ import { deleteActionClassAction, updateActionClassAction, } from "@/app/(app)/environments/[environmentId]/actions/actions"; -import { isValidCssSelector } from "@/app/lib/actionClass/actionClass"; +import { buildActionObject } from "@/modules/survey/editor/lib/action-builder"; +import { + createActionClassZodResolver, + useActionClassKeys, + validatePermissions, +} from "@/modules/survey/editor/lib/action-utils"; +import { ActionNameDescriptionFields } from "@/modules/ui/components/action-name-description-fields"; import { Button } from "@/modules/ui/components/button"; import { CodeActionForm } from "@/modules/ui/components/code-action-form"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; -import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; -import { Input } from "@/modules/ui/components/input"; import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form"; -import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslate } from "@tolgee/react"; import { TrashIcon } from "lucide-react"; import Link from "next/link"; @@ -19,8 +22,7 @@ import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; -import { z } from "zod"; -import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes"; +import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes"; interface ActionSettingsTabProps { actionClass: TActionClass; @@ -48,63 +50,51 @@ export const ActionSettingsTab = ({ [actionClass.id, actionClasses] ); + const actionClassKeys = useActionClassKeys(actionClasses); + const form = useForm({ defaultValues: { ...restActionClass, }, - resolver: zodResolver( - ZActionClassInput.superRefine((data, ctx) => { - if (data.name && actionClassNames.includes(data.name)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["name"], - message: t("environments.actions.action_with_name_already_exists", { name: data.name }), - }); - } - }) - ), + resolver: createActionClassZodResolver(actionClassNames, actionClassKeys, t), mode: "onChange", }); const { handleSubmit, control } = form; + const renderActionForm = () => { + if (actionClass.type === "code") { + return ( + <> + +

+ {t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")} +

+ + ); + } + + if (actionClass.type === "noCode") { + return ; + } + + return ( +

+ {t("environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it")} +

+ ); + }; + const onSubmit = async (data: TActionClassInput) => { try { - if (isReadOnly) { - throw new Error(t("common.you_are_not_authorised_to_perform_this_action")); - } setIsUpdatingAction(true); + validatePermissions(isReadOnly, t); + const updatedAction = buildActionObject(data, actionClass.environmentId, t); - if (data.name && actionClassNames.includes(data.name)) { - throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name })); - } - - if ( - data.type === "noCode" && - data.noCodeConfig?.type === "click" && - data.noCodeConfig.elementSelector.cssSelector && - !isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector) - ) { - throw new Error(t("environments.actions.invalid_css_selector")); - } - - const updatedData: TActionClassInput = { - ...data, - ...(data.type === "noCode" && - data.noCodeConfig?.type === "click" && { - noCodeConfig: { - ...data.noCodeConfig, - elementSelector: { - cssSelector: data.noCodeConfig.elementSelector.cssSelector, - innerHtml: data.noCodeConfig.elementSelector.innerHtml, - }, - }, - }), - }; await updateActionClassAction({ actionClassId: actionClass.id, - updatedAction: updatedData, + updatedAction: updatedAction, }); setOpen(false); router.refresh(); @@ -123,7 +113,7 @@ export const ActionSettingsTab = ({ router.refresh(); toast.success(t("environments.actions.action_deleted_successfully")); setOpen(false); - } catch (error) { + } catch { toast.error(t("common.something_went_wrong_please_try_again")); } finally { setIsDeletingAction(false); @@ -135,89 +125,23 @@ export const ActionSettingsTab = ({
-
-
- ( - - - {actionClass.type === "noCode" - ? t("environments.actions.what_did_your_user_do") - : t("environments.actions.display_name")} - - - - - - - - - )} - /> -
- -
- ( - - - {t("common.description")} - - - - - - - )} - /> -
-
- - {actionClass.type === "code" ? ( - <> - -

- {t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")} -

- - ) : actionClass.type === "noCode" ? ( - - ) : ( -

- {t( - "environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it" - )} -

- )} + + + {renderActionForm()}
-
-
+
+
{!isReadOnly ? ( +
+ )), +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick, ...props }: any) => ( + + ), +})); + +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: ({ children, open, onOpenChange }: any) => + open ? ( +
+ {children} + +
+ ) : null, + DialogContent: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children, className }: any) => ( +

+ {children} +

+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogBody: ({ children }: any) =>
{children}
, +})); + +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("lucide-react", () => ({ + MousePointerClickIcon: () =>
, + PlusIcon: () =>
, +})); + +const mockActionClasses: TActionClass[] = [ + { + id: "action1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Action 1", + description: "Description 1", + type: "noCode", + environmentId: "env1", + noCodeConfig: { type: "click" } as unknown as TActionClassNoCodeConfig, + } as unknown as TActionClass, +]; + +const environmentId = "env1"; + +describe("AddActionModal", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders the 'Add Action' button initially", () => { + render( + + ); + expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument(); + expect(screen.getByTestId("plus-icon")).toBeInTheDocument(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + }); + + test("opens the dialog when the 'Add Action' button is clicked", async () => { + render( + + ); + const addButton = screen.getByRole("button", { name: "common.add_action" }); + await userEvent.click(addButton); + + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-content")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-header")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-title")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-body")).toBeInTheDocument(); + expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument(); + expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument(); + expect( + screen.getByText("environments.actions.track_user_action_to_display_surveys_or_create_user_segment") + ).toBeInTheDocument(); + expect(screen.getByTestId("create-new-action-tab")).toBeInTheDocument(); + }); + + test("passes correct props to CreateNewActionTab", async () => { + const { CreateNewActionTab } = await import("@/modules/survey/editor/components/create-new-action-tab"); + const mockedCreateNewActionTab = vi.mocked(CreateNewActionTab); + + render( + + ); + const addButton = screen.getByRole("button", { name: "common.add_action" }); + await userEvent.click(addButton); + + expect(mockedCreateNewActionTab).toHaveBeenCalled(); + const props = mockedCreateNewActionTab.mock.calls[0][0]; + expect(props.environmentId).toBe(environmentId); + expect(props.actionClasses).toEqual(mockActionClasses); // Initial state check + expect(props.isReadOnly).toBe(false); + expect(props.setOpen).toBeInstanceOf(Function); + expect(props.setActionClasses).toBeInstanceOf(Function); + }); + + test("closes the dialog when the close button (simulated) is clicked", async () => { + render( + + ); + const addButton = screen.getByRole("button", { name: "common.add_action" }); + await userEvent.click(addButton); + + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + + // Simulate closing via the mocked Dialog's close button + const closeDialogButton = screen.getByText("Close Dialog"); + await userEvent.click(closeDialogButton); + + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + }); + + test("closes the dialog when setOpen is called from CreateNewActionTab", async () => { + render( + + ); + const addButton = screen.getByRole("button", { name: "common.add_action" }); + await userEvent.click(addButton); + + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + + // Simulate closing via the mocked CreateNewActionTab's button + const closeFromTabButton = screen.getByText("Close from Tab"); + await userEvent.click(closeFromTabButton); + + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx index 635f94c10a87..99b05c0439d3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/AddActionModal.tsx @@ -2,7 +2,14 @@ import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab"; import { Button } from "@/modules/ui/components/button"; -import { Modal } from "@/modules/ui/components/modal"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; import { useTranslate } from "@tolgee/react"; import { MousePointerClickIcon, PlusIcon } from "lucide-react"; import { useState } from "react"; @@ -26,36 +33,26 @@ export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: Add {t("common.add_action")} - -
-
-
-
-
- -
-
-
- {t("environments.actions.track_new_user_action")} -
-
- {t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")} -
-
-
-
-
-
-
- -
-
+ + + + + {t("environments.actions.track_new_user_action")} + + {t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")} + + + + + + + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/loading.test.tsx new file mode 100644 index 000000000000..0a024ce20f20 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/actions/loading.test.tsx @@ -0,0 +1,44 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import Loading from "./loading"; + +// Mock child components +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: ({ pageTitle }: { pageTitle: string }) =>
{pageTitle}
, +})); + +describe("Loading", () => { + afterEach(() => { + cleanup(); + }); + + test("renders loading state correctly", () => { + render(); + + // Check if mocked components are rendered + expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument(); + expect(screen.getByTestId("page-header")).toBeInTheDocument(); + expect(screen.getByTestId("page-header")).toHaveTextContent("common.actions"); + + // Check for translated table headers + expect(screen.getByText("environments.actions.user_actions")).toBeInTheDocument(); + expect(screen.getByText("common.created")).toBeInTheDocument(); + expect(screen.getByText("common.edit")).toBeInTheDocument(); // Screen reader text + + // Check for skeleton elements (presence of animate-pulse class) + const skeletonElements = document.querySelectorAll(".animate-pulse"); + expect(skeletonElements.length).toBeGreaterThan(0); // Ensure some skeleton elements are rendered + + // Check for the presence of multiple skeleton rows (3 rows * 4 pulse elements per row = 12) + const pulseDivs = screen.getAllByText((_, element) => { + return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse"); + }); + expect(pulseDivs.length).toBe(3 * 4); // 3 rows, 4 pulsing divs per row (icon, name, desc, created) + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/page.test.tsx new file mode 100644 index 000000000000..ed5be4ba1933 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/actions/page.test.tsx @@ -0,0 +1,161 @@ +import { getActionClasses } from "@/lib/actionClass/service"; +import { getEnvironments } from "@/lib/environment/service"; +import { findMatchingLocale } from "@/lib/utils/locale"; +import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth"; +import { cleanup, render, screen } from "@testing-library/react"; +import { redirect } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TActionClass } from "@formbricks/types/action-classes"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TProject } from "@formbricks/types/project"; +// Import the component after mocks +import Page from "./page"; + +// Mock dependencies +vi.mock("@/lib/actionClass/service", () => ({ + getActionClasses: vi.fn(), +})); +vi.mock("@/lib/environment/service", () => ({ + getEnvironments: vi.fn(), +})); +vi.mock("@/lib/utils/locale", () => ({ + findMatchingLocale: vi.fn(), +})); +vi.mock("@/modules/environments/lib/utils", () => ({ + getEnvironmentAuth: vi.fn(), +})); +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); +vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable", () => ({ + ActionClassesTable: ({ children }) =>
ActionClassesTable Mock{children}
, +})); +vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionRowData", () => ({ + ActionClassDataRow: ({ actionClass }) =>
ActionClassDataRow Mock: {actionClass.name}
, +})); +vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading", () => ({ + ActionTableHeading: () =>
ActionTableHeading Mock
, +})); +vi.mock("@/app/(app)/environments/[environmentId]/actions/components/AddActionModal", () => ({ + AddActionModal: () =>
AddActionModal Mock
, +})); +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: ({ children }) =>
PageContentWrapper Mock{children}
, +})); +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: ({ pageTitle, cta }) => ( +
+ PageHeader Mock: {pageTitle} {cta &&
CTA Mock
} +
+ ), +})); + +// Mock data +const mockEnvironmentId = "test-env-id"; +const mockProjectId = "test-project-id"; +const mockEnvironment = { + id: mockEnvironmentId, + name: "Test Environment", + type: "development", +} as unknown as TEnvironment; +const mockOtherEnvironment = { + id: "other-env-id", + name: "Other Environment", + type: "production", +} as unknown as TEnvironment; +const mockProject = { id: mockProjectId, name: "Test Project" } as unknown as TProject; +const mockActionClasses = [ + { id: "action1", name: "Action 1", type: "code", environmentId: mockEnvironmentId } as TActionClass, + { id: "action2", name: "Action 2", type: "noCode", environmentId: mockEnvironmentId } as TActionClass, +]; +const mockOtherEnvActionClasses = [ + { id: "action3", name: "Action 3", type: "code", environmentId: mockOtherEnvironment.id } as TActionClass, +]; +const mockLocale = "en-US"; + +const mockParams = { environmentId: mockEnvironmentId }; +const mockProps = { params: mockParams }; + +describe("Actions Page", () => { + beforeEach(() => { + vi.mocked(getActionClasses) + .mockResolvedValueOnce(mockActionClasses) // First call for current env + .mockResolvedValueOnce(mockOtherEnvActionClasses); // Second call for other env + vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment, mockOtherEnvironment]); + vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale); + }); + + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + test("renders the page correctly with actions", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + isReadOnly: false, + project: mockProject, + isBilling: false, + environment: mockEnvironment, + } as TEnvironmentAuth); + + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument(); + expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // AddActionModal rendered via CTA + expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument(); + expect(screen.getByText("ActionTableHeading Mock")).toBeInTheDocument(); + expect(screen.getByText("ActionClassDataRow Mock: Action 1")).toBeInTheDocument(); + expect(screen.getByText("ActionClassDataRow Mock: Action 2")).toBeInTheDocument(); + expect(vi.mocked(redirect)).not.toHaveBeenCalled(); + }); + + test("redirects if isBilling is true", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + isReadOnly: false, + project: mockProject, + isBilling: true, + environment: mockEnvironment, + } as TEnvironmentAuth); + + await Page(mockProps); + + expect(vi.mocked(redirect)).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/settings/billing`); + }); + + test("does not render AddActionModal CTA if isReadOnly is true", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + isReadOnly: true, + project: mockProject, + isBilling: false, + environment: mockEnvironment, + } as TEnvironmentAuth); + + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument(); + expect(screen.queryByText("CTA Mock")).not.toBeInTheDocument(); // CTA should not be present + expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument(); + }); + + test("renders AddActionModal CTA if isReadOnly is false", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + isReadOnly: false, + project: mockProject, + isBilling: false, + environment: mockEnvironment, + } as TEnvironmentAuth); + + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument(); + expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // CTA should be present + expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/page.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/page.tsx index 05e61078cff1..79500fa971fa 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/page.tsx @@ -2,15 +2,15 @@ import { ActionClassesTable } from "@/app/(app)/environments/[environmentId]/act import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData"; import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading"; import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal"; +import { getActionClasses } from "@/lib/actionClass/service"; +import { getEnvironments } from "@/lib/environment/service"; +import { findMatchingLocale } from "@/lib/utils/locale"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getTranslate } from "@/tolgee/server"; import { Metadata } from "next"; import { redirect } from "next/navigation"; -import { getActionClasses } from "@formbricks/lib/actionClass/service"; -import { getEnvironments } from "@formbricks/lib/environment/service"; -import { findMatchingLocale } from "@formbricks/lib/utils/locale"; export const metadata: Metadata = { title: "Actions", diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/utils.test.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/utils.test.tsx new file mode 100644 index 000000000000..58624e635161 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/actions/utils.test.tsx @@ -0,0 +1,39 @@ +import { cleanup, render } from "@testing-library/react"; +import { Code2Icon, MousePointerClickIcon } from "lucide-react"; +import React from "react"; +import { afterEach, describe, expect, test } from "vitest"; +import { ACTION_TYPE_ICON_LOOKUP } from "./utils"; + +describe("ACTION_TYPE_ICON_LOOKUP", () => { + afterEach(() => { + cleanup(); + }); + + test("should contain the correct icon for 'code'", () => { + expect(ACTION_TYPE_ICON_LOOKUP).toHaveProperty("code"); + const IconComponent = ACTION_TYPE_ICON_LOOKUP.code; + expect(React.isValidElement(IconComponent)).toBe(true); + + // Render the icon and check if it's the correct Lucide icon + const { container } = render(IconComponent); + const svgElement = container.querySelector("svg"); + expect(svgElement).toBeInTheDocument(); + // Check for a class or attribute specific to Code2Icon if possible, + // or compare the rendered output structure if necessary. + // For simplicity, we check the component type directly (though this is less robust) + expect(IconComponent.type).toBe(Code2Icon); + }); + + test("should contain the correct icon for 'noCode'", () => { + expect(ACTION_TYPE_ICON_LOOKUP).toHaveProperty("noCode"); + const IconComponent = ACTION_TYPE_ICON_LOOKUP.noCode; + expect(React.isValidElement(IconComponent)).toBe(true); + + // Render the icon and check if it's the correct Lucide icon + const { container } = render(IconComponent); + const svgElement = container.querySelector("svg"); + expect(svgElement).toBeInTheDocument(); + // Similar check as above for MousePointerClickIcon + expect(IconComponent.type).toBe(MousePointerClickIcon); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.test.tsx new file mode 100644 index 000000000000..a0d6e9a85091 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.test.tsx @@ -0,0 +1,522 @@ +import { getEnvironment, getEnvironments } from "@/lib/environment/service"; +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getAccessFlags } from "@/lib/membership/utils"; +import { + getMonthlyActiveOrganizationPeopleCount, + getMonthlyOrganizationResponseCount, + getOrganizationByEnvironmentId, + getOrganizationsByUserId, +} from "@/lib/organization/service"; +import { getUserProjects } from "@/lib/project/service"; +import { getUser } from "@/lib/user/service"; +import { + getAccessControlPermission, + getOrganizationProjectsLimit, +} from "@/modules/ee/license-check/lib/utils"; +import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; +import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team"; +import { cleanup, render, screen } from "@testing-library/react"; +import type { Session } from "next-auth"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TMembership } from "@formbricks/types/memberships"; +import { + TOrganization, + TOrganizationBilling, + TOrganizationBillingPlanLimits, +} from "@formbricks/types/organizations"; +import { TProject } from "@formbricks/types/project"; +import { TUser } from "@formbricks/types/user"; + +// Mock services and utils +vi.mock("@/lib/environment/service", () => ({ + getEnvironment: vi.fn(), + getEnvironments: vi.fn(), +})); +vi.mock("@/lib/organization/service", () => ({ + getOrganizationByEnvironmentId: vi.fn(), + getOrganizationsByUserId: vi.fn(), + getMonthlyActiveOrganizationPeopleCount: vi.fn(), + getMonthlyOrganizationResponseCount: vi.fn(), +})); +vi.mock("@/lib/user/service", () => ({ + getUser: vi.fn(), +})); +vi.mock("@/lib/project/service", () => ({ + getUserProjects: vi.fn(), +})); +vi.mock("@/lib/membership/service", () => ({ + getMembershipByUserIdOrganizationId: vi.fn(), +})); +vi.mock("@/lib/membership/utils", () => ({ + getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity +})); +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getOrganizationProjectsLimit: vi.fn(), + getAccessControlPermission: vi.fn(), +})); +vi.mock("@/modules/ee/teams/lib/roles", () => ({ + getProjectPermissionByUserId: vi.fn(), +})); +vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({ + getTeamsByOrganizationId: vi.fn(), +})); +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); + +let mockIsFormbricksCloud = false; +let mockIsDevelopment = false; + +vi.mock("@/lib/constants", () => ({ + get IS_FORMBRICKS_CLOUD() { + return mockIsFormbricksCloud; + }, + get IS_DEVELOPMENT() { + return mockIsDevelopment; + }, +})); + +// Mock components +vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({ + MainNavigation: ({ organizationTeams, isAccessControlAllowed }: any) => ( +
+ MainNavigation +
{JSON.stringify(organizationTeams || [])}
+
{isAccessControlAllowed?.toString() || "false"}
+
+ ), +})); +vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({ + TopControlBar: () =>
TopControlBar
, +})); +vi.mock("@/modules/ui/components/dev-environment-banner", () => ({ + DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) => + environment.type === "development" ?
DevEnvironmentBanner
: null, +})); +vi.mock("@/modules/ui/components/limits-reached-banner", () => ({ + LimitsReachedBanner: () =>
LimitsReachedBanner
, +})); +vi.mock("@/modules/ui/components/pending-downgrade-banner", () => ({ + PendingDowngradeBanner: ({ + isPendingDowngrade, + active, + }: { + isPendingDowngrade: boolean; + active: boolean; + }) => + isPendingDowngrade && active ?
PendingDowngradeBanner
: null, +})); + +const mockUser = { + id: "user-1", + name: "Test User", + email: "test@example.com", + emailVerified: new Date(), + imageUrl: "", + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + notificationSettings: { alert: {} }, +} as unknown as TUser; + +const mockOrganization = { + id: "org-1", + name: "Test Org", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: null, + limits: { monthly: { responses: null } } as unknown as TOrganizationBillingPlanLimits, + } as unknown as TOrganizationBilling, +} as unknown as TOrganization; + +const mockEnvironment: TEnvironment = { + id: "env-1", + createdAt: new Date(), + updatedAt: new Date(), + type: "production", + projectId: "proj-1", + appSetupCompleted: true, +}; + +const mockProject: TProject = { + id: "proj-1", + name: "Test Project", + createdAt: new Date(), + updatedAt: new Date(), + organizationId: "org-1", + environments: [mockEnvironment], +} as unknown as TProject; + +const mockMembership: TMembership = { + organizationId: "org-1", + userId: "user-1", + accepted: true, + role: "owner", +}; + +const mockLicense = { + plan: "free", + active: false, + lastChecked: new Date(), + features: { isMultiOrgEnabled: false }, +} as any; + +const mockProjectPermission = { + userId: "user-1", + projectId: "proj-1", + role: "admin", +} as any; + +const mockOrganizationTeams = [ + { + id: "team-1", + name: "Development Team", + }, + { + id: "team-2", + name: "Marketing Team", + }, +]; + +const mockSession: Session = { + user: { + id: "user-1", + }, + expires: new Date(Date.now() + 3600 * 1000).toISOString(), +}; + +describe("EnvironmentLayout", () => { + beforeEach(() => { + vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment); + vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); + vi.mocked(getUserProjects).mockResolvedValue([mockProject]); + vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment]); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100); + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500); + vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any); + vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission); + vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams); + vi.mocked(getAccessControlPermission).mockResolvedValue(true); + mockIsDevelopment = false; + mockIsFormbricksCloud = false; + }); + + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + test("renders correctly with default props", async () => { + vi.resetModules(); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: false, + isPendingDowngrade: false, + features: { isMultiOrgEnabled: false }, + lastChecked: new Date(), + fallbackLevel: "live", + }), + })); + const { EnvironmentLayout } = await import( + "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout" + ); + render( + await EnvironmentLayout({ + environmentId: "env-1", + session: mockSession, + children:
Child Content
, + }) + ); + expect(screen.getByTestId("main-navigation")).toBeInTheDocument(); + expect(screen.getByTestId("top-control-bar")).toBeInTheDocument(); + expect(screen.getByText("Child Content")).toBeInTheDocument(); + expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument(); + expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument(); + expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument(); + }); + + test("renders DevEnvironmentBanner in development environment", async () => { + const devEnvironment = { ...mockEnvironment, type: "development" as const }; + vi.mocked(getEnvironment).mockResolvedValue(devEnvironment); + mockIsDevelopment = true; + vi.resetModules(); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: false, + isPendingDowngrade: false, + features: { isMultiOrgEnabled: false }, + lastChecked: new Date(), + fallbackLevel: "live", + }), + })); + const { EnvironmentLayout } = await import( + "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout" + ); + render( + await EnvironmentLayout({ + environmentId: "env-1", + session: mockSession, + children:
Child Content
, + }) + ); + expect(screen.getByTestId("dev-banner")).toBeInTheDocument(); + }); + + test("renders LimitsReachedBanner in Formbricks Cloud", async () => { + mockIsFormbricksCloud = true; + vi.resetModules(); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: false, + isPendingDowngrade: false, + features: { isMultiOrgEnabled: false }, + lastChecked: new Date(), + fallbackLevel: "live", + }), + })); + const { EnvironmentLayout } = await import( + "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout" + ); + render( + await EnvironmentLayout({ + environmentId: "env-1", + session: mockSession, + children:
Child Content
, + }) + ); + expect(screen.getByTestId("limits-banner")).toBeInTheDocument(); + expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id); + expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id); + }); + + test("renders PendingDowngradeBanner when pending downgrade", async () => { + const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true }; + vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any); + vi.resetModules(); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue(pendingLicense), + })); + const { EnvironmentLayout } = await import( + "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout" + ); + render( + await EnvironmentLayout({ + environmentId: "env-1", + session: mockSession, + children:
Child Content
, + }) + ); + expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument(); + }); + + test("passes isAccessControlAllowed props to MainNavigation", async () => { + vi.resetModules(); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: false, + isPendingDowngrade: false, + features: { isMultiOrgEnabled: false }, + lastChecked: new Date(), + fallbackLevel: "live", + }), + })); + const { EnvironmentLayout } = await import( + "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout" + ); + render( + await EnvironmentLayout({ + environmentId: "env-1", + session: mockSession, + children:
Child Content
, + }) + ); + + expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true"); + expect(vi.mocked(getAccessControlPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan); + }); + + test("handles empty organizationTeams array", async () => { + vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]); + vi.resetModules(); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: false, + isPendingDowngrade: false, + features: { isMultiOrgEnabled: false }, + lastChecked: new Date(), + fallbackLevel: "live", + }), + })); + const { EnvironmentLayout } = await import( + "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout" + ); + render( + await EnvironmentLayout({ + environmentId: "env-1", + session: mockSession, + children:
Child Content
, + }) + ); + + expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]"); + }); + + test("handles null organizationTeams", async () => { + vi.mocked(getTeamsByOrganizationId).mockResolvedValue(null); + vi.resetModules(); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: false, + isPendingDowngrade: false, + features: { isMultiOrgEnabled: false }, + lastChecked: new Date(), + fallbackLevel: "live", + }), + })); + const { EnvironmentLayout } = await import( + "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout" + ); + render( + await EnvironmentLayout({ + environmentId: "env-1", + session: mockSession, + children:
Child Content
, + }) + ); + + expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]"); + }); + + test("handles isAccessControlAllowed false", async () => { + vi.mocked(getAccessControlPermission).mockResolvedValue(false); + vi.resetModules(); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: false, + isPendingDowngrade: false, + features: { isMultiOrgEnabled: false }, + lastChecked: new Date(), + fallbackLevel: "live", + }), + })); + const { EnvironmentLayout } = await import( + "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout" + ); + render( + await EnvironmentLayout({ + environmentId: "env-1", + session: mockSession, + children:
Child Content
, + }) + ); + + expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false"); + }); + + test("throws error if user not found", async () => { + vi.mocked(getUser).mockResolvedValue(null); + vi.resetModules(); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: false, + isPendingDowngrade: false, + features: { isMultiOrgEnabled: false }, + lastChecked: new Date(), + fallbackLevel: "live", + }), + })); + const { EnvironmentLayout } = await import( + "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout" + ); + await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( + "common.user_not_found" + ); + }); + + test("throws error if organization not found", async () => { + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); + vi.resetModules(); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: false, + isPendingDowngrade: false, + features: { isMultiOrgEnabled: false }, + lastChecked: new Date(), + fallbackLevel: "live", + }), + })); + const { EnvironmentLayout } = await import( + "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout" + ); + await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( + "common.organization_not_found" + ); + }); + + test("throws error if environment not found", async () => { + vi.mocked(getEnvironment).mockResolvedValue(null); + vi.resetModules(); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: false, + isPendingDowngrade: false, + features: { isMultiOrgEnabled: false }, + lastChecked: new Date(), + fallbackLevel: "live", + }), + })); + const { EnvironmentLayout } = await import( + "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout" + ); + await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( + "common.environment_not_found" + ); + }); + + test("throws error if projects, environments or organizations not found", async () => { + vi.mocked(getUserProjects).mockResolvedValue(null as any); + vi.resetModules(); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: false, + isPendingDowngrade: false, + features: { isMultiOrgEnabled: false }, + lastChecked: new Date(), + fallbackLevel: "live", + }), + })); + const { EnvironmentLayout } = await import( + "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout" + ); + await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( + "environments.projects_environments_organizations_not_found" + ); + }); + + test("throws error if member has no project permission", async () => { + vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any); + vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null); + vi.resetModules(); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: false, + isPendingDowngrade: false, + features: { isMultiOrgEnabled: false }, + lastChecked: new Date(), + fallbackLevel: "live", + }), + })); + const { EnvironmentLayout } = await import( + "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout" + ); + await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( + "common.project_permission_not_found" + ); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx index 6630d89ae300..35dfc283c00e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentLayout.tsx @@ -1,24 +1,28 @@ import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation"; import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar"; -import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; +import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { getEnvironment, getEnvironments } from "@/lib/environment/service"; +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getAccessFlags } from "@/lib/membership/utils"; +import { + getMonthlyActiveOrganizationPeopleCount, + getMonthlyOrganizationResponseCount, + getOrganizationByEnvironmentId, + getOrganizationsByUserId, +} from "@/lib/organization/service"; +import { getUserProjects } from "@/lib/project/service"; +import { getUser } from "@/lib/user/service"; +import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; +import { + getAccessControlPermission, + getOrganizationProjectsLimit, +} from "@/modules/ee/license-check/lib/utils"; import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner"; import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner"; import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner"; import { getTranslate } from "@/tolgee/server"; import type { Session } from "next-auth"; -import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; -import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service"; -import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; -import { - getMonthlyActiveOrganizationPeopleCount, - getMonthlyOrganizationResponseCount, - getOrganizationByEnvironmentId, - getOrganizationsByUserId, -} from "@formbricks/lib/organization/service"; -import { getUserProjects } from "@formbricks/lib/project/service"; -import { getUser } from "@formbricks/lib/user/service"; interface EnvironmentLayoutProps { environmentId: string; @@ -47,9 +51,10 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En throw new Error(t("common.environment_not_found")); } - const [projects, environments] = await Promise.all([ + const [projects, environments, isAccessControlAllowed] = await Promise.all([ getUserProjects(user.id, organization.id), getEnvironments(environment.projectId), + getAccessControlPermission(organization.billing.plan), ]); if (!projects || !environments || !organizations) { @@ -100,6 +105,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En isPendingDowngrade={isPendingDowngrade ?? false} active={active} environmentId={environment.id} + locale={user.locale} />
@@ -115,15 +121,16 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En membershipRole={membershipRole} isMultiOrgEnabled={isMultiOrgEnabled} isLicenseActive={active} + isAccessControlAllowed={isAccessControlAllowed} /> -
+
-
{children}
+
{children}
diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentStorageHandler.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentStorageHandler.test.tsx new file mode 100644 index 000000000000..20fe547b8366 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentStorageHandler.test.tsx @@ -0,0 +1,33 @@ +import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; +import { render } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import EnvironmentStorageHandler from "./EnvironmentStorageHandler"; + +describe("EnvironmentStorageHandler", () => { + test("sets environmentId in localStorage on mount", () => { + const setItemSpy = vi.spyOn(Storage.prototype, "setItem"); + const testEnvironmentId = "test-env-123"; + + render(); + + expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, testEnvironmentId); + setItemSpy.mockRestore(); + }); + + test("updates environmentId in localStorage when prop changes", () => { + const setItemSpy = vi.spyOn(Storage.prototype, "setItem"); + const initialEnvironmentId = "test-env-initial"; + const updatedEnvironmentId = "test-env-updated"; + + const { rerender } = render(); + + expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, initialEnvironmentId); + + rerender(); + + expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, updatedEnvironmentId); + expect(setItemSpy).toHaveBeenCalledTimes(2); // Called on mount and on rerender with new prop + + setItemSpy.mockRestore(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentStorageHandler.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentStorageHandler.tsx index 6fba90b3d0f3..448e615dfff1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentStorageHandler.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentStorageHandler.tsx @@ -1,7 +1,7 @@ "use client"; +import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; import { useEffect } from "react"; -import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage"; interface EnvironmentStorageHandlerProps { environmentId: string; diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentSwitch.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentSwitch.test.tsx new file mode 100644 index 000000000000..6f817ea581aa --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentSwitch.test.tsx @@ -0,0 +1,149 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { EnvironmentSwitch } from "./EnvironmentSwitch"; + +// Mock next/navigation +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: vi.fn(() => ({ + push: mockPush, + })), +})); + +// Mock @tolgee/react +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +const mockEnvironmentDev: TEnvironment = { + id: "dev-env-id", + createdAt: new Date(), + updatedAt: new Date(), + type: "development", + projectId: "project-id", + appSetupCompleted: true, +}; + +const mockEnvironmentProd: TEnvironment = { + id: "prod-env-id", + createdAt: new Date(), + updatedAt: new Date(), + type: "production", + projectId: "project-id", + appSetupCompleted: true, +}; + +const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd]; + +describe("EnvironmentSwitch", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders checked when environment is development", () => { + render(); + const switchElement = screen.getByRole("switch"); + expect(switchElement).toBeChecked(); + expect(screen.getByText("common.dev_env")).toHaveClass("text-orange-800"); + }); + + test("renders unchecked when environment is production", () => { + render(); + const switchElement = screen.getByRole("switch"); + expect(switchElement).not.toBeChecked(); + expect(screen.getByText("common.dev_env")).not.toHaveClass("text-orange-800"); + }); + + test("calls router.push with development environment ID when toggled from production", async () => { + render(); + const switchElement = screen.getByRole("switch"); + + expect(switchElement).not.toBeChecked(); + await userEvent.click(switchElement); + + // Check loading state (switch disabled) + expect(switchElement).toBeDisabled(); + + // Check router push call + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/`); + }); + + // Check visual state change (though state update happens before navigation) + // In a real scenario, the component would re-render with the new environment prop after navigation. + // Here, we simulate the state change directly for testing the toggle logic. + await waitFor(() => { + // Re-render or check internal state if possible, otherwise check mock calls + // Since the component manages its own state, we can check the visual state after click + expect(switchElement).toBeChecked(); // State updates immediately + }); + }); + + test("calls router.push with production environment ID when toggled from development", async () => { + render(); + const switchElement = screen.getByRole("switch"); + + expect(switchElement).toBeChecked(); + await userEvent.click(switchElement); + + // Check loading state (switch disabled) + expect(switchElement).toBeDisabled(); + + // Check router push call + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentProd.id}/`); + }); + + // Check visual state change + await waitFor(() => { + expect(switchElement).not.toBeChecked(); // State updates immediately + }); + }); + + test("does not call router.push if target environment is not found", async () => { + const incompleteEnvironments = [mockEnvironmentProd]; // Only production exists + render(); + const switchElement = screen.getByRole("switch"); + + await userEvent.click(switchElement); // Try to toggle to development + + await waitFor(() => { + expect(switchElement).toBeDisabled(); // Loading state still set + }); + + // router.push should not be called because dev env is missing + expect(mockPush).not.toHaveBeenCalled(); + + // State still updates visually + await waitFor(() => { + expect(switchElement).toBeChecked(); + }); + }); + + test("toggles using the label click", async () => { + render(); + const labelElement = screen.getByText("common.dev_env"); + const switchElement = screen.getByRole("switch"); + + expect(switchElement).not.toBeChecked(); + await userEvent.click(labelElement); // Click the label + + // Check loading state (switch disabled) + expect(switchElement).toBeDisabled(); + + // Check router push call + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/`); + }); + + // Check visual state change + await waitFor(() => { + expect(switchElement).toBeChecked(); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentSwitch.tsx b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentSwitch.tsx index 4993f645f9d2..6e0420c5ec1a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentSwitch.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/EnvironmentSwitch.tsx @@ -1,11 +1,11 @@ "use client"; +import { cn } from "@/lib/cn"; import { Label } from "@/modules/ui/components/label"; import { Switch } from "@/modules/ui/components/switch"; import { useTranslate } from "@tolgee/react"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import { cn } from "@formbricks/lib/cn"; import { TEnvironment } from "@formbricks/types/environment"; interface EnvironmentSwitchProps { diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx new file mode 100644 index 000000000000..182ea9b361d2 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx @@ -0,0 +1,368 @@ +import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; +import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { usePathname, useRouter } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TProject } from "@formbricks/types/project"; +import { TUser } from "@formbricks/types/user"; +import { getLatestStableFbReleaseAction } from "../actions/actions"; +import { MainNavigation } from "./MainNavigation"; + +// Mock constants that this test needs +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + WEBAPP_URL: "http://localhost:3000", +})); + +// Mock server actions that this test needs +vi.mock("@/modules/auth/actions/sign-out", () => ({ + logSignOutAction: vi.fn().mockResolvedValue(undefined), +})); + +// Mock dependencies +vi.mock("next/navigation", () => ({ + useRouter: vi.fn(() => ({ push: vi.fn() })), + usePathname: vi.fn(() => "/environments/env1/surveys"), +})); +vi.mock("next-auth/react", () => ({ + signOut: vi.fn(), +})); +vi.mock("@/modules/auth/hooks/use-sign-out", () => ({ + useSignOut: vi.fn(() => ({ signOut: vi.fn() })), +})); +vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({ + getLatestStableFbReleaseAction: vi.fn(), +})); +vi.mock("@/app/lib/formbricks", () => ({ + formbricksLogout: vi.fn(), +})); +vi.mock("@/lib/membership/utils", () => ({ + getAccessFlags: (role?: string) => ({ + isAdmin: role === "admin", + isOwner: role === "owner", + isManager: role === "manager", + isMember: role === "member", + isBilling: role === "billing", + }), +})); +vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({ + CreateOrganizationModal: ({ open }: { open: boolean }) => + open ?
Create Org Modal
: null, +})); +vi.mock("@/modules/projects/components/project-switcher", () => ({ + ProjectSwitcher: ({ + isCollapsed, + organizationTeams, + isAccessControlAllowed, + }: { + isCollapsed: boolean; + organizationTeams: TOrganizationTeam[]; + isAccessControlAllowed: boolean; + }) => ( +
+ Project Switcher +
{organizationTeams?.length || 0}
+
{isAccessControlAllowed.toString()}
+
+ ), +})); +vi.mock("@/modules/ui/components/avatars", () => ({ + ProfileAvatar: () =>
Avatar
, +})); +vi.mock("next/image", () => ({ + // eslint-disable-next-line @next/next/no-img-element + default: (props: any) => test, +})); +vi.mock("../../../../../package.json", () => ({ + version: "1.0.0", +})); + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value.toString(); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; +})(); +Object.defineProperty(window, "localStorage", { value: localStorageMock }); + +// Mock data +const mockEnvironment: TEnvironment = { + id: "env1", + createdAt: new Date(), + updatedAt: new Date(), + type: "production", + projectId: "proj1", + appSetupCompleted: true, +}; +const mockUser = { + id: "user1", + name: "Test User", + email: "test@example.com", + imageUrl: "http://example.com/avatar.png", + emailVerified: new Date(), + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + notificationSettings: { alert: {} }, + role: "project_manager", + objective: "other", +} as unknown as TUser; + +const mockOrganization = { + id: "org1", + name: "Test Org", + createdAt: new Date(), + updatedAt: new Date(), + billing: { stripeCustomerId: null, plan: "free", limits: { monthly: { responses: null } } } as any, +} as unknown as TOrganization; + +const mockOrganizations: TOrganization[] = [ + mockOrganization, + { ...mockOrganization, id: "org2", name: "Another Org" }, +]; +const mockProject: TProject = { + id: "proj1", + name: "Test Project", + createdAt: new Date(), + updatedAt: new Date(), + organizationId: "org1", + environments: [mockEnvironment], + config: { channel: "website" }, +} as unknown as TProject; +const mockProjects: TProject[] = [mockProject]; + +const defaultProps = { + environment: mockEnvironment, + organizations: mockOrganizations, + user: mockUser, + organization: mockOrganization, + projects: mockProjects, + isMultiOrgEnabled: true, + isFormbricksCloud: false, + isDevelopment: false, + membershipRole: "owner" as const, + organizationProjectsLimit: 5, + isLicenseActive: true, + isAccessControlAllowed: true, +}; + +describe("MainNavigation", () => { + let mockRouterPush: ReturnType; + + beforeEach(() => { + mockRouterPush = vi.fn(); + vi.mocked(useRouter).mockReturnValue({ push: mockRouterPush } as any); + vi.mocked(usePathname).mockReturnValue("/environments/env1/surveys"); + vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: null }); // Default: no new version + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders expanded by default and collapses on toggle", async () => { + render(); + const projectSwitcher = screen.getByTestId("project-switcher"); + // Assuming the toggle button is the only one initially without an accessible name + // A more specific selector like data-testid would be better if available. + const toggleButton = screen.getByRole("button", { name: "" }); + + // Check initial state (expanded) + expect(projectSwitcher).toHaveAttribute("data-collapsed", "false"); + expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument(); + // Check localStorage is not set initially after clear() + expect(localStorage.getItem("isMainNavCollapsed")).toBeNull(); + + // Click to collapse + await userEvent.click(toggleButton); + + // Check state after first toggle (collapsed) + await waitFor(() => { + // Check that the attribute eventually becomes true + expect(projectSwitcher).toHaveAttribute("data-collapsed", "true"); + // Check that localStorage is updated + expect(localStorage.getItem("isMainNavCollapsed")).toBe("true"); + }); + // Check that the logo is eventually hidden + await waitFor(() => { + expect(screen.queryByAltText("environments.formbricks_logo")).not.toBeInTheDocument(); + }); + + // Click to expand + await userEvent.click(toggleButton); + + // Check state after second toggle (expanded) + await waitFor(() => { + // Check that the attribute eventually becomes false + expect(projectSwitcher).toHaveAttribute("data-collapsed", "false"); + // Check that localStorage is updated + expect(localStorage.getItem("isMainNavCollapsed")).toBe("false"); + }); + // Check that the logo is eventually visible + await waitFor(() => { + expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument(); + }); + }); + + test("renders correct active navigation link", () => { + vi.mocked(usePathname).mockReturnValue("/environments/env1/actions"); + render(); + const actionsLink = screen.getByRole("link", { name: /common.actions/ }); + // Check if the parent li has the active class styling + expect(actionsLink.closest("li")).toHaveClass("border-brand-dark"); + }); + + test("renders user dropdown and handles logout", async () => { + const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" }); + vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut }); + + // Set up localStorage spy on the mocked localStorage + + render(); + + // Find the avatar and get its parent div which acts as the trigger + const userTrigger = screen.getByTestId("profile-avatar").parentElement!; + expect(userTrigger).toBeInTheDocument(); // Ensure the trigger element is found + await userEvent.click(userTrigger); + + // Wait for the dropdown content to appear + await waitFor(() => { + expect(screen.getByText("common.account")).toBeInTheDocument(); + }); + + expect(screen.getByText("common.organization")).toBeInTheDocument(); + expect(screen.getByText("common.license")).toBeInTheDocument(); // Not cloud, not member + expect(screen.getByText("common.documentation")).toBeInTheDocument(); + expect(screen.getByText("common.logout")).toBeInTheDocument(); + + const logoutButton = screen.getByText("common.logout"); + await userEvent.click(logoutButton); + + expect(mockSignOut).toHaveBeenCalledWith({ + reason: "user_initiated", + redirectUrl: "/auth/login", + organizationId: "org1", + redirect: false, + callbackUrl: "/auth/login", + clearEnvironmentId: true, + }); + + await waitFor(() => { + expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); + }); + }); + + test("handles organization switching", async () => { + render(); + + const userTrigger = screen.getByTestId("profile-avatar").parentElement!; + await userEvent.click(userTrigger); + + // Wait for the initial dropdown items + await waitFor(() => { + expect(screen.getByText("common.switch_organization")).toBeInTheDocument(); + }); + + const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!; + await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu + + const org2Item = await screen.findByText("Another Org"); // findByText includes waitFor + await userEvent.click(org2Item); + + expect(mockRouterPush).toHaveBeenCalledWith("/organizations/org2/"); + }); + + test("opens create organization modal", async () => { + render(); + + const userTrigger = screen.getByTestId("profile-avatar").parentElement!; + await userEvent.click(userTrigger); + + // Wait for the initial dropdown items + await waitFor(() => { + expect(screen.getByText("common.switch_organization")).toBeInTheDocument(); + }); + + const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!; + await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu + + const createOrgButton = await screen.findByText("common.create_new_organization"); // findByText includes waitFor + await userEvent.click(createOrgButton); + + expect(screen.getByTestId("create-org-modal")).toBeInTheDocument(); + }); + + test("hides new version banner for members or if no new version", async () => { + // Test for member + vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: "v1.1.0" }); + render(); + let toggleButton = screen.getByRole("button", { name: "" }); + await userEvent.click(toggleButton); + await waitFor(() => { + expect(screen.queryByText("common.new_version_available", { exact: false })).not.toBeInTheDocument(); + }); + cleanup(); // Clean up before next render + + // Test for no new version + vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: null }); + render(); + toggleButton = screen.getByRole("button", { name: "" }); + await userEvent.click(toggleButton); + await waitFor(() => { + expect(screen.queryByText("common.new_version_available", { exact: false })).not.toBeInTheDocument(); + }); + }); + + test("hides main nav and project switcher if user role is billing", () => { + render(); + expect(screen.queryByRole("link", { name: /common.surveys/ })).not.toBeInTheDocument(); + expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument(); + }); + + test("shows billing link and hides license link in cloud", async () => { + render(); + const userTrigger = screen.getByTestId("profile-avatar").parentElement!; + await userEvent.click(userTrigger); + + // Wait for dropdown items + await waitFor(() => { + expect(screen.getByText("common.billing")).toBeInTheDocument(); + }); + expect(screen.queryByText("common.license")).not.toBeInTheDocument(); + }); + + test("passes isAccessControlAllowed props to ProjectSwitcher", () => { + render(); + + expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0"); + expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true"); + }); + + test("handles no organizationTeams", () => { + render(); + + expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0"); + }); + + test("handles isAccessControlAllowed false", () => { + render(); + + expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx index 7c07d8f08844..460035878d6a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx @@ -2,8 +2,11 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions"; import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink"; -import { formbricksLogout } from "@/app/lib/formbricks"; import FBLogo from "@/images/formbricks-wordmark.svg"; +import { cn } from "@/lib/cn"; +import { getAccessFlags } from "@/lib/membership/utils"; +import { capitalizeFirstLetter } from "@/lib/utils/strings"; +import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal"; import { ProjectSwitcher } from "@/modules/projects/components/project-switcher"; import { ProfileAvatar } from "@/modules/ui/components/avatars"; @@ -40,14 +43,10 @@ import { UserIcon, UsersIcon, } from "lucide-react"; -import { signOut } from "next-auth/react"; import Image from "next/image"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; -import { cn } from "@formbricks/lib/cn"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; -import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings"; import { TEnvironment } from "@formbricks/types/environment"; import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganization } from "@formbricks/types/organizations"; @@ -67,6 +66,7 @@ interface NavigationProps { membershipRole?: TOrganizationRole; organizationProjectsLimit: number; isLicenseActive: boolean; + isAccessControlAllowed: boolean; } export const MainNavigation = ({ @@ -81,6 +81,7 @@ export const MainNavigation = ({ organizationProjectsLimit, isLicenseActive, isDevelopment, + isAccessControlAllowed, }: NavigationProps) => { const router = useRouter(); const pathname = usePathname(); @@ -91,6 +92,7 @@ export const MainNavigation = ({ const [isCollapsed, setIsCollapsed] = useState(true); const [isTextVisible, setIsTextVisible] = useState(true); const [latestVersion, setLatestVersion] = useState(""); + const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); const project = projects.find((project) => project.id === environment.projectId); const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole); @@ -110,7 +112,7 @@ export const MainNavigation = ({ useEffect(() => { const toggleTextOpacity = () => { - setIsTextVisible(isCollapsed ? true : false); + setIsTextVisible(isCollapsed); }; const timeoutId = setTimeout(toggleTextOpacity, 150); return () => clearTimeout(timeoutId); @@ -171,7 +173,7 @@ export const MainNavigation = ({ name: t("common.actions"), href: `/environments/${environment.id}/actions`, icon: MousePointerClick, - isActive: pathname?.includes("/actions") || pathname?.includes("/actions"), + isActive: pathname?.includes("/actions"), }, { name: t("common.integrations"), @@ -265,7 +267,7 @@ export const MainNavigation = ({ size="icon" onClick={toggleSidebar} className={cn( - "rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none" + "rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent" )}> {isCollapsed ? ( @@ -323,6 +325,7 @@ export const MainNavigation = ({ isTextVisible={isTextVisible} organization={organization} organizationProjectsLimit={organizationProjectsLimit} + isAccessControlAllowed={isAccessControlAllowed} /> )} @@ -336,27 +339,30 @@ export const MainNavigation = ({
{!isCollapsed && !isTextVisible && ( <> -
+

{user?.name ? {user?.name} : {user?.email}}

+ className="truncate text-sm text-slate-500"> {capitalizeFirstLetter(organization?.name)}

- + )}
@@ -390,9 +396,15 @@ export const MainNavigation = ({ { - const route = await signOut({ redirect: false, callbackUrl: "/auth/login" }); - router.push(route.url); - await formbricksLogout(); + const route = await signOutWithAudit({ + reason: "user_initiated", + redirectUrl: "/auth/login", + organizationId: organization.id, + redirect: false, + callbackUrl: "/auth/login", + clearEnvironmentId: true, + }); + router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings }} icon={}> {t("common.logout")} diff --git a/apps/web/app/(app)/environments/[environmentId]/components/NavbarLoading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/NavbarLoading.test.tsx new file mode 100644 index 000000000000..ecb026161831 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/components/NavbarLoading.test.tsx @@ -0,0 +1,21 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { NavbarLoading } from "./NavbarLoading"; + +describe("NavbarLoading", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the correct number of skeleton elements", () => { + render(); + + // Find all divs with the animate-pulse class + const skeletonElements = screen.getAllByText((content, element) => { + return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse"); + }); + + // There are 8 skeleton divs in the component + expect(skeletonElements).toHaveLength(8); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/NavigationLink.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/NavigationLink.test.tsx new file mode 100644 index 000000000000..7d17cc66e2cb --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/components/NavigationLink.test.tsx @@ -0,0 +1,105 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { NavigationLink } from "./NavigationLink"; + +// Mock next/link +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => {children}, +})); + +// Mock tooltip components +vi.mock("@/modules/ui/components/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + TooltipProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +const defaultProps = { + href: "/test-link", + isActive: false, + isCollapsed: false, + children: , + linkText: "Test Link Text", + isTextVisible: true, +}; + +describe("NavigationLink", () => { + afterEach(() => { + cleanup(); + }); + + test("renders expanded link correctly (inactive, text visible)", () => { + render(); + const linkElement = screen.getByRole("link"); + const listItem = linkElement.closest("li"); + const textSpan = screen.getByText(defaultProps.linkText); + + expect(linkElement).toHaveAttribute("href", defaultProps.href); + expect(screen.getByTestId("icon")).toBeInTheDocument(); + expect(textSpan).toBeInTheDocument(); + expect(textSpan).toHaveClass("opacity-0"); + expect(listItem).not.toHaveClass("bg-slate-50"); // inactiveClass check + expect(listItem).toHaveClass("hover:bg-slate-50"); // inactiveClass check + expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument(); + }); + + test("renders expanded link correctly (active, text hidden)", () => { + render(); + const linkElement = screen.getByRole("link"); + const listItem = linkElement.closest("li"); + const textSpan = screen.getByText(defaultProps.linkText); + + expect(linkElement).toHaveAttribute("href", defaultProps.href); + expect(screen.getByTestId("icon")).toBeInTheDocument(); + expect(textSpan).toBeInTheDocument(); + expect(textSpan).toHaveClass("opacity-100"); + expect(listItem).toHaveClass("bg-slate-50"); // activeClass check + expect(listItem).toHaveClass("border-brand-dark"); // activeClass check + expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument(); + }); + + test("renders collapsed link correctly (inactive)", () => { + render(); + const linkElement = screen.getByRole("link"); + const listItem = linkElement.closest("li"); + + expect(linkElement).toHaveAttribute("href", defaultProps.href); + expect(screen.getByTestId("icon")).toBeInTheDocument(); + // Check text is NOT directly within the list item + expect(within(listItem!).queryByText(defaultProps.linkText)).not.toBeInTheDocument(); + expect(listItem).not.toHaveClass("bg-slate-50"); // inactiveClass check + expect(listItem).toHaveClass("hover:bg-slate-50"); // inactiveClass check + + // Check tooltip elements + expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument(); + // Check text IS within the tooltip content mock + expect(screen.getByTestId("tooltip-content")).toHaveTextContent(defaultProps.linkText); + }); + + test("renders collapsed link correctly (active)", () => { + render(); + const linkElement = screen.getByRole("link"); + const listItem = linkElement.closest("li"); + + expect(linkElement).toHaveAttribute("href", defaultProps.href); + expect(screen.getByTestId("icon")).toBeInTheDocument(); + // Check text is NOT directly within the list item + expect(within(listItem!).queryByText(defaultProps.linkText)).not.toBeInTheDocument(); + expect(listItem).toHaveClass("bg-slate-50"); // activeClass check + expect(listItem).toHaveClass("border-brand-dark"); // activeClass check + + // Check tooltip elements + expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument(); + // Check text IS within the tooltip content mock + expect(screen.getByTestId("tooltip-content")).toHaveTextContent(defaultProps.linkText); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/NavigationLink.tsx b/apps/web/app/(app)/environments/[environmentId]/components/NavigationLink.tsx index 6473800d9092..102dba68f510 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/NavigationLink.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/NavigationLink.tsx @@ -1,7 +1,7 @@ +import { cn } from "@/lib/cn"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import Link from "next/link"; import React from "react"; -import { cn } from "@formbricks/lib/cn"; interface NavigationLinkProps { href: string; diff --git a/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.test.tsx index 0861c570f776..67b04387c494 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/PosthogIdentify.test.tsx @@ -2,7 +2,7 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, render } from "@testing-library/react"; import { Session } from "next-auth"; import { usePostHog } from "posthog-js/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { TOrganizationBilling } from "@formbricks/types/organizations"; import { TUser } from "@formbricks/types/user"; import { PosthogIdentify } from "./PosthogIdentify"; @@ -18,7 +18,7 @@ describe("PosthogIdentify", () => { cleanup(); }); - it("identifies the user and sets groups when isPosthogEnabled is true", () => { + test("identifies the user and sets groups when isPosthogEnabled is true", () => { const mockIdentify = vi.fn(); const mockGroup = vi.fn(); @@ -72,7 +72,7 @@ describe("PosthogIdentify", () => { }); }); - it("does nothing if isPosthogEnabled is false", () => { + test("does nothing if isPosthogEnabled is false", () => { const mockIdentify = vi.fn(); const mockGroup = vi.fn(); @@ -95,7 +95,7 @@ describe("PosthogIdentify", () => { expect(mockGroup).not.toHaveBeenCalled(); }); - it("does nothing if session user is missing", () => { + test("does nothing if session user is missing", () => { const mockIdentify = vi.fn(); const mockGroup = vi.fn(); @@ -120,7 +120,7 @@ describe("PosthogIdentify", () => { expect(mockGroup).not.toHaveBeenCalled(); }); - it("identifies user but does not group if environmentId/organizationId not provided", () => { + test("identifies user but does not group if environmentId/organizationId not provided", () => { const mockIdentify = vi.fn(); const mockGroup = vi.fn(); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/ProjectNavItem.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/ProjectNavItem.test.tsx new file mode 100644 index 000000000000..d3f754882532 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/components/ProjectNavItem.test.tsx @@ -0,0 +1,40 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ProjectNavItem } from "./ProjectNavItem"; + +describe("ProjectNavItem", () => { + afterEach(() => { + cleanup(); + }); + + const defaultProps = { + href: "/test-path", + children: Test Child, + }; + + test("renders correctly when active", () => { + render(); + + const linkElement = screen.getByRole("link"); + const listItem = linkElement.closest("li"); + + expect(linkElement).toHaveAttribute("href", "/test-path"); + expect(screen.getByText("Test Child")).toBeInTheDocument(); + expect(listItem).toHaveClass("bg-slate-50"); + expect(listItem).toHaveClass("font-semibold"); + expect(listItem).not.toHaveClass("hover:bg-slate-50"); + }); + + test("renders correctly when inactive", () => { + render(); + + const linkElement = screen.getByRole("link"); + const listItem = linkElement.closest("li"); + + expect(linkElement).toHaveAttribute("href", "/test-path"); + expect(screen.getByText("Test Child")).toBeInTheDocument(); + expect(listItem).not.toHaveClass("bg-slate-50"); + expect(listItem).not.toHaveClass("font-semibold"); + expect(listItem).toHaveClass("hover:bg-slate-50"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.test.tsx new file mode 100644 index 000000000000..c78d71799c3b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.test.tsx @@ -0,0 +1,140 @@ +import { QuestionOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox"; +import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter"; +import { getTodayDate } from "@/app/lib/surveys/surveys"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ResponseFilterProvider, useResponseFilter } from "./ResponseFilterContext"; + +// Mock the getTodayDate function +vi.mock("@/app/lib/surveys/surveys", () => ({ + getTodayDate: vi.fn(), +})); + +const mockToday = new Date("2024-01-15T00:00:00.000Z"); +const mockFromDate = new Date("2024-01-01T00:00:00.000Z"); + +// Test component to use the hook +const TestComponent = () => { + const { + selectedFilter, + setSelectedFilter, + selectedOptions, + setSelectedOptions, + dateRange, + setDateRange, + resetState, + } = useResponseFilter(); + + return ( +
+
{selectedFilter.responseStatus}
+
{selectedFilter.filter.length}
+
{selectedOptions.questionOptions.length}
+
{selectedOptions.questionFilterOptions.length}
+
{dateRange.from?.toISOString()}
+
{dateRange.to?.toISOString()}
+ + + + + +
+ ); +}; + +describe("ResponseFilterContext", () => { + beforeEach(() => { + vi.mocked(getTodayDate).mockReturnValue(mockToday); + }); + + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + test("should provide initial state values", () => { + render( + + + + ); + + expect(screen.getByTestId("responseStatus").textContent).toBe("all"); + expect(screen.getByTestId("filterLength").textContent).toBe("0"); + expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0"); + expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0"); + expect(screen.getByTestId("dateFrom").textContent).toBe(""); + expect(screen.getByTestId("dateTo").textContent).toBe(mockToday.toISOString()); + }); + + test("should update selectedFilter state", async () => { + render( + + + + ); + + const updateButton = screen.getByText("Update Filter"); + await userEvent.click(updateButton); + + expect(screen.getByTestId("responseStatus").textContent).toBe("complete"); + expect(screen.getByTestId("filterLength").textContent).toBe("1"); + }); + + test("should update selectedOptions state", async () => { + render( + + + + ); + + const updateButton = screen.getByText("Update Options"); + await userEvent.click(updateButton); + + expect(screen.getByTestId("questionOptionsLength").textContent).toBe("1"); + expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("1"); + }); + + test("should update dateRange state", async () => { + render( + + + + ); + + const updateButton = screen.getByText("Update Date Range"); + await userEvent.click(updateButton); + + expect(screen.getByTestId("dateFrom").textContent).toBe(mockFromDate.toISOString()); + expect(screen.getByTestId("dateTo").textContent).toBe(mockToday.toISOString()); + }); + + test("should throw error when useResponseFilter is used outside of Provider", () => { + // Hide console error temporarily + const consoleErrorMock = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow("useFilterDate must be used within a FilterDateProvider"); + consoleErrorMock.mockRestore(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.tsx b/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.tsx index ec62057383e6..850288a0e228 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/ResponseFilterContext.tsx @@ -16,9 +16,11 @@ export interface FilterValue { }; } +export type TResponseStatus = "all" | "complete" | "partial"; + export interface SelectedFilterValue { filter: FilterValue[]; - onlyComplete: boolean; + responseStatus: TResponseStatus; } interface SelectedFilterOptions { @@ -47,7 +49,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) => // state holds the filter selected value const [selectedFilter, setSelectedFilter] = useState({ filter: [], - onlyComplete: false, + responseStatus: "all", }); // state holds all the options of the responses fetched const [selectedOptions, setSelectedOptions] = useState({ @@ -67,7 +69,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) => }); setSelectedFilter({ filter: [], - onlyComplete: false, + responseStatus: "all", }); }, []); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.test.tsx new file mode 100644 index 000000000000..70b35df973cc --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.test.tsx @@ -0,0 +1,64 @@ +import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TOrganizationRole } from "@formbricks/types/memberships"; +import { TopControlBar } from "./TopControlBar"; + +// Mock the child component +vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlButtons", () => ({ + TopControlButtons: vi.fn(() =>
Mocked TopControlButtons
), +})); + +const mockEnvironment: TEnvironment = { + id: "env1", + createdAt: new Date(), + updatedAt: new Date(), + type: "production", + projectId: "proj1", + appSetupCompleted: true, +}; + +const mockEnvironments: TEnvironment[] = [ + mockEnvironment, + { ...mockEnvironment, id: "env2", type: "development" }, +]; + +const mockMembershipRole: TOrganizationRole = "owner"; +const mockProjectPermission = "manage"; + +describe("TopControlBar", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders correctly and passes props to TopControlButtons", () => { + render( + + ); + + // Check if the main div is rendered + const mainDiv = screen.getByTestId("fb__global-top-control-bar"); + expect(mainDiv).toHaveClass("flex h-14 w-full items-center justify-end bg-slate-50 px-6"); + + // Check if the mocked child component is rendered + expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument(); + + // Check if the child component received the correct props + expect(TopControlButtons).toHaveBeenCalledWith( + { + environment: mockEnvironment, + environments: mockEnvironments, + membershipRole: mockMembershipRole, + projectPermission: mockProjectPermission, + }, + undefined // Updated from {} to undefined + ); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx index 205de99e2d4f..020f67f40d2b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlBar.tsx @@ -17,7 +17,9 @@ export const TopControlBar = ({ projectPermission, }: SideBarProps) => { return ( -
+
({ + useRouter: vi.fn(() => ({ push: mockPush })), +})); + +vi.mock("@/lib/membership/utils", () => ({ + getAccessFlags: vi.fn(), +})); + +vi.mock("@/modules/ee/teams/utils/teams", () => ({ + getTeamPermissionFlags: vi.fn(), +})); + +vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch", () => ({ + EnvironmentSwitch: vi.fn(() =>
EnvironmentSwitch
), +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick, variant, size, className, asChild, ...props }: any) => { + const Tag = asChild ? "div" : "button"; // Use div if asChild is true for Link mock + return ( + + {children} + + ); + }, +})); + +vi.mock("@/modules/ui/components/tooltip", () => ({ + TooltipRenderer: ({ children, tooltipContent }: { children: React.ReactNode; tooltipContent: string }) => ( +
{children}
+ ), +})); + +vi.mock("lucide-react", () => ({ + BugIcon: () =>
, + CircleUserIcon: () =>
, + PlusIcon: () =>
, +})); + +vi.mock("next/link", () => ({ + default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => ( + + {children} + + ), +})); + +// Mock data +const mockEnvironmentDev: TEnvironment = { + id: "dev-env-id", + createdAt: new Date(), + updatedAt: new Date(), + type: "development", + projectId: "project-id", + appSetupCompleted: true, +}; + +const mockEnvironmentProd: TEnvironment = { + id: "prod-env-id", + createdAt: new Date(), + updatedAt: new Date(), + type: "production", + projectId: "project-id", + appSetupCompleted: true, +}; + +const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd]; + +describe("TopControlButtons", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default mocks for access flags + vi.mocked(getAccessFlags).mockReturnValue({ + isOwner: false, + isMember: false, + isBilling: false, + } as any); + vi.mocked(getTeamPermissionFlags).mockReturnValue({ + hasReadAccess: false, + } as any); + }); + + afterEach(() => { + cleanup(); + }); + + const renderComponent = ( + membershipRole?: TOrganizationRole, + projectPermission: any = null, + isBilling = false, + hasReadAccess = false + ) => { + vi.mocked(getAccessFlags).mockReturnValue({ + isMember: membershipRole === "member", + isBilling: isBilling, + isOwner: membershipRole === "owner", + } as any); + vi.mocked(getTeamPermissionFlags).mockReturnValue({ + hasReadAccess: hasReadAccess, + } as any); + + return render( + + ); + }; + + test("renders correctly for Owner role", async () => { + renderComponent("owner"); + + expect(screen.getByTestId("environment-switch")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument(); + expect(screen.getByTestId("bug-icon")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-account")).toBeInTheDocument(); + expect(screen.getByTestId("circle-user-icon")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument(); + expect(screen.getByTestId("plus-icon")).toBeInTheDocument(); + + // Check link + const link = screen.getByTestId("link-mock"); + expect(link).toHaveAttribute("href", "https://github.com/formbricks/formbricks/issues"); + expect(link).toHaveAttribute("target", "_blank"); + + // Click account button + const accountButton = screen.getByTestId("circle-user-icon").closest("button"); + await userEvent.click(accountButton!); + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/settings/profile`); + }); + + // Click new survey button + const newSurveyButton = screen.getByTestId("plus-icon").closest("button"); + await userEvent.click(newSurveyButton!); + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/surveys/templates`); + }); + }); + + test("hides EnvironmentSwitch for Billing role", () => { + renderComponent(undefined, null, true); // isBilling = true + expect(screen.queryByTestId("environment-switch")).not.toBeInTheDocument(); + expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-account")).toBeInTheDocument(); + expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument(); // Hidden for billing + }); + + test("hides New Survey button for Billing role", () => { + renderComponent(undefined, null, true); // isBilling = true + expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument(); + expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument(); + }); + + test("hides New Survey button for read-only Member", () => { + renderComponent("member", null, false, true); // isMember = true, hasReadAccess = true + expect(screen.getByTestId("environment-switch")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-account")).toBeInTheDocument(); + expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument(); + expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument(); + }); + + test("shows New Survey button for Member with write access", () => { + renderComponent("member", null, false, false); // isMember = true, hasReadAccess = false + expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument(); + expect(screen.getByTestId("plus-icon")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx b/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx index 22ef9d82183d..033410062aa5 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/TopControlButtons.tsx @@ -1,6 +1,7 @@ "use client"; import { EnvironmentSwitch } from "@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch"; +import { getAccessFlags } from "@/lib/membership/utils"; import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { Button } from "@/modules/ui/components/button"; @@ -9,7 +10,6 @@ import { useTranslate } from "@tolgee/react"; import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { TEnvironment } from "@formbricks/types/environment"; import { TOrganizationRole } from "@formbricks/types/memberships"; diff --git a/apps/web/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator.test.tsx new file mode 100644 index 000000000000..e46a908694d0 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator.test.tsx @@ -0,0 +1,104 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { WidgetStatusIndicator } from "./WidgetStatusIndicator"; + +// Mock next/navigation +const mockRefresh = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: mockRefresh, + }), +})); + +// Mock lucide-react icons +vi.mock("lucide-react", () => ({ + AlertTriangleIcon: () =>
AlertTriangleIcon
, + CheckIcon: () =>
CheckIcon
, + RotateCcwIcon: () =>
RotateCcwIcon
, +})); + +// Mock Button component +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick, ...props }: any) => ( + + ), +})); + +const mockEnvironmentNotImplemented: TEnvironment = { + id: "env-not-implemented", + createdAt: new Date(), + updatedAt: new Date(), + type: "development", + projectId: "proj1", + appSetupCompleted: false, // Not implemented state +}; + +const mockEnvironmentRunning: TEnvironment = { + id: "env-running", + createdAt: new Date(), + updatedAt: new Date(), + type: "production", + projectId: "proj1", + appSetupCompleted: true, // Running state +}; + +describe("WidgetStatusIndicator", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders correctly for 'notImplemented' state", () => { + render(); + + // Check icon + expect(screen.getByTestId("alert-icon")).toBeInTheDocument(); + expect(screen.queryByTestId("check-icon")).not.toBeInTheDocument(); + + // Check texts + expect( + screen.getByText("environments.project.app-connection.formbricks_sdk_not_connected") + ).toBeInTheDocument(); + expect( + screen.getByText("environments.project.app-connection.formbricks_sdk_not_connected_description") + ).toBeInTheDocument(); + + // Check button + const recheckButton = screen.getByRole("button", { name: /environments.project.app-connection.recheck/ }); + expect(recheckButton).toBeInTheDocument(); + expect(screen.getByTestId("refresh-icon")).toBeInTheDocument(); + }); + + test("renders correctly for 'running' state", () => { + render(); + + // Check icon + expect(screen.getByTestId("check-icon")).toBeInTheDocument(); + expect(screen.queryByTestId("alert-icon")).not.toBeInTheDocument(); + + // Check texts + expect(screen.getByText("environments.project.app-connection.receiving_data")).toBeInTheDocument(); + expect( + screen.getByText("environments.project.app-connection.formbricks_sdk_connected") + ).toBeInTheDocument(); + + // Check button absence + expect( + screen.queryByRole("button", { name: /environments.project.app-connection.recheck/ }) + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("refresh-icon")).not.toBeInTheDocument(); + }); + + test("calls router.refresh when 'Recheck' button is clicked", async () => { + render(); + + const recheckButton = screen.getByRole("button", { name: /environments.project.app-connection.recheck/ }); + await userEvent.click(recheckButton); + + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator.tsx b/apps/web/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator.tsx index 17bb189f00a7..e5a63bb16c4d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator.tsx @@ -1,10 +1,10 @@ "use client"; +import { cn } from "@/lib/cn"; import { Button } from "@/modules/ui/components/button"; import { useTranslate } from "@tolgee/react"; import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react"; import { useRouter } from "next/navigation"; -import { cn } from "@formbricks/lib/cn"; import { TEnvironment } from "@formbricks/types/environment"; interface WidgetStatusIndicatorProps { diff --git a/apps/web/app/(app)/environments/[environmentId]/context/environment-context.test.tsx b/apps/web/app/(app)/environments/[environmentId]/context/environment-context.test.tsx new file mode 100644 index 000000000000..430a414d71c8 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/context/environment-context.test.tsx @@ -0,0 +1,157 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TProject } from "@formbricks/types/project"; +import { EnvironmentContextWrapper, useEnvironment } from "./environment-context"; + +// Mock environment data +const mockEnvironment: TEnvironment = { + id: "test-env-id", + createdAt: new Date(), + updatedAt: new Date(), + type: "development", + projectId: "test-project-id", + appSetupCompleted: true, +}; + +// Mock project data +const mockProject = { + id: "test-project-id", + createdAt: new Date(), + updatedAt: new Date(), + organizationId: "test-org-id", + config: { + channel: "app", + industry: "saas", + }, + linkSurveyBranding: true, + styling: { + allowStyleOverwrite: true, + brandColor: { + light: "#ffffff", + dark: "#000000", + }, + questionColor: { + light: "#000000", + dark: "#ffffff", + }, + inputColor: { + light: "#000000", + dark: "#ffffff", + }, + inputBorderColor: { + light: "#cccccc", + dark: "#444444", + }, + cardBackgroundColor: { + light: "#ffffff", + dark: "#000000", + }, + cardBorderColor: { + light: "#cccccc", + dark: "#444444", + }, + isDarkModeEnabled: false, + isLogoHidden: false, + hideProgressBar: false, + roundness: 8, + cardArrangement: { + linkSurveys: "casual", + appSurveys: "casual", + }, + }, + recontactDays: 30, + inAppSurveyBranding: true, + logo: { + url: "test-logo.png", + bgColor: "#ffffff", + }, + placement: "bottomRight", + clickOutsideClose: true, +} as TProject; + +// Test component that uses the hook +const TestComponent = () => { + const { environment, project } = useEnvironment(); + return ( +
+
{environment.id}
+
{environment.type}
+
{project.id}
+
{project.organizationId}
+
+ ); +}; + +describe("EnvironmentContext", () => { + afterEach(() => { + cleanup(); + }); + + test("provides environment and project data to child components", () => { + render( + + + + ); + + expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id"); + expect(screen.getByTestId("environment-type")).toHaveTextContent("development"); + expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id"); + expect(screen.getByTestId("project-organization-id")).toHaveTextContent("test-org-id"); + }); + + test("throws error when useEnvironment is used outside of provider", () => { + const TestComponentWithoutProvider = () => { + useEnvironment(); + return
Should not render
; + }; + + expect(() => { + render(); + }).toThrow("useEnvironment must be used within an EnvironmentProvider"); + }); + + test("updates context value when environment or project changes", () => { + const { rerender } = render( + + + + ); + + expect(screen.getByTestId("environment-type")).toHaveTextContent("development"); + + const updatedEnvironment = { + ...mockEnvironment, + type: "production" as const, + }; + + rerender( + + + + ); + + expect(screen.getByTestId("environment-type")).toHaveTextContent("production"); + }); + + test("memoizes context value correctly", () => { + const { rerender } = render( + + + + ); + + // Re-render with same props + rerender( + + + + ); + + // Should still work correctly + expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id"); + expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/context/environment-context.tsx b/apps/web/app/(app)/environments/[environmentId]/context/environment-context.tsx new file mode 100644 index 000000000000..0bc15edbbe21 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/context/environment-context.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { createContext, useContext, useMemo } from "react"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TProject } from "@formbricks/types/project"; + +export interface EnvironmentContextType { + environment: TEnvironment; + project: TProject; + organizationId: string; +} + +const EnvironmentContext = createContext(null); + +export const useEnvironment = () => { + const context = useContext(EnvironmentContext); + if (!context) { + throw new Error("useEnvironment must be used within an EnvironmentProvider"); + } + return context; +}; + +// Client wrapper component to be used in server components +interface EnvironmentContextWrapperProps { + environment: TEnvironment; + project: TProject; + children: React.ReactNode; +} + +export const EnvironmentContextWrapper = ({ + environment, + project, + children, +}: EnvironmentContextWrapperProps) => { + const environmentContextValue = useMemo( + () => ({ + environment, + project, + organizationId: project.organizationId, + }), + [environment, project] + ); + + return ( + {children} + ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/actions.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/actions.ts index 9378615fc696..79d6546cfe53 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/actions.ts @@ -1,15 +1,17 @@ "use server"; +import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { getOrganizationIdFromEnvironmentId, getOrganizationIdFromIntegrationId, getProjectIdFromEnvironmentId, getProjectIdFromIntegrationId, } from "@/lib/utils/helper"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { z } from "zod"; -import { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/integration/service"; import { ZId } from "@formbricks/types/common"; import { ZIntegrationInput } from "@formbricks/types/integration"; @@ -20,48 +22,79 @@ const ZCreateOrUpdateIntegrationAction = z.object({ export const createOrUpdateIntegrationAction = authenticatedActionClient .schema(ZCreateOrUpdateIntegrationAction) - .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "projectTeam", - minPermission: "readWrite", - projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId), - }, - ], - }); + .action( + withAuditLogging( + "createdUpdated", + "integration", + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: Record; + }) => { + const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId); - return await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData); - }); + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + minPermission: "readWrite", + projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId), + }, + ], + }); + + ctx.auditLoggingCtx.organizationId = organizationId; + const result = await createOrUpdateIntegration( + parsedInput.environmentId, + parsedInput.integrationData + ); + ctx.auditLoggingCtx.integrationId = result.id; + ctx.auditLoggingCtx.newObject = result; + return result; + } + ) + ); const ZDeleteIntegrationAction = z.object({ integrationId: ZId, }); -export const deleteIntegrationAction = authenticatedActionClient - .schema(ZDeleteIntegrationAction) - .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromIntegrationId(parsedInput.integrationId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "projectTeam", - projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId), - minPermission: "readWrite", - }, - ], - }); +export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action( + withAuditLogging( + "deleted", + "integration", + async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => { + const organizationId = await getOrganizationIdFromIntegrationId(parsedInput.integrationId); + + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId), + minPermission: "readWrite", + }, + ], + }); - return await deleteIntegration(parsedInput.integrationId); - }); + ctx.auditLoggingCtx.organizationId = organizationId; + ctx.auditLoggingCtx.integrationId = parsedInput.integrationId; + const result = await deleteIntegration(parsedInput.integrationId); + ctx.auditLoggingCtx.oldObject = result; + return result; + } + ) +); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.test.tsx new file mode 100644 index 000000000000..360e39a47abe --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.test.tsx @@ -0,0 +1,466 @@ +import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; +import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRouter } from "next/navigation"; +import { toast } from "react-hot-toast"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TIntegrationItem } from "@formbricks/types/integration"; +import { + TIntegrationAirtable, + TIntegrationAirtableConfigData, + TIntegrationAirtableCredential, + TIntegrationAirtableTables, +} from "@formbricks/types/integration/airtable"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { AddIntegrationModal } from "./AddIntegrationModal"; + +// Mock dependencies +vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({ + createOrUpdateIntegrationAction: vi.fn(), +})); +vi.mock( + "@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown", + () => ({ + BaseSelectDropdown: ({ control, airtableArray, fetchTable, defaultValue, setValue }) => ( +
+ + +
+ ), + }) +); +vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({ + fetchTables: vi.fn(), +})); +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: (value, _locale) => value?.default || value || "", +})); +vi.mock("@/lib/utils/recall", () => ({ + replaceHeadlineRecall: (survey, _locale) => survey, +})); +vi.mock("@/modules/ui/components/additional-integration-settings", () => ({ + AdditionalIntegrationSettings: ({ + includeVariables, + setIncludeVariables, + includeHiddenFields, + setIncludeHiddenFields, + includeMetadata, + setIncludeMetadata, + includeCreatedAt, + setIncludeCreatedAt, + }) => ( +
+ setIncludeVariables(e.target.checked)} + /> + setIncludeHiddenFields(e.target.checked)} + /> + setIncludeMetadata(e.target.checked)} + /> + setIncludeCreatedAt(e.target.checked)} + /> +
+ ), +})); +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: ({ children, open, onOpenChange }: any) => + open ? ( +
+ {children} + +
+ ) : null, + DialogContent: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>

{children}

, + DialogBody: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, +})); +vi.mock("@/modules/ui/components/alert", () => ({ + Alert: ({ children }) =>
{children}
, + AlertTitle: ({ children }) =>
{children}
, + AlertDescription: ({ children }) =>
{children}
, +})); +vi.mock("next/image", () => ({ + // eslint-disable-next-line @next/next/no-img-element + default: (props) => test, +})); +vi.mock("next/navigation", () => ({ + useRouter: vi.fn(() => ({ refresh: vi.fn() })), +})); + +// Mock the Select component used for Table and Survey selections +vi.mock("@/modules/ui/components/select", () => ({ + Select: ({ children }) => ( + // Render children, assuming Controller passes props to the Trigger/Value + // The actual select logic will be handled by the mocked Controller/field + // We need to simulate the structure expected by the Controller render prop +
{children}
+ ), + SelectTrigger: ({ children, ...props }) =>
{children}
, // Mock Trigger + SelectValue: ({ placeholder }) => {placeholder || "Select..."}, // Mock Value display + SelectContent: ({ children }) =>
{children}
, // Mock Content wrapper + SelectItem: ({ children, value, ...props }) => ( + // Mock Item - crucial for userEvent.selectOptions if we were using a real select + // For Controller, the value change is handled by field.onChange directly +
+ {children} +
+ ), +})); + +// Mock react-hook-form Controller to render a simple select +vi.mock("react-hook-form", async () => { + const actual = await vi.importActual("react-hook-form"); + let fields = {}; + const mockReset = vi.fn((values) => { + fields = values || {}; // Reset fields, optionally with new values + }); + + return { + ...actual, + useForm: vi.fn((options) => { + fields = options?.defaultValues || {}; + const mockControlOnChange = (event) => { + if (event && event.target) { + fields[event.target.name] = event.target.value; + } + }; + return { + handleSubmit: (fn) => (e) => { + e?.preventDefault(); + fn(fields); + }, + control: { + _mockOnChange: mockControlOnChange, + // Add other necessary control properties if needed + register: vi.fn(), + unregister: vi.fn(), + getFieldState: vi.fn(() => ({ invalid: false, isDirty: false, isTouched: false, error: null })), + _names: { mount: new Set(), unMount: new Set(), array: new Set(), watch: new Set() }, + _options: {}, + _proxyFormState: { + isDirty: false, + isValidating: false, + dirtyFields: {}, + touchedFields: {}, + errors: {}, + }, + _formState: { isDirty: false, isValidating: false, dirtyFields: {}, touchedFields: {}, errors: {} }, + _updateFormState: vi.fn(), + _updateFieldArray: vi.fn(), + _executeSchema: vi.fn().mockResolvedValue({ errors: {}, values: {} }), + _getWatch: vi.fn(), + _subjects: { + watch: { subscribe: vi.fn() }, + array: { subscribe: vi.fn() }, + state: { subscribe: vi.fn() }, + }, + _getDirty: vi.fn(), + _reset: vi.fn(), + _removeUnmounted: vi.fn(), + }, + watch: (name) => fields[name], + setValue: (name, value) => { + fields[name] = value; + }, + reset: mockReset, + formState: { errors: {}, isDirty: false, isValid: true, isSubmitting: false }, + getValues: (name) => (name ? fields[name] : fields), + }; + }), + Controller: ({ name, defaultValue }) => { + // Initialize field value if not already set by reset/defaultValues + if (fields[name] === undefined && defaultValue !== undefined) { + fields[name] = defaultValue; + } + + const field = { + onChange: (valueOrEvent) => { + const value = valueOrEvent?.target ? valueOrEvent.target.value : valueOrEvent; + fields[name] = value; + // Re-render might be needed here in a real scenario, but testing library handles it + }, + onBlur: vi.fn(), + value: fields[name], + name: name, + ref: vi.fn(), + }; + + // Find the corresponding label to associate with the select + const labelId = name; // Assuming label 'for' matches field name + const labelText = + name === "table" ? "environments.integrations.airtable.table_name" : "common.select_survey"; + + // Render a simple select element instead of the complex component + // This makes interaction straightforward with userEvent.selectOptions + return ( + <> + {/* The actual label is rendered outside the Controller in the component */} + + + ); + }, + reset: mockReset, + }; +}); + +const environmentId = "test-env-id"; +const mockSurveys: TSurvey[] = [ + { + id: "survey1", + name: "Survey 1", + questions: [ + { id: "q1", headline: { default: "Question 1" } }, + { id: "q2", headline: { default: "Question 2" } }, + ], + hiddenFields: { enabled: true, fieldIds: ["hf1"] }, + variables: { enabled: true, fieldIds: ["var1"] }, + } as any, + { + id: "survey2", + name: "Survey 2", + questions: [{ id: "q3", headline: { default: "Question 3" } }], + hiddenFields: { enabled: false }, + variables: { enabled: false }, + } as any, +]; +const mockAirtableArray: TIntegrationItem[] = [ + { id: "base1", name: "Base 1" }, + { id: "base2", name: "Base 2" }, +]; +const mockAirtableIntegration: TIntegrationAirtable = { + id: "integration1", + type: "airtable", + environmentId, + config: { + key: { access_token: "abc" } as TIntegrationAirtableCredential, + email: "test@test.com", + data: [], + }, +}; +const mockTables: TIntegrationAirtableTables["tables"] = [ + { id: "table1", name: "Table 1" }, + { id: "table2", name: "Table 2" }, +]; +const mockSetOpenWithStates = vi.fn(); +const mockRouterRefresh = vi.fn(); + +describe("AddIntegrationModal", () => { + beforeEach(async () => { + vi.clearAllMocks(); + vi.mocked(useRouter).mockReturnValue({ refresh: mockRouterRefresh } as any); + }); + + afterEach(() => { + cleanup(); + }); + + test("renders in add mode correctly", () => { + render( + + ); + + expect(screen.getByText("environments.integrations.airtable.link_airtable_table")).toBeInTheDocument(); + expect(screen.getByLabelText("Base")).toBeInTheDocument(); + // Use getByLabelText for the mocked selects + expect(screen.getByLabelText("environments.integrations.airtable.table_name")).toBeInTheDocument(); + expect(screen.getByLabelText("common.select_survey")).toBeInTheDocument(); + expect(screen.getByText("common.save")).toBeInTheDocument(); + expect(screen.getByText("common.cancel")).toBeInTheDocument(); + expect(screen.queryByText("common.delete")).not.toBeInTheDocument(); + }); + + test("shows 'No Base Found' error when airtableArray is empty", () => { + render( + + ); + expect(screen.getByTestId("alert-title")).toHaveTextContent( + "environments.integrations.airtable.no_bases_found" + ); + }); + + test("shows 'No Surveys Found' warning when surveys array is empty", () => { + render( + + ); + expect(screen.getByText("environments.integrations.create_survey_warning")).toBeInTheDocument(); + }); + + test("fetches and displays tables when a base is selected", async () => { + vi.mocked(fetchTables).mockResolvedValue({ tables: mockTables }); + render( + + ); + + const baseSelect = screen.getByLabelText("Base"); + await userEvent.selectOptions(baseSelect, "base1"); + + expect(fetchTables).toHaveBeenCalledWith(environmentId, "base1"); + await waitFor(() => { + // Use getByLabelText (mocked select) + const tableSelect = screen.getByLabelText("environments.integrations.airtable.table_name"); + expect(tableSelect).toBeEnabled(); + // Check options within the mocked select + expect(tableSelect.querySelector("option[value='table1']")).toBeInTheDocument(); + expect(tableSelect.querySelector("option[value='table2']")).toBeInTheDocument(); + }); + }); + + test("handles deletion in edit mode", async () => { + const initialData: TIntegrationAirtableConfigData = { + baseId: "base1", + tableId: "table1", + surveyId: "survey1", + questionIds: ["q1"], + questions: "common.selected_questions", + tableName: "Table 1", + surveyName: "Survey 1", + createdAt: new Date(), + includeVariables: false, + includeHiddenFields: false, + includeMetadata: false, + includeCreatedAt: true, + }; + const integrationWithData = { + ...mockAirtableIntegration, + config: { ...mockAirtableIntegration.config, data: [initialData] }, + }; + const defaultData = { ...initialData, index: 0 } as any; + + vi.mocked(fetchTables).mockResolvedValue({ tables: mockTables }); + vi.mocked(createOrUpdateIntegrationAction).mockResolvedValue({ ok: true, data: {} } as any); + + render( + + ); + + await waitFor(() => expect(fetchTables).toHaveBeenCalled()); // Wait for initial load + + // Click delete + await userEvent.click(screen.getByText("common.delete")); + + await waitFor(() => { + expect(createOrUpdateIntegrationAction).toHaveBeenCalledTimes(1); + const submittedData = vi.mocked(createOrUpdateIntegrationAction).mock.calls[0][0].integrationData; + // Expect data array to be empty after deletion + expect(submittedData.config.data).toHaveLength(0); + }); + + expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully"); + expect(mockSetOpenWithStates).toHaveBeenCalledWith(false); + expect(mockRouterRefresh).toHaveBeenCalled(); + }); + + test("handles cancel button click", async () => { + render( + + ); + + await userEvent.click(screen.getByText("common.cancel")); + expect(mockSetOpenWithStates).toHaveBeenCalledWith(false); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx index c45b4114063a..5ee6ad83ea4f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx @@ -4,12 +4,22 @@ import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[envir import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown"; import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable"; import AirtableLogo from "@/images/airtableLogo.svg"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; import { Button } from "@/modules/ui/components/button"; import { Checkbox } from "@/modules/ui/components/checkbox"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; import { Label } from "@/modules/ui/components/label"; -import { Modal } from "@/modules/ui/components/modal"; import { Select, SelectContent, @@ -17,14 +27,12 @@ import { SelectTrigger, SelectValue, } from "@/modules/ui/components/select"; -import { useTranslate } from "@tolgee/react"; +import { TFnType, useTranslate } from "@tolgee/react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { Control, Controller, useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall"; import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationAirtable, @@ -68,6 +76,80 @@ const NoBaseFoundError = () => { ); }; +const renderQuestionSelection = ({ + t, + selectedSurvey, + control, + includeVariables, + setIncludeVariables, + includeHiddenFields, + includeMetadata, + setIncludeHiddenFields, + setIncludeMetadata, + includeCreatedAt, + setIncludeCreatedAt, +}: { + t: TFnType; + selectedSurvey: TSurvey; + control: Control; + includeVariables: boolean; + setIncludeVariables: (value: boolean) => void; + includeHiddenFields: boolean; + includeMetadata: boolean; + setIncludeHiddenFields: (value: boolean) => void; + setIncludeMetadata: (value: boolean) => void; + includeCreatedAt: boolean; + setIncludeCreatedAt: (value: boolean) => void; +}) => { + return ( +
+
+ +
+
+ {replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => ( + ( +
+ +
+ )} + /> + ))} +
+
+
+ +
+ ); +}; + export const AddIntegrationModal = ({ open, setOpenWithStates, @@ -210,182 +292,148 @@ export const AddIntegrationModal = ({ }; return ( - -
-
+ + +
-
- Airtable logo +
+ {t("environments.integrations.airtable.airtable_logo")}
-
-
- {t("environments.integrations.airtable.link_airtable_table")} -
-
+
+ {t("environments.integrations.airtable.link_airtable_table")} + {t("environments.integrations.airtable.sync_responses_with_airtable")} -
+
-
-
- -
-
- {airtableArray.length ? ( - - ) : ( - - )} - -
- -
- + + +
+ {airtableArray.length ? ( + ( - - )} + isLoading={isLoading} + fetchTable={fetchTable} + airtableArray={airtableArray} + setValue={setValue} + defaultValue={defaultData?.base} /> -
-
+ ) : ( + + )} - {surveys.length ? (
- +
( )} />
- ) : null} - {!surveys.length ? ( -

- {t("environments.integrations.create_survey_warning")} -

- ) : null} - - {survey && selectedSurvey && ( -
-
- -
-
- {replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => ( - ( -
- -
- )} - /> - ))} -
+ {surveys.length ? ( +
+ +
+ ( + + )} + />
- -
- )} - -
- {isEditMode ? ( - ) : ( - +

+ {t("environments.integrations.create_survey_warning")} +

)} - + {survey && + selectedSurvey && + renderQuestionSelection({ + t, + selectedSurvey, + control, + includeVariables, + setIncludeVariables, + includeHiddenFields, + includeMetadata, + setIncludeHiddenFields, + setIncludeMetadata, + includeCreatedAt, + setIncludeCreatedAt, + })}
-
-
- - + + + {isEditMode ? ( + + ) : ( + + )} + + + + + +
); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper.test.tsx new file mode 100644 index 000000000000..8ecfebc8a22c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper.test.tsx @@ -0,0 +1,134 @@ +import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TIntegrationAirtable } from "@formbricks/types/integration/airtable"; +import { AirtableWrapper } from "./AirtableWrapper"; + +// Mock child components +vi.mock( + "@/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration", + () => ({ + ManageIntegration: ({ setIsConnected }) => ( +
+ +
+ ), + }) +); +vi.mock("@/modules/ui/components/connect-integration", () => ({ + ConnectIntegration: ({ handleAuthorization, isEnabled }) => ( +
+ +
+ ), +})); + +// Mock library function +vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({ + authorize: vi.fn(), +})); + +// Mock image import +vi.mock("@/images/airtableLogo.svg", () => ({ + default: "airtable-logo-path", +})); + +// Mock window.location.replace +Object.defineProperty(window, "location", { + value: { + replace: vi.fn(), + }, + writable: true, +}); + +const environmentId = "test-env-id"; +const webAppUrl = "https://app.formbricks.com"; +const environment = { id: environmentId } as TEnvironment; +const surveys = []; +const airtableArray = []; +const locale = "en-US" as const; + +const baseProps = { + environmentId, + airtableArray, + surveys, + environment, + webAppUrl, + locale, +}; + +describe("AirtableWrapper", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders ConnectIntegration when not connected (no integration)", () => { + render(); + expect(screen.getByTestId("connect-integration")).toBeInTheDocument(); + expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled(); + }); + + test("renders ConnectIntegration when not connected (integration without key)", () => { + const integrationWithoutKey = { config: {} } as TIntegrationAirtable; + render(); + expect(screen.getByTestId("connect-integration")).toBeInTheDocument(); + expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument(); + }); + + test("renders ConnectIntegration disabled when isEnabled is false", () => { + render(); + expect(screen.getByTestId("connect-integration")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled(); + }); + + test("calls authorize and redirects when Connect button is clicked", async () => { + const mockAuthorize = vi.mocked(authorize); + const redirectUrl = "https://airtable.com/auth"; + mockAuthorize.mockResolvedValue(redirectUrl); + + render(); + + const connectButton = screen.getByRole("button", { name: "Connect" }); + await userEvent.click(connectButton); + + expect(mockAuthorize).toHaveBeenCalledWith(environmentId, webAppUrl); + await vi.waitFor(() => { + expect(window.location.replace).toHaveBeenCalledWith(redirectUrl); + }); + }); + + test("renders ManageIntegration when connected", () => { + const connectedIntegration = { + id: "int-1", + config: { key: { access_token: "abc" }, email: "test@test.com", data: [] }, + } as unknown as TIntegrationAirtable; + render(); + expect(screen.getByTestId("manage-integration")).toBeInTheDocument(); + expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument(); + }); + + test("switches from ManageIntegration to ConnectIntegration when disconnected", async () => { + const connectedIntegration = { + id: "int-1", + config: { key: { access_token: "abc" }, email: "test@test.com", data: [] }, + } as unknown as TIntegrationAirtable; + render(); + + // Initially, ManageIntegration is shown + expect(screen.getByTestId("manage-integration")).toBeInTheDocument(); + + // Simulate disconnection via ManageIntegration's button + const disconnectButton = screen.getByRole("button", { name: "Disconnect" }); + await userEvent.click(disconnectButton); + + // Now, ConnectIntegration should be shown + expect(screen.getByTestId("connect-integration")).toBeInTheDocument(); + expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown.test.tsx new file mode 100644 index 000000000000..c3075a0076fb --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown.test.tsx @@ -0,0 +1,125 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { useForm } from "react-hook-form"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TIntegrationItem } from "@formbricks/types/integration"; +import { IntegrationModalInputs } from "./AddIntegrationModal"; +import { BaseSelectDropdown } from "./BaseSelectDropdown"; + +// Mock UI components +vi.mock("@/modules/ui/components/label", () => ({ + Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor: string }) => ( + + ), +})); +vi.mock("@/modules/ui/components/select", () => ({ + Select: ({ children, onValueChange, disabled, defaultValue }) => ( + + ), + SelectTrigger: ({ children }) =>
{children}
, + SelectValue: () => SelectValueMock, + SelectContent: ({ children }) =>
{children}
, + SelectItem: ({ children, value }) => , +})); + +// Mock react-hook-form's Controller specifically +vi.mock("react-hook-form", async () => { + const actual = await vi.importActual("react-hook-form"); + // Keep the actual useForm + const originalUseForm = actual.useForm; + + // Mock Controller + const MockController = ({ name, _, render, defaultValue }) => { + // Minimal mock: call render with a basic field object + const field = { + onChange: vi.fn(), // Simple spy for field.onChange + onBlur: vi.fn(), + value: defaultValue, // Use defaultValue passed to Controller + name: name, + ref: vi.fn(), + }; + // The component passes the render prop result to the actual Select component + return render({ field }); + }; + + return { + ...actual, + useForm: originalUseForm, // Use the actual useForm + Controller: MockController, // Use the mocked Controller + }; +}); + +const mockAirtableArray: TIntegrationItem[] = [ + { id: "base1", name: "Base One" }, + { id: "base2", name: "Base Two" }, +]; + +const mockFetchTable = vi.fn(); + +// Use a wrapper component that utilizes the actual useForm +const renderComponent = ( + isLoading = false, + defaultValue: string | undefined = undefined, + airtableArray = mockAirtableArray +) => { + const Component = () => { + // Now uses the actual useForm because Controller is mocked separately + const { control, setValue } = useForm({ + defaultValues: { base: defaultValue }, + }); + return ( + + ); + }; + return render(); +}; + +describe("BaseSelectDropdown", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders the label and select trigger", () => { + renderComponent(); + expect(screen.getByText("environments.integrations.airtable.airtable_base")).toBeInTheDocument(); + expect(screen.getByTestId("base-select")).toBeInTheDocument(); + expect(screen.getByText("SelectValueMock")).toBeInTheDocument(); // From mocked SelectValue + }); + + test("renders options from airtableArray", () => { + renderComponent(); + const select = screen.getByTestId("base-select"); + expect(select.querySelectorAll("option")).toHaveLength(mockAirtableArray.length); + expect(screen.getByText("Base One")).toBeInTheDocument(); + expect(screen.getByText("Base Two")).toBeInTheDocument(); + }); + + test("disables the select when isLoading is true", () => { + renderComponent(true); + expect(screen.getByTestId("base-select")).toBeDisabled(); + }); + + test("enables the select when isLoading is false", () => { + renderComponent(false); + expect(screen.getByTestId("base-select")).toBeEnabled(); + }); + + test("renders correctly with empty airtableArray", () => { + renderComponent(false, undefined, []); + const select = screen.getByTestId("base-select"); + expect(select.querySelectorAll("option")).toHaveLength(0); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.test.tsx new file mode 100644 index 000000000000..df1a9130c9b7 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.test.tsx @@ -0,0 +1,151 @@ +import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TIntegrationAirtable, TIntegrationAirtableConfig } from "@formbricks/types/integration/airtable"; +import { ManageIntegration } from "./ManageIntegration"; + +vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({ + deleteIntegrationAction: vi.fn(), +})); +vi.mock( + "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal", + () => ({ + AddIntegrationModal: ({ open, setOpenWithStates }) => + open ? ( +
+ +
+ ) : null, + }) +); +vi.mock("@/modules/ui/components/delete-dialog", () => ({ + DeleteDialog: ({ open, setOpen, onDelete }) => + open ? ( +
+ + +
+ ) : null, +})); +vi.mock("react-hot-toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const baseProps = { + environment: { id: "env1" } as TEnvironment, + environmentId: "env1", + setIsConnected: vi.fn(), + surveys: [], + airtableArray: [], + locale: "en-US" as const, +}; + +describe("ManageIntegration", () => { + afterEach(() => { + cleanup(); + }); + + test("empty state", () => { + render( + + ); + expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument(); + expect(screen.getByText(/link_new_table/)).toBeInTheDocument(); + }); + + test("open add modal", async () => { + render( + + ); + await userEvent.click(screen.getByText(/link_new_table/)); + expect(screen.getByTestId("add-modal")).toBeInTheDocument(); + }); + + test("list integrations and open edit modal", async () => { + const item = { + baseId: "b", + tableId: "t", + surveyId: "s", + surveyName: "S", + tableName: "T", + questions: "Q", + questionIds: ["x"], + createdAt: new Date(), + includeVariables: false, + includeHiddenFields: false, + includeMetadata: false, + includeCreatedAt: false, + }; + render( + + ); + expect(screen.getByText("S")).toBeInTheDocument(); + await userEvent.click(screen.getByText("S")); + expect(screen.getByTestId("add-modal")).toBeInTheDocument(); + }); + + test("delete integration success", async () => { + vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any); + render( + + ); + await userEvent.click(screen.getByText(/delete_integration/)); + expect(screen.getByTestId("delete-dialog")).toBeInTheDocument(); + await userEvent.click(screen.getByText("confirm")); + expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" }); + const { toast } = await import("react-hot-toast"); + expect(toast.success).toHaveBeenCalled(); + expect(baseProps.setIsConnected).toHaveBeenCalledWith(false); + }); + + test("delete integration error", async () => { + vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any); + render( + + ); + await userEvent.click(screen.getByText(/delete_integration/)); + await userEvent.click(screen.getByText("confirm")); + const { toast } = await import("react-hot-toast"); + expect(toast.error).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.tsx index 87324ad1348f..617c75b39b15 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration.tsx @@ -5,6 +5,7 @@ import { AddIntegrationModal, IntegrationModalInputs, } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal"; +import { timeSince } from "@/lib/time"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { Button } from "@/modules/ui/components/button"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; @@ -13,7 +14,6 @@ import { useTranslate } from "@tolgee/react"; import { Trash2Icon } from "lucide-react"; import { useState } from "react"; import { toast } from "react-hot-toast"; -import { timeSince } from "@formbricks/lib/time"; import { TEnvironment } from "@formbricks/types/environment"; import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationAirtable } from "@formbricks/types/integration/airtable"; @@ -30,16 +30,16 @@ interface ManageIntegrationProps { locale: TUserLocale; } -const tableHeaders = [ - "common.survey", - "environments.integrations.airtable.table_name", - "common.questions", - "common.updated_at", -]; - export const ManageIntegration = (props: ManageIntegrationProps) => { const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props; const { t } = useTranslate(); + + const tableHeaders = [ + t("common.survey"), + t("environments.integrations.airtable.table_name"), + t("common.questions"), + t("common.updated_at"), + ]; const [isDeleting, setisDeleting] = useState(false); const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false); const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>( @@ -98,17 +98,17 @@ export const ManageIntegration = (props: ManageIntegrationProps) => { {integrationData.length ? (
- {tableHeaders.map((header, idx) => ( - {integrationData.map((data, index) => ( -
{ setDefaultValues({ base: data.baseId, @@ -129,7 +129,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
{timeSince(data.createdAt.toString(), props.locale)}
-
+ ))}
) : ( diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable.test.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable.test.ts new file mode 100644 index 000000000000..22fcf400db2e --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; +import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable"; +import { authorize, fetchTables } from "./airtable"; + +// Mock the logger +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +// Mock fetch +global.fetch = vi.fn(); + +const environmentId = "test-env-id"; +const baseId = "test-base-id"; +const apiHost = "http://localhost:3000"; + +describe("Airtable Library", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("fetchTables", () => { + test("should fetch tables successfully", async () => { + const mockTables: TIntegrationAirtableTables = { + tables: [ + { id: "tbl1", name: "Table 1" }, + { id: "tbl2", name: "Table 2" }, + ], + }; + const mockResponse = { + ok: true, + json: async () => ({ data: mockTables }), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as Response); + + const tables = await fetchTables(environmentId, baseId); + + expect(fetch).toHaveBeenCalledWith(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, { + method: "GET", + headers: { environmentId: environmentId }, + cache: "no-store", + }); + expect(tables).toEqual(mockTables); + }); + }); + + describe("authorize", () => { + test("should return authUrl successfully", async () => { + const mockAuthUrl = "https://airtable.com/oauth2/v1/authorize?..."; + const mockResponse = { + ok: true, + json: async () => ({ data: { authUrl: mockAuthUrl } }), + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as Response); + + const authUrl = await authorize(environmentId, apiHost); + + expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, { + method: "GET", + headers: { environmentId: environmentId }, + }); + expect(authUrl).toBe(mockAuthUrl); + }); + + test("should throw error and log when fetch fails", async () => { + const errorText = "Failed to fetch"; + const mockResponse = { + ok: false, + text: async () => errorText, + }; + vi.mocked(fetch).mockResolvedValue(mockResponse as Response); + + await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response"); + + expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, { + method: "GET", + headers: { environmentId: environmentId }, + }); + expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch airtable config"); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.test.tsx new file mode 100644 index 000000000000..9abf32e348ed --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.test.tsx @@ -0,0 +1,220 @@ +import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; +import { getAirtableTables } from "@/lib/airtable/service"; +import { WEBAPP_URL } from "@/lib/constants"; +import { getIntegrations } from "@/lib/integration/service"; +import { findMatchingLocale } from "@/lib/utils/locale"; +import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth"; +import { cleanup, render, screen } from "@testing-library/react"; +import { redirect } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TIntegrationItem } from "@formbricks/types/integration"; +import { TIntegrationAirtable, TIntegrationAirtableCredential } from "@formbricks/types/integration/airtable"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import Page from "./page"; + +// Mock dependencies +vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper", () => ({ + AirtableWrapper: vi.fn(() =>
AirtableWrapper Mock
), +})); +vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys"); +vi.mock("@/lib/airtable/service"); + +let mockAirtableClientId: string | undefined = "test-client-id"; + +vi.mock("@/lib/constants", () => ({ + get AIRTABLE_CLIENT_ID() { + return mockAirtableClientId; + }, + WEBAPP_URL: "http://localhost:3000", + IS_PRODUCTION: true, + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: true, +})); + +vi.mock("@/lib/integration/service"); +vi.mock("@/lib/utils/locale"); +vi.mock("@/modules/environments/lib/utils"); +vi.mock("@/modules/ui/components/go-back-button", () => ({ + GoBackButton: vi.fn(() =>
GoBackButton Mock
), +})); +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: vi.fn(({ children }) =>
{children}
), +})); +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: vi.fn(({ pageTitle }) =>

{pageTitle}

), +})); +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); +vi.mock("next/navigation"); + +const mockEnvironmentId = "test-env-id"; +const mockEnvironment = { + id: mockEnvironmentId, + createdAt: new Date(), + updatedAt: new Date(), + type: "development", +} as unknown as TEnvironment; +const mockSurveys: TSurvey[] = [{ id: "survey1", name: "Survey 1" } as TSurvey]; +const mockAirtableIntegration: TIntegrationAirtable = { + type: "airtable", + config: { + key: { access_token: "test-token" } as unknown as TIntegrationAirtableCredential, + data: [], + email: "test@example.com", + }, + environmentId: mockEnvironmentId, + id: "int_airtable_123", +}; +const mockAirtableTables: TIntegrationItem[] = [{ id: "table1", name: "Table 1" } as TIntegrationItem]; +const mockLocale = "en-US"; + +const props = { + params: { + environmentId: mockEnvironmentId, + }, +}; + +describe("Airtable Integration Page", () => { + beforeEach(() => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + environment: mockEnvironment, + isReadOnly: false, + } as unknown as TEnvironmentAuth); + vi.mocked(getSurveys).mockResolvedValue(mockSurveys); + vi.mocked(getIntegrations).mockResolvedValue([mockAirtableIntegration]); + vi.mocked(getAirtableTables).mockResolvedValue(mockAirtableTables); + vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("redirects if user is readOnly", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + environment: mockEnvironment, + isReadOnly: true, + } as unknown as TEnvironmentAuth); + await render(await Page(props)); + expect(redirect).toHaveBeenCalledWith("./"); + }); + + test("renders correctly when integration is configured", async () => { + await render(await Page(props)); + + expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument(); + expect(screen.getByText("GoBackButton Mock")).toBeInTheDocument(); + expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument(); + + expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId); + expect(vi.mocked(getSurveys)).toHaveBeenCalledWith(mockEnvironmentId); + expect(vi.mocked(getIntegrations)).toHaveBeenCalledWith(mockEnvironmentId); + expect(vi.mocked(getAirtableTables)).toHaveBeenCalledWith(mockEnvironmentId); + expect(vi.mocked(findMatchingLocale)).toHaveBeenCalled(); + + const AirtableWrapper = vi.mocked( + ( + await import( + "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper" + ) + ).AirtableWrapper + ); + expect(AirtableWrapper).toHaveBeenCalledWith( + { + isEnabled: true, + airtableIntegration: mockAirtableIntegration, + airtableArray: mockAirtableTables, + environmentId: mockEnvironmentId, + surveys: mockSurveys, + environment: mockEnvironment, + webAppUrl: WEBAPP_URL, + locale: mockLocale, + }, + undefined + ); + }); + + test("renders correctly when integration exists but is not configured (no key)", async () => { + const integrationWithoutKey = { + ...mockAirtableIntegration, + config: { ...mockAirtableIntegration.config, key: undefined }, + } as unknown as TIntegrationAirtable; + vi.mocked(getIntegrations).mockResolvedValue([integrationWithoutKey]); + + await render(await Page(props)); + + expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument(); + expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument(); + + expect(vi.mocked(getAirtableTables)).not.toHaveBeenCalled(); // Should not fetch tables if no key + + const AirtableWrapper = vi.mocked( + ( + await import( + "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper" + ) + ).AirtableWrapper + ); + // Update assertion to match the actual call + expect(AirtableWrapper).toHaveBeenCalledWith( + { + isEnabled: true, // isEnabled is true because AIRTABLE_CLIENT_ID is set in beforeEach + airtableIntegration: integrationWithoutKey, + airtableArray: [], // Should be empty as getAirtableTables is not called + environmentId: mockEnvironmentId, + surveys: mockSurveys, + environment: mockEnvironment, + webAppUrl: WEBAPP_URL, + locale: mockLocale, + }, + undefined // Change second argument to undefined + ); + }); + + test("renders correctly when integration is disabled (no client ID)", async () => { + mockAirtableClientId = undefined; // Simulate disabled integration + + await render(await Page(props)); + + expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument(); + expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument(); + + const AirtableWrapper = vi.mocked( + ( + await import( + "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper" + ) + ).AirtableWrapper + ); + expect(AirtableWrapper).toHaveBeenCalledWith( + expect.objectContaining({ + isEnabled: false, // Should be false + }), + undefined + ); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx index 38861cd0a50b..ebd184254ed0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx @@ -1,15 +1,15 @@ import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; +import { getAirtableTables } from "@/lib/airtable/service"; +import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants"; +import { getIntegrations } from "@/lib/integration/service"; +import { findMatchingLocale } from "@/lib/utils/locale"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getTranslate } from "@/tolgee/server"; import { redirect } from "next/navigation"; -import { getAirtableTables } from "@formbricks/lib/airtable/service"; -import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants"; -import { getIntegrations } from "@formbricks/lib/integration/service"; -import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationAirtable } from "@formbricks/types/integration/airtable"; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/actions.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/actions.ts index 987137725321..9e23cd3bff6a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/actions.ts @@ -1,10 +1,10 @@ "use server"; +import { getSpreadsheetNameById } from "@/lib/googleSheet/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper"; import { z } from "zod"; -import { getSpreadsheetNameById } from "@formbricks/lib/googleSheet/service"; import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet"; const ZGetSpreadsheetNameByIdAction = z.object({ diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.test.tsx new file mode 100644 index 000000000000..dec43f2dcdde --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.test.tsx @@ -0,0 +1,705 @@ +import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { + TIntegrationGoogleSheets, + TIntegrationGoogleSheetsConfigData, +} from "@formbricks/types/integration/google-sheet"; +import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; + +// Mock actions and utilities +vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({ + createOrUpdateIntegrationAction: vi.fn(), +})); +vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions", () => ({ + getSpreadsheetNameByIdAction: vi.fn(), +})); +vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util", () => ({ + constructGoogleSheetsUrl: (id: string) => `https://docs.google.com/spreadsheets/d/${id}`, + extractSpreadsheetIdFromUrl: (url: string) => url.split("/")[5], + isValidGoogleSheetsUrl: (url: string) => url.startsWith("https://docs.google.com/spreadsheets/d/"), +})); +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: (value: any, _locale: string) => value?.default || "", +})); +vi.mock("@/lib/utils/recall", () => ({ + replaceHeadlineRecall: (survey: any) => survey, +})); +vi.mock("@/modules/ui/components/additional-integration-settings", () => ({ + AdditionalIntegrationSettings: ({ + includeVariables, + setIncludeVariables, + includeHiddenFields, + setIncludeHiddenFields, + includeMetadata, + setIncludeMetadata, + includeCreatedAt, + setIncludeCreatedAt, + }: any) => ( +
+ Additional Settings + setIncludeVariables(e.target.checked)} + /> + setIncludeHiddenFields(e.target.checked)} + /> + setIncludeMetadata(e.target.checked)} + /> + setIncludeCreatedAt(e.target.checked)} + /> +
+ ), +})); +vi.mock("@/modules/ui/components/dropdown-selector", () => ({ + DropdownSelector: ({ label, items, selectedItem, setSelectedItem }: any) => ( +
+ + +
+ ), +})); +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: ({ children, open, onOpenChange }: any) => + open ? ( +
+ {children} + +
+ ) : null, + DialogContent: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>

{children}

, + DialogBody: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, +})); +vi.mock("next/image", () => ({ + // eslint-disable-next-line @next/next/no-img-element + default: ({ src, alt }: { src: string; alt: string }) => {alt}, +})); +vi.mock("react-hook-form", () => ({ + useForm: () => ({ + handleSubmit: (callback: any) => (event: any) => { + event.preventDefault(); + callback(); + }, + }), +})); +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); +vi.mock("@tolgee/react", async () => { + const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}; + const useTranslate = () => ({ + t: (key: string, _?: any) => { + // NOSONAR + // Simple mock translation function + if (key === "common.all_questions") return "All questions"; + if (key === "common.selected_questions") return "Selected questions"; + if (key === "environments.integrations.google_sheets.link_google_sheet") return "Link Google Sheet"; + if (key === "common.update") return "Update"; + if (key === "common.delete") return "Delete"; + if (key === "common.cancel") return "Cancel"; + if (key === "environments.integrations.google_sheets.spreadsheet_url") return "Spreadsheet URL"; + if (key === "common.select_survey") return "Select survey"; + if (key === "common.questions") return "Questions"; + if (key === "environments.integrations.google_sheets.enter_a_valid_spreadsheet_url_error") + return "Please enter a valid Google Sheet URL."; + if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey."; + if (key === "environments.integrations.select_at_least_one_question_error") + return "Please select at least one question."; + if (key === "environments.integrations.integration_updated_successfully") + return "Integration updated successfully."; + if (key === "environments.integrations.integration_added_successfully") + return "Integration added successfully."; + if (key === "environments.integrations.integration_removed_successfully") + return "Integration removed successfully."; + if (key === "environments.integrations.google_sheets.google_sheet_logo") return "Google Sheet logo"; + if (key === "environments.integrations.google_sheets.google_sheets_integration_description") + return "Sync responses with Google Sheets."; + if (key === "environments.integrations.create_survey_warning") + return "You need to create a survey first."; + return key; // Return key if no translation is found + }, + }); + return { TolgeeProvider: MockTolgeeProvider, useTranslate }; +}); + +// Mock dependencies +const createOrUpdateIntegrationAction = vi.mocked( + (await import("@/app/(app)/environments/[environmentId]/integrations/actions")) + .createOrUpdateIntegrationAction +); +const getSpreadsheetNameByIdAction = vi.mocked( + (await import("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions")) + .getSpreadsheetNameByIdAction +); +const toast = vi.mocked((await import("react-hot-toast")).default); + +const environmentId = "test-env-id"; +const mockSetOpen = vi.fn(); + +const surveys: TSurvey[] = [ + { + id: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Survey 1", + type: "app", + environmentId: environmentId, + status: "inProgress", + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Question 1?" }, + required: true, + } as unknown as TSurveyQuestion, + { + id: "q2", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Question 2?" }, + required: false, + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "c2", label: { default: "Choice 2" } }, + ], + }, + ], + triggers: [], + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + displayOption: "displayOnce", + displayPercentage: null, + autoComplete: null, + singleUse: null, + styling: null, + surveyClosedMessage: null, + segment: null, + languages: [], + variables: [], + welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], + hiddenFields: { enabled: true, fieldIds: [] }, + pin: null, + displayLimit: null, + } as unknown as TSurvey, + { + id: "survey2", + createdAt: new Date(), + updatedAt: new Date(), + name: "Survey 2", + type: "link", + environmentId: environmentId, + status: "draft", + questions: [ + { + id: "q3", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rate this?" }, + required: true, + scale: "number", + range: 5, + } as unknown as TSurveyQuestion, + ], + triggers: [], + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + displayOption: "displayOnce", + displayPercentage: null, + autoComplete: null, + singleUse: null, + styling: null, + surveyClosedMessage: null, + segment: null, + languages: [], + variables: [], + welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], + hiddenFields: { enabled: true, fieldIds: [] }, + pin: null, + displayLimit: null, + } as unknown as TSurvey, +]; + +const mockGoogleSheetIntegration = { + id: "integration1", + type: "googleSheets", + config: { + key: { + access_token: "mock_access_token", + expiry_date: Date.now() + 3600000, + refresh_token: "mock_refresh_token", + scope: "mock_scope", + token_type: "Bearer", + }, + email: "test@example.com", + data: [], // Initially empty, will be populated in beforeEach + }, +} as unknown as TIntegrationGoogleSheets; + +const mockSelectedIntegration: TIntegrationGoogleSheetsConfigData & { index: number } = { + spreadsheetId: "existing-sheet-id", + spreadsheetName: "Existing Sheet", + surveyId: surveys[0].id, + surveyName: surveys[0].name, + questionIds: [surveys[0].questions[0].id], + questions: "Selected questions", + createdAt: new Date(), + includeVariables: true, + includeHiddenFields: false, + includeMetadata: true, + includeCreatedAt: false, + index: 0, +}; + +describe("AddIntegrationModal", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + beforeEach(() => { + // Reset integration data before each test if needed + mockGoogleSheetIntegration.config.data = [ + { ...mockSelectedIntegration }, // Simulate existing data for update/delete tests + ]; + }); + + test("renders correctly when open (create mode)", () => { + render( + + ); + + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet"); + expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets."); + // Use getByPlaceholderText for the input + expect( + screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/") + ).toBeInTheDocument(); + // Use getByTestId for the dropdown + expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Link Google Sheet" })).toBeInTheDocument(); + expect(screen.queryByText("Delete")).not.toBeInTheDocument(); + expect(screen.queryByText("Questions")).not.toBeInTheDocument(); + }); + + test("renders correctly when open (update mode)", () => { + render( + + ); + + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet"); + expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets."); + // Use getByPlaceholderText for the input + expect( + screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/") + ).toHaveValue("https://docs.google.com/spreadsheets/d/existing-sheet-id"); + expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id); + expect(screen.getByText("Questions")).toBeInTheDocument(); + expect(screen.getByText("Delete")).toBeInTheDocument(); + expect(screen.getByText("Update")).toBeInTheDocument(); + expect(screen.queryByText("Cancel")).not.toBeInTheDocument(); + expect(screen.getByTestId("include-variables")).toBeChecked(); + expect(screen.getByTestId("include-hidden-fields")).not.toBeChecked(); + expect(screen.getByTestId("include-metadata")).toBeChecked(); + expect(screen.getByTestId("include-created-at")).not.toBeChecked(); + }); + + test("selects survey and shows questions", async () => { + render( + + ); + + const surveyDropdown = screen.getByTestId("survey-dropdown"); + await userEvent.selectOptions(surveyDropdown, surveys[1].id); + + expect(screen.getByText("Questions")).toBeInTheDocument(); + surveys[1].questions.forEach((q) => { + expect(screen.getByLabelText(q.headline.default)).toBeInTheDocument(); + // Initially all questions should be checked when a survey is selected in create mode + expect(screen.getByLabelText(q.headline.default)).toBeChecked(); + }); + }); + + test("handles question selection", async () => { + render( + + ); + + const surveyDropdown = screen.getByTestId("survey-dropdown"); + await userEvent.selectOptions(surveyDropdown, surveys[0].id); + + const firstQuestionCheckbox = screen.getByLabelText(surveys[0].questions[0].headline.default); + expect(firstQuestionCheckbox).toBeChecked(); // Initially checked + + await userEvent.click(firstQuestionCheckbox); + expect(firstQuestionCheckbox).not.toBeChecked(); // Unchecked after click + + await userEvent.click(firstQuestionCheckbox); + expect(firstQuestionCheckbox).toBeChecked(); // Checked again + }); + + test("creates integration successfully", async () => { + getSpreadsheetNameByIdAction.mockResolvedValue({ data: "Test Sheet Name" }); + createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any }); // Mock successful action + + render( + + ); + + // Use getByPlaceholderText for the input + const urlInput = screen.getByPlaceholderText( + "https://docs.google.com/spreadsheets/d/" + ); + const surveyDropdown = screen.getByTestId("survey-dropdown"); + const submitButton = screen.getByRole("button", { name: "Link Google Sheet" }); + + await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/new-sheet-id"); + await userEvent.selectOptions(surveyDropdown, surveys[0].id); + + // Wait for questions to appear and potentially uncheck one + const firstQuestionCheckbox = await screen.findByLabelText(surveys[0].questions[0].headline.default); + await userEvent.click(firstQuestionCheckbox); // Uncheck first question + + // Check additional settings + await userEvent.click(screen.getByTestId("include-variables")); + await userEvent.click(screen.getByTestId("include-metadata")); + + await userEvent.click(submitButton); + + await waitFor(() => { + expect(getSpreadsheetNameByIdAction).toHaveBeenCalledWith({ + googleSheetIntegration: expect.any(Object), + environmentId, + spreadsheetId: "new-sheet-id", + }); + }); + + await waitFor(() => { + expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({ + environmentId, + integrationData: expect.objectContaining({ + type: "googleSheets", + config: expect.objectContaining({ + key: mockGoogleSheetIntegration.config.key, + email: mockGoogleSheetIntegration.config.email, + data: expect.arrayContaining([ + expect.objectContaining({ + spreadsheetId: "new-sheet-id", + spreadsheetName: "Test Sheet Name", + surveyId: surveys[0].id, + surveyName: surveys[0].name, + questionIds: surveys[0].questions.slice(1).map((q) => q.id), // Excludes the first question + questions: "Selected questions", + includeVariables: true, + includeHiddenFields: false, + includeMetadata: true, + includeCreatedAt: true, // Default + }), + ]), + }), + }), + }); + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith("Integration added successfully."); + }); + await waitFor(() => { + expect(mockSetOpen).toHaveBeenCalledWith(false); + }); + }); + + test("deletes integration successfully", async () => { + createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any }); + + render( + + ); + + const deleteButton = screen.getByText("Delete"); + await userEvent.click(deleteButton); + + await waitFor(() => { + expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({ + environmentId, + integrationData: expect.objectContaining({ + config: expect.objectContaining({ + data: [], // Data array should be empty after deletion + }), + }), + }); + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith("Integration removed successfully."); + }); + await waitFor(() => { + expect(mockSetOpen).toHaveBeenCalledWith(false); + }); + }); + + test("shows validation error for invalid URL", async () => { + render( + + ); + + // Use getByPlaceholderText for the input + const urlInput = screen.getByPlaceholderText( + "https://docs.google.com/spreadsheets/d/" + ); + const surveyDropdown = screen.getByTestId("survey-dropdown"); + const submitButton = screen.getByRole("button", { name: "Link Google Sheet" }); + + await userEvent.type(urlInput, "invalid-url"); + await userEvent.selectOptions(surveyDropdown, surveys[0].id); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Please enter a valid Google Sheet URL."); + }); + expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled(); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + test("shows validation error if no survey selected", async () => { + render( + + ); + + // Use getByPlaceholderText for the input + const urlInput = screen.getByPlaceholderText( + "https://docs.google.com/spreadsheets/d/" + ); + const submitButton = screen.getByRole("button", { name: "Link Google Sheet" }); + + await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/some-id"); + // No survey selected + await userEvent.click(submitButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Please select a survey."); + }); + expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled(); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + test("shows validation error if no questions selected", async () => { + render( + + ); + + // Use getByPlaceholderText for the input + const urlInput = screen.getByPlaceholderText( + "https://docs.google.com/spreadsheets/d/" + ); + const surveyDropdown = screen.getByTestId("survey-dropdown"); + const submitButton = screen.getByRole("button", { name: "Link Google Sheet" }); + + await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/some-id"); + await userEvent.selectOptions(surveyDropdown, surveys[0].id); + + // Uncheck all questions + for (const question of surveys[0].questions) { + const checkbox = await screen.findByLabelText(question.headline.default); + await userEvent.click(checkbox); + } + + await userEvent.click(submitButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Please select at least one question."); + }); + expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled(); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + test("shows error toast if createOrUpdateIntegrationAction fails", async () => { + const errorMessage = "Failed to update integration"; + getSpreadsheetNameByIdAction.mockResolvedValue({ data: "Some Sheet Name" }); + createOrUpdateIntegrationAction.mockRejectedValue(new Error(errorMessage)); + + render( + + ); + + // Use getByPlaceholderText for the input + const urlInput = screen.getByPlaceholderText( + "https://docs.google.com/spreadsheets/d/" + ); + const surveyDropdown = screen.getByTestId("survey-dropdown"); + const submitButton = screen.getByRole("button", { name: "Link Google Sheet" }); + + await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/another-id"); + await userEvent.selectOptions(surveyDropdown, surveys[0].id); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(getSpreadsheetNameByIdAction).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(createOrUpdateIntegrationAction).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(errorMessage); + }); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + test("calls setOpen(false) and resets form on cancel", async () => { + render( + + ); + + // Use getByPlaceholderText for the input + const urlInput = screen.getByPlaceholderText( + "https://docs.google.com/spreadsheets/d/" + ); + const cancelButton = screen.getByText("Cancel"); + + // Simulate some interaction + await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/temp-id"); + await userEvent.click(cancelButton); + + expect(mockSetOpen).toHaveBeenCalledWith(false); + // Re-render with open=true to check if state was reset (URL should be empty) + cleanup(); + render( + + ); + // Use getByPlaceholderText for the input check after re-render + expect( + screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/") + ).toHaveValue(""); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx index 3c1a01314c96..19b64452693b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx @@ -8,21 +8,29 @@ import { isValidGoogleSheetsUrl, } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util"; import GoogleSheetLogo from "@/images/googleSheetsLogo.png"; +import { getLocalizedValue } from "@/lib/i18n/utils"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { Button } from "@/modules/ui/components/button"; import { Checkbox } from "@/modules/ui/components/checkbox"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { Input } from "@/modules/ui/components/input"; import { Label } from "@/modules/ui/components/label"; -import { Modal } from "@/modules/ui/components/modal"; import { useTranslate } from "@tolgee/react"; import Image from "next/image"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall"; import { TIntegrationGoogleSheets, TIntegrationGoogleSheetsConfigData, @@ -202,31 +210,28 @@ export const AddIntegrationModal = ({ }; return ( - -
-
-
-
-
- {t("environments.integrations.google_sheets.google_sheet_logo")} -
-
-
- {t("environments.integrations.google_sheets.link_google_sheet")} -
-
- {t("environments.integrations.google_sheets.google_sheets_integration_description")} -
-
+ + + +
+
+ {t("environments.integrations.google_sheets.google_sheet_logo")} +
+
+ {t("environments.integrations.google_sheets.link_google_sheet")} + + {t("environments.integrations.google_sheets.google_sheets_integration_description")} +
-
-
-
+ + +
@@ -292,39 +297,37 @@ export const AddIntegrationModal = ({
)}
-
-
-
- {selectedIntegration ? ( - - ) : ( - - )} - -
-
+ ) : ( + + )} + + -
- + + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper.test.tsx new file mode 100644 index 000000000000..b582fe3f8cd5 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper.test.tsx @@ -0,0 +1,175 @@ +import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper"; +import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { + TIntegrationGoogleSheets, + TIntegrationGoogleSheetsCredential, +} from "@formbricks/types/integration/google-sheet"; +import { TSurvey } from "@formbricks/types/surveys/types"; + +// Mock child components and functions +vi.mock( + "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration", + () => ({ + ManageIntegration: vi.fn(({ setOpenAddIntegrationModal }) => ( +
+ +
+ )), + }) +); + +vi.mock("@/modules/ui/components/connect-integration", () => ({ + ConnectIntegration: vi.fn(({ handleAuthorization }) => ( +
+ +
+ )), +})); + +vi.mock( + "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal", + () => ({ + AddIntegrationModal: vi.fn(({ open }) => + open ?
Modal
: null + ), + }) +); + +vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google", () => ({ + authorize: vi.fn(() => Promise.resolve("http://google.com/auth")), +})); + +const mockEnvironment = { + id: "test-env-id", + createdAt: new Date(), + updatedAt: new Date(), + type: "development", + appSetupCompleted: false, +} as unknown as TEnvironment; + +const mockSurveys: TSurvey[] = []; +const mockWebAppUrl = "http://localhost:3000"; +const mockLocale = "en-US"; + +const mockGoogleSheetIntegration = { + id: "test-integration-id", + type: "googleSheets", + config: { + key: { access_token: "test-token" } as unknown as TIntegrationGoogleSheetsCredential, + data: [], + email: "test@example.com", + }, +} as unknown as TIntegrationGoogleSheets; + +describe("GoogleSheetWrapper", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders ConnectIntegration when not connected", () => { + render( + + ); + expect(screen.getByTestId("connect-integration")).toBeInTheDocument(); + expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument(); + expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument(); + }); + + test("renders ConnectIntegration when integration exists but has no key", () => { + const integrationWithoutKey = { + ...mockGoogleSheetIntegration, + config: { data: [], email: "test" }, + } as unknown as TIntegrationGoogleSheets; + render( + + ); + expect(screen.getByTestId("connect-integration")).toBeInTheDocument(); + expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument(); + }); + + test("calls authorize when connect button is clicked", async () => { + const user = userEvent.setup(); + // Mock window.location.replace + const originalLocation = window.location; + // @ts-expect-error + delete window.location; + window.location = { ...originalLocation, replace: vi.fn() } as any; + + render( + + ); + + const connectButton = screen.getByRole("button", { name: "Connect" }); + await user.click(connectButton); + + expect(vi.mocked(authorize)).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl); + // Need to wait for the promise returned by authorize to resolve + await vi.waitFor(() => { + expect(window.location.replace).toHaveBeenCalledWith("http://google.com/auth"); + }); + + // Restore window.location + window.location = originalLocation as any; + }); + + test("renders ManageIntegration and AddIntegrationModal when connected", () => { + render( + + ); + expect(screen.getByTestId("manage-integration")).toBeInTheDocument(); + // Modal is rendered but initially hidden + expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument(); + expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument(); + }); + + test("opens AddIntegrationModal when triggered from ManageIntegration", async () => { + const user = userEvent.setup(); + render( + + ); + + expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument(); + const openModalButton = screen.getByRole("button", { name: "Open Modal" }); // Button inside mocked ManageIntegration + await user.click(openModalButton); + expect(screen.getByTestId("add-integration-modal")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration.test.tsx new file mode 100644 index 000000000000..d77ac85ac8f1 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration.test.tsx @@ -0,0 +1,162 @@ +import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet"; +import { ManageIntegration } from "./ManageIntegration"; + +vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({ + deleteIntegrationAction: vi.fn(), +})); + +vi.mock("react-hot-toast", () => ({ + default: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/modules/ui/components/delete-dialog", () => ({ + DeleteDialog: ({ open, setOpen, onDelete }: any) => + open ? ( +
+ + +
+ ) : null, +})); + +vi.mock("@/modules/ui/components/empty-space-filler", () => ({ + EmptySpaceFiller: ({ emptyMessage }: any) =>
{emptyMessage}
, +})); + +const baseProps = { + environment: { id: "env1" } as TEnvironment, + setOpenAddIntegrationModal: vi.fn(), + setIsConnected: vi.fn(), + setSelectedIntegration: vi.fn(), + locale: "en-US" as const, +} as const; + +describe("ManageIntegration (Google Sheets)", () => { + afterEach(() => { + cleanup(); + }); + + test("empty state", () => { + render( + + ); + + expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument(); + expect(screen.getByText(/link_new_sheet/)).toBeInTheDocument(); + }); + + test("click link new sheet", async () => { + render( + + ); + + await userEvent.click(screen.getByText(/link_new_sheet/)); + + expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null); + expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true); + }); + + test("list integrations and open edit", async () => { + const item = { + spreadsheetId: "sid", + spreadsheetName: "SheetName", + surveyId: "s1", + surveyName: "Survey1", + questionIds: ["q1"], + questions: "Q", + createdAt: new Date(), + }; + + render( + + ); + + expect(screen.getByText("Survey1")).toBeInTheDocument(); + + await userEvent.click(screen.getByText("Survey1")); + + expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({ + ...item, + index: 0, + }); + expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true); + }); + + test("delete integration success", async () => { + vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any); + + render( + + ); + + await userEvent.click(screen.getByText(/delete_integration/)); + expect(screen.getByTestId("delete-dialog")).toBeInTheDocument(); + + await userEvent.click(screen.getByText("confirm")); + + expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" }); + + const { default: toast } = await import("react-hot-toast"); + expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully"); + expect(baseProps.setIsConnected).toHaveBeenCalledWith(false); + }); + + test("delete integration error", async () => { + vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any); + + render( + + ); + + await userEvent.click(screen.getByText(/delete_integration/)); + await userEvent.click(screen.getByText("confirm")); + + const { default: toast } = await import("react-hot-toast"); + expect(toast.error).toHaveBeenCalledWith(expect.any(String)); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration.tsx index 717632c67de6..a1876d3fbd4c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration.tsx @@ -1,6 +1,7 @@ "use client"; import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; +import { timeSince } from "@/lib/time"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { Button } from "@/modules/ui/components/button"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; @@ -9,7 +10,6 @@ import { useTranslate } from "@tolgee/react"; import { Trash2Icon } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; -import { timeSince } from "@formbricks/lib/time"; import { TEnvironment } from "@formbricks/types/environment"; import { TIntegrationGoogleSheets, @@ -36,11 +36,10 @@ export const ManageIntegration = ({ }: ManageIntegrationProps) => { const { t } = useTranslate(); const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false); - const integrationArray = googleSheetIntegration - ? googleSheetIntegration.config.data - ? googleSheetIntegration.config.data - : [] - : []; + let integrationArray: TIntegrationGoogleSheetsConfigData[] = []; + if (googleSheetIntegration?.config.data) { + integrationArray = googleSheetIntegration.config.data; + } const [isDeleting, setisDeleting] = useState(false); const handleDeleteIntegration = async () => { @@ -112,9 +111,9 @@ export const ManageIntegration = ({ {integrationArray && integrationArray.map((data, index) => { return ( -
{ editIntegration(index); }}> @@ -124,7 +123,7 @@ export const ManageIntegration = ({
{timeSince(data.createdAt.toString(), locale)}
-
+ ); })}
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google.test.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google.test.ts new file mode 100644 index 000000000000..46d300398f4a --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; +import { authorize } from "./google"; + +// Mock the logger +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +// Mock fetch +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +describe("authorize", () => { + const environmentId = "test-env-id"; + const apiHost = "http://test.com"; + const expectedUrl = `${apiHost}/api/google-sheet`; + const expectedHeaders = { environmentId: environmentId }; + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should return authUrl on successful fetch", async () => { + const mockAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth?..."; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { authUrl: mockAuthUrl } }), + }); + + const authUrl = await authorize(environmentId, apiHost); + + expect(mockFetch).toHaveBeenCalledWith(expectedUrl, { + method: "GET", + headers: expectedHeaders, + }); + expect(authUrl).toBe(mockAuthUrl); + expect(logger.error).not.toHaveBeenCalled(); + }); + + test("should throw error and log on failed fetch", async () => { + const errorText = "Failed to fetch"; + mockFetch.mockResolvedValueOnce({ + ok: false, + text: async () => errorText, + }); + + await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response"); + + expect(mockFetch).toHaveBeenCalledWith(expectedUrl, { + method: "GET", + headers: expectedHeaders, + }); + expect(logger.error).toHaveBeenCalledWith( + { errorText }, + "authorize: Could not fetch google sheet config" + ); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util.test.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util.test.ts new file mode 100644 index 000000000000..e0edfe3ea50c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; +import { constructGoogleSheetsUrl, extractSpreadsheetIdFromUrl, isValidGoogleSheetsUrl } from "./util"; + +describe("Google Sheets Util", () => { + describe("extractSpreadsheetIdFromUrl", () => { + test("should extract spreadsheet ID from a valid URL", () => { + const url = + "https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq/edit#gid=0"; + const expectedId = "1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq"; + expect(extractSpreadsheetIdFromUrl(url)).toBe(expectedId); + }); + + test("should throw an error for an invalid URL", () => { + const invalidUrl = "https://not-a-google-sheet-url.com"; + expect(() => extractSpreadsheetIdFromUrl(invalidUrl)).toThrow("Invalid Google Sheets URL"); + }); + + test("should throw an error for a URL without an ID", () => { + const urlWithoutId = "https://docs.google.com/spreadsheets/d/"; + expect(() => extractSpreadsheetIdFromUrl(urlWithoutId)).toThrow("Invalid Google Sheets URL"); + }); + }); + + describe("constructGoogleSheetsUrl", () => { + test("should construct a valid Google Sheets URL from a spreadsheet ID", () => { + const spreadsheetId = "1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq"; + const expectedUrl = + "https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq"; + expect(constructGoogleSheetsUrl(spreadsheetId)).toBe(expectedUrl); + }); + }); + + describe("isValidGoogleSheetsUrl", () => { + test("should return true for a valid Google Sheets URL", () => { + const validUrl = + "https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq/edit#gid=0"; + expect(isValidGoogleSheetsUrl(validUrl)).toBe(true); + }); + + test("should return false for an invalid URL", () => { + const invalidUrl = "https://not-a-google-sheet-url.com"; + expect(isValidGoogleSheetsUrl(invalidUrl)).toBe(false); + }); + + test("should return true for a base Google Sheets URL", () => { + const baseUrl = "https://docs.google.com/spreadsheets/d/"; + expect(isValidGoogleSheetsUrl(baseUrl)).toBe(true); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/loading.test.tsx new file mode 100644 index 000000000000..7fd3355e783d --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/loading.test.tsx @@ -0,0 +1,40 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import Loading from "./loading"; + +// Mock the GoBackButton component +vi.mock("@/modules/ui/components/go-back-button", () => ({ + GoBackButton: () =>
GoBackButton
, +})); + +describe("Loading", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the loading state correctly", () => { + render(); + + // Check for GoBackButton mock + expect(screen.getByText("GoBackButton")).toBeInTheDocument(); + + // Check for the disabled button text + expect(screen.getByText("environments.integrations.google_sheets.link_new_sheet")).toBeInTheDocument(); + expect( + screen.getByText("environments.integrations.google_sheets.link_new_sheet").closest("button") + ).toHaveClass("pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none"); + + // Check for table headers + expect(screen.getByText("common.survey")).toBeInTheDocument(); + expect(screen.getByText("environments.integrations.google_sheets.google_sheet_name")).toBeInTheDocument(); + expect(screen.getByText("common.questions")).toBeInTheDocument(); + expect(screen.getByText("common.updated_at")).toBeInTheDocument(); + + // Check for placeholder elements (count based on the loop) + const placeholders = screen.getAllByRole("generic", { hidden: true }); // Using generic role as divs don't have implicit roles + // Calculate expected placeholders: 3 rows * 5 placeholders per row = 15 + // Plus the button, header divs (4), and the main containers + // It's simpler to check if there are *any* pulse animations + expect(placeholders.some((el) => el.classList.contains("animate-pulse"))).toBe(true); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.test.tsx new file mode 100644 index 000000000000..58a3bf39087f --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.test.tsx @@ -0,0 +1,227 @@ +import Page from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/page"; +import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; +import { getIntegrations } from "@/lib/integration/service"; +import { findMatchingLocale } from "@/lib/utils/locale"; +import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth"; +import { cleanup, render, screen } from "@testing-library/react"; +import { redirect } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { + TIntegrationGoogleSheets, + TIntegrationGoogleSheetsCredential, +} from "@formbricks/types/integration/google-sheet"; +import { TSurvey } from "@formbricks/types/surveys/types"; + +// Mock dependencies +vi.mock( + "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper", + () => ({ + GoogleSheetWrapper: vi.fn( + ({ isEnabled, environment, surveys, googleSheetIntegration, webAppUrl, locale }) => ( +
+ Mocked GoogleSheetWrapper + {isEnabled.toString()} + {environment.id} + {surveys?.length ?? 0} + {googleSheetIntegration?.id} + {webAppUrl} + {locale} +
+ ) + ), + }) +); +vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({ + getSurveys: vi.fn(), +})); + +let mockGoogleSheetClientId: string | undefined = "test-client-id"; + +vi.mock("@/lib/constants", () => ({ + get GOOGLE_SHEETS_CLIENT_ID() { + return mockGoogleSheetClientId; + }, + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", + GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret", + GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url", +})); +vi.mock("@/lib/integration/service", () => ({ + getIntegrations: vi.fn(), +})); +vi.mock("@/lib/utils/locale", () => ({ + findMatchingLocale: vi.fn(), +})); +vi.mock("@/modules/environments/lib/utils", () => ({ + getEnvironmentAuth: vi.fn(), +})); +vi.mock("@/modules/ui/components/go-back-button", () => ({ + GoBackButton: vi.fn(({ url }) =>
{url}
), +})); +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: vi.fn(({ children }) =>
{children}
), +})); +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: vi.fn(({ pageTitle }) =>

{pageTitle}

), +})); +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +const mockEnvironment = { + id: "test-env-id", + createdAt: new Date(), + updatedAt: new Date(), + appSetupCompleted: false, + type: "development", +} as unknown as TEnvironment; + +const mockSurveys: TSurvey[] = [ + { + id: "survey1", + name: "Survey 1", + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "test-env-id", + status: "inProgress", + type: "app", + questions: [], + triggers: [], + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + displayOption: "displayOnce", + displayPercentage: null, + languages: [], + pin: null, + segment: null, + singleUse: null, + styling: null, + surveyClosedMessage: null, + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + autoComplete: null, + runOnDate: null, + } as unknown as TSurvey, +]; + +const mockGoogleSheetIntegration = { + id: "integration1", + type: "googleSheets", + config: { + data: [], + key: { + refresh_token: "refresh", + access_token: "access", + expiry_date: Date.now() + 3600000, + } as unknown as TIntegrationGoogleSheetsCredential, + email: "test@example.com", + }, +} as unknown as TIntegrationGoogleSheets; + +const mockProps = { + params: { environmentId: "test-env-id" }, +}; + +describe("GoogleSheetsIntegrationPage", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + beforeEach(() => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + environment: mockEnvironment, + isReadOnly: false, + } as TEnvironmentAuth); + vi.mocked(getSurveys).mockResolvedValue(mockSurveys); + vi.mocked(getIntegrations).mockResolvedValue([mockGoogleSheetIntegration]); + vi.mocked(findMatchingLocale).mockResolvedValue("en-US"); + }); + + test("renders the page with GoogleSheetWrapper when enabled and not read-only", async () => { + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect( + screen.getByText("environments.integrations.google_sheets.google_sheets_integration") + ).toBeInTheDocument(); + expect(screen.getByText("Mocked GoogleSheetWrapper")).toBeInTheDocument(); + expect(screen.getByTestId("isEnabled")).toHaveTextContent("true"); + expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id); + expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString()); + expect(screen.getByTestId("integrationId")).toHaveTextContent(mockGoogleSheetIntegration.id); + expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url"); + expect(screen.getByTestId("locale")).toHaveTextContent("en-US"); + expect(screen.getByTestId("go-back")).toHaveTextContent( + `test-webapp-url/environments/${mockProps.params.environmentId}/integrations` + ); + expect(vi.mocked(redirect)).not.toHaveBeenCalled(); + }); + + test("calls redirect when user is read-only", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + environment: mockEnvironment, + isReadOnly: true, + } as TEnvironmentAuth); + + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect(vi.mocked(redirect)).toHaveBeenCalledWith("./"); + }); + + test("passes isEnabled=false to GoogleSheetWrapper when constants are missing", async () => { + mockGoogleSheetClientId = undefined; + + const { default: PageWithMissingConstants } = (await import( + "@/app/(app)/environments/[environmentId]/integrations/google-sheets/page" + )) as { default: typeof Page }; + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + environment: mockEnvironment, + isReadOnly: false, + } as TEnvironmentAuth); + vi.mocked(getSurveys).mockResolvedValue(mockSurveys); + vi.mocked(getIntegrations).mockResolvedValue([mockGoogleSheetIntegration]); + vi.mocked(findMatchingLocale).mockResolvedValue("en-US"); + + const PageComponent = await PageWithMissingConstants(mockProps); + render(PageComponent); + + expect(screen.getByTestId("isEnabled")).toHaveTextContent("false"); + }); + + test("handles case where no Google Sheet integration exists", async () => { + vi.mocked(getIntegrations).mockResolvedValue([]); // No integrations + + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect(screen.getByText("Mocked GoogleSheetWrapper")).toBeInTheDocument(); + expect(screen.getByTestId("integrationId")).toBeEmptyDOMElement(); // No integration ID passed + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx index 57ac840deb8f..9561d08fc802 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx @@ -1,19 +1,19 @@ import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; +import { + GOOGLE_SHEETS_CLIENT_ID, + GOOGLE_SHEETS_CLIENT_SECRET, + GOOGLE_SHEETS_REDIRECT_URL, + WEBAPP_URL, +} from "@/lib/constants"; +import { getIntegrations } from "@/lib/integration/service"; +import { findMatchingLocale } from "@/lib/utils/locale"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getTranslate } from "@/tolgee/server"; import { redirect } from "next/navigation"; -import { - GOOGLE_SHEETS_CLIENT_ID, - GOOGLE_SHEETS_CLIENT_SECRET, - GOOGLE_SHEETS_REDIRECT_URL, - WEBAPP_URL, -} from "@formbricks/lib/constants"; -import { getIntegrations } from "@formbricks/lib/integration/service"; -import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet"; const Page = async (props) => { diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/lib/surveys.test.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/lib/surveys.test.ts new file mode 100644 index 000000000000..5bab7617756b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/lib/surveys.test.ts @@ -0,0 +1,146 @@ +import { selectSurvey } from "@/lib/survey/service"; +import { transformPrismaSurvey } from "@/lib/survey/utils"; +import { validateInputs } from "@/lib/utils/validate"; +import { Prisma } from "@prisma/client"; +import { describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { getSurveys } from "./surveys"; + +// Mock dependencies +vi.mock("@/lib/survey/service", () => ({ + selectSurvey: { id: true, name: true, status: true, updatedAt: true }, // Expanded mock based on usage +})); +vi.mock("@/lib/survey/utils"); +vi.mock("@/lib/utils/validate"); +vi.mock("@formbricks/database", () => ({ + prisma: { + survey: { + findMany: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + cache: vi.fn((fn) => fn), // Mock reactCache to just return the function + }; +}); + +const environmentId = "test-environment-id"; +// Use 'as any' to bypass complex type matching for mock data +const mockPrismaSurveys = [ + { id: "survey1", name: "Survey 1", status: "inProgress", updatedAt: new Date() }, + { id: "survey2", name: "Survey 2", status: "draft", updatedAt: new Date() }, +] as any; // Use 'as any' to bypass complex type matching +const mockTransformedSurveys: TSurvey[] = [ + { + id: "survey1", + name: "Survey 1", + status: "inProgress", + questions: [], + triggers: [], + recontactDays: null, + displayOption: "displayOnce", + autoClose: null, + delay: 0, + autoComplete: null, + surveyClosedMessage: null, + singleUse: null, + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + hiddenFields: { enabled: false }, + type: "app", // Changed type to web to match original file + environmentId: environmentId, + createdAt: new Date(), + updatedAt: new Date(), + languages: [], + styling: null, + } as unknown as TSurvey, + { + id: "survey2", + name: "Survey 2", + status: "draft", + questions: [], + triggers: [], + recontactDays: null, + displayOption: "displayOnce", + autoClose: null, + delay: 0, + autoComplete: null, + surveyClosedMessage: null, + singleUse: null, + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + hiddenFields: { enabled: false }, + type: "app", + environmentId: environmentId, + createdAt: new Date(), + updatedAt: new Date(), + languages: [], + styling: null, + } as unknown as TSurvey, +]; + +describe("getSurveys", () => { + test("should fetch and transform surveys successfully", async () => { + vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys as any); + vi.mocked(transformPrismaSurvey).mockImplementation((survey) => { + const found = mockTransformedSurveys.find((ts) => ts.id === survey.id); + if (!found) throw new Error("Survey not found in mock transformed data"); + // Ensure the returned object matches the TSurvey structure precisely + return { ...found } as TSurvey; + }); + + const surveys = await getSurveys(environmentId); + + expect(surveys).toEqual(mockTransformedSurveys); + // Use expect.any(ZId) for the Zod schema validation check + expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); // Adjusted expectation + expect(prisma.survey.findMany).toHaveBeenCalledWith({ + where: { + environmentId, + status: { + not: "completed", + }, + }, + select: selectSurvey, + orderBy: { + updatedAt: "desc", + }, + }); + expect(transformPrismaSurvey).toHaveBeenCalledTimes(mockPrismaSurveys.length); + expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[0]); + expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[1]); + // React cache is already mocked globally - no need to check it here + }); + + test("should throw DatabaseError on Prisma known request error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database connection error", { + code: "P2002", + clientVersion: "4.0.0", + }); + + vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(prismaError); + + await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError); + expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys"); + // React cache is already mocked globally - no need to check it here + }); + + test("should throw original error on other errors", async () => { + const genericError = new Error("Some other error"); + + vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(genericError); + + await expect(getSurveys(environmentId)).rejects.toThrow(genericError); + expect(logger.error).not.toHaveBeenCalled(); + // React cache is already mocked globally - no need to check it here + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/lib/surveys.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/lib/surveys.ts index 7e9127267a5e..4a17466db1c4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/lib/surveys.ts +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/lib/surveys.ts @@ -1,49 +1,38 @@ import "server-only"; +import { selectSurvey } from "@/lib/survey/service"; +import { transformPrismaSurvey } from "@/lib/survey/utils"; +import { validateInputs } from "@/lib/utils/validate"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { surveyCache } from "@formbricks/lib/survey/cache"; -import { selectSurvey } from "@formbricks/lib/survey/service"; -import { transformPrismaSurvey } from "@formbricks/lib/survey/utils"; -import { validateInputs } from "@formbricks/lib/utils/validate"; import { logger } from "@formbricks/logger"; import { ZId } from "@formbricks/types/common"; import { DatabaseError } from "@formbricks/types/errors"; import { TSurvey } from "@formbricks/types/surveys/types"; -export const getSurveys = reactCache( - async (environmentId: string): Promise => - cache( - async () => { - validateInputs([environmentId, ZId]); +export const getSurveys = reactCache(async (environmentId: string): Promise => { + validateInputs([environmentId, ZId]); - try { - const surveysPrisma = await prisma.survey.findMany({ - where: { - environmentId, - status: { - not: "completed", - }, - }, - select: selectSurvey, - orderBy: { - updatedAt: "desc", - }, - }); - - return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey(surveyPrisma)); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - logger.error({ error }, "getSurveys: Could not fetch surveys"); - throw new DatabaseError(error.message); - } - throw error; - } + try { + const surveysPrisma = await prisma.survey.findMany({ + where: { + environmentId, + status: { + not: "completed", + }, + }, + select: selectSurvey, + orderBy: { + updatedAt: "desc", }, - [`getSurveys-${environmentId}`], - { - tags: [surveyCache.tag.byEnvironmentId(environmentId)], - } - )() -); + }); + + return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey(surveyPrisma)); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error({ error }, "getSurveys: Could not fetch surveys"); + throw new DatabaseError(error.message); + } + throw error; + } +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/lib/webhook.test.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/lib/webhook.test.ts new file mode 100644 index 000000000000..a0a0d31cc8a7 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/lib/webhook.test.ts @@ -0,0 +1,81 @@ +import { validateInputs } from "@/lib/utils/validate"; +import { Prisma } from "@prisma/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { getWebhookCountBySource } from "./webhook"; + +vi.mock("@/lib/utils/validate"); +vi.mock("@formbricks/database", () => ({ + prisma: { + webhook: { + count: vi.fn(), + }, + }, +})); + +const environmentId = "test-environment-id"; +const sourceZapier = "zapier"; + +describe("getWebhookCountBySource", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + test("should return webhook count for a specific source", async () => { + const mockCount = 5; + vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount); + + const count = await getWebhookCountBySource(environmentId, sourceZapier); + + expect(count).toBe(mockCount); + expect(validateInputs).toHaveBeenCalledWith( + [environmentId, expect.any(Object)], + [sourceZapier, expect.any(Object)] + ); + expect(prisma.webhook.count).toHaveBeenCalledWith({ + where: { + environmentId, + source: sourceZapier, + }, + }); + }); + + test("should return total webhook count when source is undefined", async () => { + const mockCount = 10; + vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount); + + const count = await getWebhookCountBySource(environmentId); + + expect(count).toBe(mockCount); + expect(validateInputs).toHaveBeenCalledWith( + [environmentId, expect.any(Object)], + [undefined, expect.any(Object)] + ); + expect(prisma.webhook.count).toHaveBeenCalledWith({ + where: { + environmentId, + source: undefined, + }, + }); + }); + + test("should throw DatabaseError on Prisma known request error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2025", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.webhook.count).mockRejectedValue(prismaError); + + await expect(getWebhookCountBySource(environmentId, sourceZapier)).rejects.toThrow(DatabaseError); + expect(prisma.webhook.count).toHaveBeenCalledTimes(1); + }); + + test("should throw original error on other errors", async () => { + const genericError = new Error("Something went wrong"); + vi.mocked(prisma.webhook.count).mockRejectedValue(genericError); + + await expect(getWebhookCountBySource(environmentId)).rejects.toThrow(genericError); + expect(prisma.webhook.count).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/lib/webhook.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/lib/webhook.ts index f7b024ed6626..54df0cc2bc07 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/lib/webhook.ts +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/lib/webhook.ts @@ -1,35 +1,29 @@ -import { webhookCache } from "@/lib/cache/webhook"; +import { validateInputs } from "@/lib/utils/validate"; import { Prisma, Webhook } from "@prisma/client"; import { z } from "zod"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { validateInputs } from "@formbricks/lib/utils/validate"; import { ZId } from "@formbricks/types/common"; import { DatabaseError } from "@formbricks/types/errors"; -export const getWebhookCountBySource = (environmentId: string, source?: Webhook["source"]): Promise => - cache( - async () => { - validateInputs([environmentId, ZId], [source, z.string().optional()]); +export const getWebhookCountBySource = async ( + environmentId: string, + source?: Webhook["source"] +): Promise => { + validateInputs([environmentId, ZId], [source, z.string().optional()]); - try { - const count = await prisma.webhook.count({ - where: { - environmentId, - source, - }, - }); - return count; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } - }, - [`getWebhookCountBySource-${environmentId}-${source}`], - { - tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, source)], + try { + const count = await prisma.webhook.count({ + where: { + environmentId, + source, + }, + }); + return count; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )(); + + throw error; + } +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.test.tsx new file mode 100644 index 000000000000..0a29a2a79228 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.test.tsx @@ -0,0 +1,632 @@ +import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + TIntegrationNotion, + TIntegrationNotionConfigData, + TIntegrationNotionCredential, + TIntegrationNotionDatabase, +} from "@formbricks/types/integration/notion"; +import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; + +// Mock actions and utilities +vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({ + createOrUpdateIntegrationAction: vi.fn(), +})); +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: (value: any, _locale: string) => value?.default || "", +})); +vi.mock("@/lib/pollyfills/structuredClone", () => ({ + structuredClone: (obj: any) => JSON.parse(JSON.stringify(obj)), +})); +vi.mock("@/lib/utils/recall", () => ({ + replaceHeadlineRecall: (survey: any) => survey, +})); +vi.mock("@/modules/survey/lib/questions", () => ({ + getQuestionTypes: () => [ + { id: TSurveyQuestionTypeEnum.OpenText, label: "Open Text" }, + { id: TSurveyQuestionTypeEnum.MultipleChoiceSingle, label: "Multiple Choice Single" }, + { id: TSurveyQuestionTypeEnum.Date, label: "Date" }, + ], +})); +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick, loading, variant, type = "button" }: any) => ( + + ), +})); +vi.mock("@/modules/ui/components/dropdown-selector", () => ({ + DropdownSelector: ({ label, items, selectedItem, setSelectedItem, placeholder, disabled }: any) => { + // Ensure the selected item is always available as an option + const allOptions = [...items]; + if (selectedItem && !items.some((item: any) => item.id === selectedItem.id)) { + // Use a simple object structure consistent with how options are likely used + allOptions.push({ id: selectedItem.id, name: selectedItem.name }); + } + // Remove duplicates just in case + const uniqueOptions = Array.from(new Map(allOptions.map((item) => [item.id, item])).values()); + + return ( +
+ {label && } + +
+ ); + }, +})); +vi.mock("@/modules/ui/components/label", () => ({ + Label: ({ children }: { children: React.ReactNode }) => , +})); +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), + DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), + DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +

+ {children} +

+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), + DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), +})); +vi.mock("lucide-react", () => ({ + PlusIcon: () => +, + TrashIcon: () => 🗑️, +})); +vi.mock("next/image", () => ({ + // eslint-disable-next-line @next/next/no-img-element + default: ({ src, alt }: { src: string; alt: string }) => {alt}, +})); +vi.mock("react-hook-form", () => ({ + useForm: () => ({ + handleSubmit: (callback: any) => (event: any) => { + event.preventDefault(); + callback(); + }, + }), +})); +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); +vi.mock("@tolgee/react", async () => { + const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}; + const useTranslate = () => ({ + t: (key: string, params?: any) => { + // NOSONAR + // Simple mock translation function + if (key === "common.warning") return "Warning"; + if (key === "common.metadata") return "Metadata"; + if (key === "common.created_at") return "Created at"; + if (key === "common.hidden_field") return "Hidden Field"; + if (key === "environments.integrations.notion.link_notion_database") return "Link Notion Database"; + if (key === "environments.integrations.notion.sync_responses_with_a_notion_database") + return "Sync responses with a Notion database."; + if (key === "environments.integrations.notion.select_a_database") return "Select a database"; + if (key === "common.select_survey") return "Select survey"; + if (key === "environments.integrations.notion.map_formbricks_fields_to_notion_property") + return "Map Formbricks fields to Notion property"; + if (key === "environments.integrations.notion.select_a_survey_question") + return "Select a survey question"; + if (key === "environments.integrations.notion.select_a_field_to_map") return "Select a field to map"; + if (key === "common.delete") return "Delete"; + if (key === "common.cancel") return "Cancel"; + if (key === "common.update") return "Update"; + if (key === "environments.integrations.notion.please_select_a_database") + return "Please select a database."; + if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey."; + if (key === "environments.integrations.notion.please_select_at_least_one_mapping") + return "Please select at least one mapping."; + if (key === "environments.integrations.notion.please_resolve_mapping_errors") + return "Please resolve mapping errors."; + if (key === "environments.integrations.notion.please_complete_mapping_fields_with_notion_property") + return "Please complete mapping fields."; + if (key === "environments.integrations.integration_updated_successfully") + return "Integration updated successfully."; + if (key === "environments.integrations.integration_added_successfully") + return "Integration added successfully."; + if (key === "environments.integrations.integration_removed_successfully") + return "Integration removed successfully."; + if (key === "environments.integrations.notion.notion_logo") return "Notion logo"; + if (key === "environments.integrations.create_survey_warning") + return "You need to create a survey first."; + if (key === "environments.integrations.notion.create_at_least_one_database_to_setup_this_integration") + return "Create at least one database."; + if (key === "environments.integrations.notion.duplicate_connection_warning") + return "Duplicate connection warning."; + if (key === "environments.integrations.notion.que_name_of_type_cant_be_mapped_to") + return `Question ${params.que_name} (${params.question_label}) can't be mapped to ${params.col_name} (${params.col_type}). Allowed types: ${params.mapped_type}`; + + return key; // Return key if no translation is found + }, + }); + return { TolgeeProvider: MockTolgeeProvider, useTranslate }; +}); + +// Mock dependencies +const createOrUpdateIntegrationAction = vi.mocked( + (await import("@/app/(app)/environments/[environmentId]/integrations/actions")) + .createOrUpdateIntegrationAction +); +const toast = vi.mocked((await import("react-hot-toast")).default); + +const environmentId = "test-env-id"; +const mockSetOpen = vi.fn(); + +const surveys: TSurvey[] = [ + { + id: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Survey 1", + type: "app", + environmentId: environmentId, + status: "inProgress", + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Question 1?" }, + required: true, + } as unknown as TSurveyQuestion, + { + id: "q2", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Question 2?" }, + required: false, + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "c2", label: { default: "Choice 2" } }, + ], + }, + ], + variables: [{ id: "var1", name: "Variable 1" }], + hiddenFields: { enabled: true, fieldIds: ["hf1"] }, + triggers: [], + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + displayOption: "displayOnce", + displayPercentage: null, + autoComplete: null, + singleUse: null, + styling: null, + surveyClosedMessage: null, + segment: null, + languages: [], + welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], + pin: null, + displayLimit: null, + } as unknown as TSurvey, + { + id: "survey2", + createdAt: new Date(), + updatedAt: new Date(), + name: "Survey 2", + type: "link", + environmentId: environmentId, + status: "draft", + questions: [ + { + id: "q3", + type: TSurveyQuestionTypeEnum.Date, + headline: { default: "Date Question?" }, + required: true, + } as unknown as TSurveyQuestion, + ], + variables: [], + hiddenFields: { enabled: false }, + triggers: [], + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + displayOption: "displayOnce", + displayPercentage: null, + autoComplete: null, + singleUse: null, + styling: null, + surveyClosedMessage: null, + segment: null, + languages: [], + welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], + pin: null, + displayLimit: null, + } as unknown as TSurvey, +]; + +const databases: TIntegrationNotionDatabase[] = [ + { + id: "db1", + name: "Database 1 Title", + properties: { + prop1: { id: "p1", name: "Title Prop", type: "title" }, + prop2: { id: "p2", name: "Text Prop", type: "rich_text" }, + prop3: { id: "p3", name: "Number Prop", type: "number" }, + prop4: { id: "p4", name: "Date Prop", type: "date" }, + prop5: { id: "p5", name: "Unsupported Prop", type: "formula" }, // Unsupported + }, + }, + { + id: "db2", + name: "Database 2 Title", + properties: { + propA: { id: "pa", name: "Name", type: "title" }, + propB: { id: "pb", name: "Email", type: "email" }, + }, + }, +]; + +const mockNotionIntegration: TIntegrationNotion = { + id: "integration1", + type: "notion", + environmentId: environmentId, + config: { + key: { + access_token: "token", + bot_id: "bot", + workspace_name: "ws", + workspace_icon: "", + } as unknown as TIntegrationNotionCredential, + data: [], // Initially empty + }, +}; + +const mockSelectedIntegration: TIntegrationNotionConfigData & { index: number } = { + databaseId: databases[0].id, + databaseName: databases[0].name, + surveyId: surveys[0].id, + surveyName: surveys[0].name, + mapping: [ + { + column: { id: "p1", name: "Title Prop", type: "title" }, + question: { id: "q1", name: "Question 1?", type: TSurveyQuestionTypeEnum.OpenText }, + }, + { + column: { id: "p2", name: "Text Prop", type: "rich_text" }, + question: { id: "var1", name: "Variable 1", type: TSurveyQuestionTypeEnum.OpenText }, + }, + ], + createdAt: new Date(), + index: 0, +}; + +describe("AddIntegrationModal (Notion)", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + beforeEach(() => { + // Reset integration data before each test if needed + mockNotionIntegration.config.data = [ + { ...mockSelectedIntegration }, // Simulate existing data for update/delete tests + ]; + }); + + test("renders correctly when open (create mode)", () => { + render( + + ); + + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument(); + expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument(); + expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "environments.integrations.notion.link_database" }) + ).toBeInTheDocument(); + expect(screen.queryByText("Delete")).not.toBeInTheDocument(); + expect(screen.queryByText("Map Formbricks fields to Notion property")).not.toBeInTheDocument(); + }); + + test("renders correctly when open (update mode)", async () => { + render( + + ); + + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id); + expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id); + expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument(); + + // Check if mapping rows are rendered + await waitFor(() => { + const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question"); + const columnDropdowns = screen.getAllByTestId("dropdown-select-a-field-to-map"); + + expect(questionDropdowns).toHaveLength(2); // Expecting two rows based on mockSelectedIntegration + expect(columnDropdowns).toHaveLength(2); + + // Assert values for the first row + expect(questionDropdowns[0]).toHaveValue("q1"); + expect(columnDropdowns[0]).toHaveValue("p1"); + + // Assert values for the second row + expect(questionDropdowns[1]).toHaveValue("var1"); + expect(columnDropdowns[1]).toHaveValue("p2"); + + expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0); + expect(screen.getAllByTestId("trash-icon").length).toBeGreaterThan(0); + }); + + expect(screen.getByText("Delete")).toBeInTheDocument(); + expect(screen.getByText("Update")).toBeInTheDocument(); + expect(screen.queryByText("Cancel")).not.toBeInTheDocument(); + }); + + test("selects database and survey, shows mapping", async () => { + render( + + ); + + const dbDropdown = screen.getByTestId("dropdown-select-a-database"); + const surveyDropdown = screen.getByTestId("dropdown-select-survey"); + + await userEvent.selectOptions(dbDropdown, databases[0].id); + await userEvent.selectOptions(surveyDropdown, surveys[0].id); + + expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument(); + expect(screen.getByTestId("dropdown-select-a-survey-question")).toBeInTheDocument(); + expect(screen.getByTestId("dropdown-select-a-field-to-map")).toBeInTheDocument(); + }); + + test("adds and removes mapping rows", async () => { + render( + + ); + + const dbDropdown = screen.getByTestId("dropdown-select-a-database"); + const surveyDropdown = screen.getByTestId("dropdown-select-survey"); + + await userEvent.selectOptions(dbDropdown, databases[0].id); + await userEvent.selectOptions(surveyDropdown, surveys[0].id); + + expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1); + + const plusButton = screen.getByTestId("plus-icon"); + await userEvent.click(plusButton); + + expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2); + + const trashButton = screen.getAllByTestId("trash-icon")[0]; // Get the first trash button + await userEvent.click(trashButton); + + expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1); + }); + + test("deletes integration successfully", async () => { + createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any }); + + render( + + ); + + const deleteButton = screen.getByText("Delete"); + await userEvent.click(deleteButton); + + await waitFor(() => { + expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({ + environmentId, + integrationData: expect.objectContaining({ + config: expect.objectContaining({ + data: [], // Data array should be empty after deletion + }), + }), + }); + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith("Integration removed successfully."); + }); + await waitFor(() => { + expect(mockSetOpen).toHaveBeenCalledWith(false); + }); + }); + + test("shows validation error if no database selected", async () => { + render( + + ); + await userEvent.selectOptions(screen.getByTestId("dropdown-select-survey"), surveys[0].id); + await userEvent.click( + screen.getByRole("button", { name: "environments.integrations.notion.link_database" }) + ); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Please select a database."); + }); + }); + + test("shows validation error if no survey selected", async () => { + render( + + ); + await userEvent.selectOptions(screen.getByTestId("dropdown-select-a-database"), databases[0].id); + await userEvent.click( + screen.getByRole("button", { name: "environments.integrations.notion.link_database" }) + ); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Please select a survey."); + }); + }); + + test("shows validation error if no mapping defined", async () => { + render( + + ); + await userEvent.selectOptions(screen.getByTestId("dropdown-select-a-database"), databases[0].id); + await userEvent.selectOptions(screen.getByTestId("dropdown-select-survey"), surveys[0].id); + // Default mapping row is empty + await userEvent.click( + screen.getByRole("button", { name: "environments.integrations.notion.link_database" }) + ); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Please select at least one mapping."); + }); + }); + + test("calls setOpen(false) and resets form on cancel", async () => { + render( + + ); + + const dbDropdown = screen.getByTestId("dropdown-select-a-database"); + const cancelButton = screen.getByText("Cancel"); + + await userEvent.selectOptions(dbDropdown, databases[0].id); // Simulate interaction + await userEvent.click(cancelButton); + + expect(mockSetOpen).toHaveBeenCalledWith(false); + // Re-render with open=true to check if state was reset + cleanup(); + render( + + ); + expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(""); // Should be reset + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx index 73fdb91ec81f..321b710560b9 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx @@ -7,20 +7,28 @@ import { UNSUPPORTED_TYPES_BY_NOTION, } from "@/app/(app)/environments/[environmentId]/integrations/notion/constants"; import NotionLogo from "@/images/notion.png"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { structuredClone } from "@/lib/pollyfills/structuredClone"; +import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { getQuestionTypes } from "@/modules/survey/lib/questions"; import { Button } from "@/modules/ui/components/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { Label } from "@/modules/ui/components/label"; -import { Modal } from "@/modules/ui/components/modal"; import { useTranslate } from "@tolgee/react"; -import { PlusIcon, XIcon } from "lucide-react"; +import { PlusIcon, TrashIcon } from "lucide-react"; import Image from "next/image"; import React, { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone"; -import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall"; import { TIntegrationInput } from "@formbricks/types/integration"; import { TIntegrationNotion, @@ -336,9 +344,9 @@ export const AddIntegrationModal = ({ col={mapping[idx].column} ques={mapping[idx].question} /> -
+
-
+
-
+
- - +
+ {mapping.length > 1 && ( + + )} + +
); }; return ( - -
-
-
-
-
- {t("environments.integrations.notion.notion_logo")} -
-
-
- {t("environments.integrations.notion.link_notion_database")} -
-
- {t("environments.integrations.notion.sync_responses_with_a_notion_database")} -
-
+ + + +
+
+ {t("environments.integrations.notion.notion_logo")} +
+
+ {t("environments.integrations.notion.link_notion_database")} + + {t("environments.integrations.notion.notion_integration_description")} +
-
-
-
+ + + +
@@ -521,7 +521,7 @@ export const AddIntegrationModal = ({ -
+
{mapping.map((_, idx) => ( ))} @@ -530,43 +530,40 @@ export const AddIntegrationModal = ({ )}
-
-
-
- {selectedIntegration ? ( - - ) : ( - - )} + + + + {selectedIntegration ? ( -
-
+ ) : ( + + )} + + -
- + + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration.test.tsx new file mode 100644 index 000000000000..0c0c05c0a055 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration.test.tsx @@ -0,0 +1,91 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { + TIntegrationNotion, + TIntegrationNotionConfig, + TIntegrationNotionConfigData, + TIntegrationNotionCredential, +} from "@formbricks/types/integration/notion"; +import { ManageIntegration } from "./ManageIntegration"; + +vi.mock("react-hot-toast", () => ({ success: vi.fn(), error: vi.fn() })); +vi.mock("@/lib/time", () => ({ timeSince: () => "ago" })); +vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({ + deleteIntegrationAction: vi.fn(), +})); + +describe("ManageIntegration", () => { + afterEach(() => { + cleanup(); + }); + + const defaultProps = { + environment: {} as any, + locale: "en-US" as const, + setOpenAddIntegrationModal: vi.fn(), + setIsConnected: vi.fn(), + setSelectedIntegration: vi.fn(), + handleNotionAuthorization: vi.fn(), + }; + + test("shows empty state when no databases", () => { + render( + + ); + expect(screen.getByText("environments.integrations.notion.no_databases_found")).toBeInTheDocument(); + }); + + test("renders list and handles clicks", async () => { + const data = [ + { surveyName: "S", databaseName: "D", createdAt: new Date().toISOString(), databaseId: "db" }, + ] as unknown as TIntegrationNotionConfigData[]; + render( + + ); + expect(screen.getByText("S")).toBeInTheDocument(); + await userEvent.click(screen.getByText("S")); + expect(defaultProps.setSelectedIntegration).toHaveBeenCalledWith({ ...data[0], index: 0 }); + expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled(); + }); + + test("update and link new buttons invoke handlers", async () => { + render( + + ); + await userEvent.click(screen.getByText("environments.integrations.notion.update_connection")); + expect(defaultProps.handleNotionAuthorization).toHaveBeenCalled(); + await userEvent.click(screen.getByText("environments.integrations.notion.link_new_database")); + expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration.tsx index d9a33dc687f8..702cd02c8e10 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration.tsx @@ -1,6 +1,7 @@ "use client"; import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; +import { timeSince } from "@/lib/time"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { Button } from "@/modules/ui/components/button"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; @@ -10,7 +11,6 @@ import { useTranslate } from "@tolgee/react"; import { RefreshCcwIcon, Trash2Icon } from "lucide-react"; import React, { useState } from "react"; import toast from "react-hot-toast"; -import { timeSince } from "@formbricks/lib/time"; import { TEnvironment } from "@formbricks/types/environment"; import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion"; import { TUserLocale } from "@formbricks/types/user"; @@ -39,11 +39,11 @@ export const ManageIntegration = ({ const { t } = useTranslate(); const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false); const [isDeleting, setisDeleting] = useState(false); - const integrationArray = notionIntegration - ? notionIntegration.config.data - ? notionIntegration.config.data - : [] - : []; + + let integrationArray: TIntegrationNotionConfigData[] = []; + if (notionIntegration?.config.data) { + integrationArray = notionIntegration.config.data; + } const handleDeleteIntegration = async () => { setisDeleting(true); @@ -121,9 +121,9 @@ export const ManageIntegration = ({ {integrationArray && integrationArray.map((data, index) => { return ( -
{ editIntegration(index); }}> @@ -132,7 +132,7 @@ export const ManageIntegration = ({
{timeSince(data.createdAt.toString(), locale)}
-
+ ); })}
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper.test.tsx new file mode 100644 index 000000000000..201cf5399b5c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper.test.tsx @@ -0,0 +1,155 @@ +import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TIntegrationNotion, TIntegrationNotionCredential } from "@formbricks/types/integration/notion"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { NotionWrapper } from "./NotionWrapper"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", + GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret", + GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: true, +})); + +// Mock child components +vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration", () => ({ + ManageIntegration: vi.fn(({ setIsConnected }) => ( +
+ +
+ )), +})); +vi.mock("@/modules/ui/components/connect-integration", () => ({ + ConnectIntegration: vi.fn( + ( + { handleAuthorization, isEnabled } // Reverted back to isEnabled + ) => ( +
+ +
+ ) + ), +})); + +// Mock library function +vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion", () => ({ + authorize: vi.fn(), +})); + +// Mock image import +vi.mock("@/images/notion-logo.svg", () => ({ + default: "notion-logo-path", +})); + +// Mock window.location.replace +Object.defineProperty(window, "location", { + value: { + replace: vi.fn(), + }, + writable: true, +}); + +const environmentId = "test-env-id"; +const webAppUrl = "https://app.formbricks.com"; +const environment = { id: environmentId } as TEnvironment; +const surveys: TSurvey[] = []; +const databases = []; +const locale = "en-US" as const; + +const mockNotionIntegration: TIntegrationNotion = { + id: "int-notion-123", + type: "notion", + environmentId: environmentId, + config: { + key: { access_token: "test-token" } as TIntegrationNotionCredential, + data: [], + }, +}; + +const baseProps = { + environment, + surveys, + databasesArray: databases, // Renamed databases to databasesArray to match component prop + webAppUrl, + locale, +}; + +describe("NotionWrapper", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders ConnectIntegration disabled when enabled is false", () => { + // Changed description slightly + render(); // Changed isEnabled to enabled + expect(screen.getByTestId("connect-integration")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled(); + expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument(); + }); + + test("renders ConnectIntegration enabled when enabled is true and not connected (no integration)", () => { + // Changed description slightly + render(); // Changed isEnabled to enabled + expect(screen.getByTestId("connect-integration")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled(); + expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument(); + }); + + test("renders ConnectIntegration enabled when enabled is true and not connected (integration without key)", () => { + // Changed description slightly + const integrationWithoutKey = { + ...mockNotionIntegration, + config: { data: [] }, + } as unknown as TIntegrationNotion; + render(); // Changed isEnabled to enabled + expect(screen.getByTestId("connect-integration")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled(); + expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument(); + }); + + test("calls authorize and redirects when Connect button is clicked", async () => { + const mockAuthorize = vi.mocked(authorize); + const redirectUrl = "https://notion.com/auth"; + mockAuthorize.mockResolvedValue(redirectUrl); + + render(); // Changed isEnabled to enabled + + const connectButton = screen.getByRole("button", { name: "Connect" }); + await userEvent.click(connectButton); + + expect(mockAuthorize).toHaveBeenCalledWith(environmentId, webAppUrl); + await waitFor(() => { + expect(window.location.replace).toHaveBeenCalledWith(redirectUrl); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/constants.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/constants.ts index 5f24b5fd24ba..a2ef63ba6a71 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/constants.ts +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/constants.ts @@ -25,6 +25,8 @@ export const TYPE_MAPPING = { [TSurveyQuestionTypeEnum.Address]: ["rich_text"], [TSurveyQuestionTypeEnum.Matrix]: ["rich_text"], [TSurveyQuestionTypeEnum.Cal]: ["checkbox"], + [TSurveyQuestionTypeEnum.ContactInfo]: ["rich_text"], + [TSurveyQuestionTypeEnum.Ranking]: ["rich_text"], }; export const UNSUPPORTED_TYPES_BY_NOTION = [ diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/lib/notion.test.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/lib/notion.test.ts new file mode 100644 index 000000000000..e4795f68a323 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/lib/notion.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; +import { authorize } from "./notion"; + +// Mock the logger +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +// Mock fetch +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +describe("authorize", () => { + const environmentId = "test-env-id"; + const apiHost = "http://test.com"; + const expectedUrl = `${apiHost}/api/v1/integrations/notion`; + const expectedHeaders = { environmentId: environmentId }; + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should return authUrl on successful fetch", async () => { + const mockAuthUrl = "https://api.notion.com/v1/oauth/authorize?..."; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { authUrl: mockAuthUrl } }), + }); + + const authUrl = await authorize(environmentId, apiHost); + + expect(mockFetch).toHaveBeenCalledWith(expectedUrl, { + method: "GET", + headers: expectedHeaders, + }); + expect(authUrl).toBe(mockAuthUrl); + expect(logger.error).not.toHaveBeenCalled(); + }); + + test("should throw error and log on failed fetch", async () => { + const errorText = "Failed to fetch"; + mockFetch.mockResolvedValueOnce({ + ok: false, + text: async () => errorText, + }); + + await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response"); + + expect(mockFetch).toHaveBeenCalledWith(expectedUrl, { + method: "GET", + headers: expectedHeaders, + }); + expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch notion config"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/loading.test.tsx new file mode 100644 index 000000000000..f15aa6990116 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/loading.test.tsx @@ -0,0 +1,50 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import Loading from "./loading"; + +// Mock child components +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, className }: { children: React.ReactNode; className: string }) => ( + + ), +})); +vi.mock("@/modules/ui/components/go-back-button", () => ({ + GoBackButton: () =>
Go Back
, +})); + +// Mock @tolgee/react +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, // Simple mock translation + }), +})); + +describe("Notion Integration Loading", () => { + afterEach(() => { + cleanup(); + }); + + test("renders loading state correctly", () => { + render(); + + // Check for GoBackButton mock + expect(screen.getByTestId("go-back-button")).toBeInTheDocument(); + + // Check for the disabled button + const linkButton = screen.getByText("environments.integrations.notion.link_database"); + expect(linkButton).toBeInTheDocument(); + expect(linkButton.closest("button")).toHaveClass( + "pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200" + ); + + // Check for table headers + expect(screen.getByText("common.survey")).toBeInTheDocument(); + expect(screen.getByText("environments.integrations.notion.database_name")).toBeInTheDocument(); + expect(screen.getByText("common.updated_at")).toBeInTheDocument(); + + // Check for placeholder elements (skeleton loaders) + // There should be 3 rows * 5 pulse divs per row = 15 pulse divs + const pulseDivs = screen.getAllByText("", { selector: "div.animate-pulse" }); + expect(pulseDivs.length).toBeGreaterThanOrEqual(15); // Check if at least 15 pulse divs are rendered + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.test.tsx new file mode 100644 index 000000000000..a8947ba06752 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.test.tsx @@ -0,0 +1,249 @@ +import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; +import Page from "@/app/(app)/environments/[environmentId]/integrations/notion/page"; +import { getIntegrationByType } from "@/lib/integration/service"; +import { getNotionDatabases } from "@/lib/notion/service"; +import { findMatchingLocale } from "@/lib/utils/locale"; +import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth"; +import { cleanup, render, screen } from "@testing-library/react"; +import { redirect } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion"; +import { TSurvey } from "@formbricks/types/surveys/types"; + +// Mock dependencies +vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper", () => ({ + NotionWrapper: vi.fn( + ({ enabled, environment, surveys, notionIntegration, webAppUrl, databasesArray, locale }) => ( +
+ Mocked NotionWrapper + {enabled.toString()} + {environment.id} + {surveys?.length ?? 0} + {notionIntegration?.id} + {webAppUrl} + {databasesArray?.length ?? 0} + {locale} +
+ ) + ), +})); +vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({ + getSurveys: vi.fn(), +})); + +let mockNotionClientId: string | undefined = "test-client-id"; +let mockNotionClientSecret: string | undefined = "test-client-secret"; +let mockNotionAuthUrl: string | undefined = "https://notion.com/auth"; +let mockNotionRedirectUri: string | undefined = "https://app.formbricks.com/redirect"; + +vi.mock("@/lib/constants", () => ({ + get NOTION_OAUTH_CLIENT_ID() { + return mockNotionClientId; + }, + get NOTION_OAUTH_CLIENT_SECRET() { + return mockNotionClientSecret; + }, + get NOTION_AUTH_URL() { + return mockNotionAuthUrl; + }, + get NOTION_REDIRECT_URI() { + return mockNotionRedirectUri; + }, + WEBAPP_URL: "test-webapp-url", + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", +})); +vi.mock("@/lib/integration/service", () => ({ + getIntegrationByType: vi.fn(), +})); +vi.mock("@/lib/notion/service", () => ({ + getNotionDatabases: vi.fn(), +})); +vi.mock("@/lib/utils/locale", () => ({ + findMatchingLocale: vi.fn(), +})); +vi.mock("@/modules/environments/lib/utils", () => ({ + getEnvironmentAuth: vi.fn(), +})); +vi.mock("@/modules/ui/components/go-back-button", () => ({ + GoBackButton: vi.fn(({ url }) =>
{url}
), +})); +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: vi.fn(({ children }) =>
{children}
), +})); +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: vi.fn(({ pageTitle }) =>

{pageTitle}

), +})); +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +const mockEnvironment = { + id: "test-env-id", + createdAt: new Date(), + updatedAt: new Date(), + appSetupCompleted: false, + type: "development", +} as unknown as TEnvironment; + +const mockSurveys: TSurvey[] = [ + { + id: "survey1", + name: "Survey 1", + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "test-env-id", + status: "inProgress", + type: "app", + questions: [], + triggers: [], + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + displayOption: "displayOnce", + displayPercentage: null, + languages: [], + pin: null, + segment: null, + singleUse: null, + styling: null, + surveyClosedMessage: null, + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + autoComplete: null, + runOnDate: null, + } as unknown as TSurvey, +]; + +const mockNotionIntegration = { + id: "integration1", + type: "notion", + config: { + data: [], + key: { bot_id: "bot-id-123" }, + email: "test@example.com", + }, +} as unknown as TIntegrationNotion; + +const mockDatabases: TIntegrationNotionDatabase[] = [ + { id: "db1", name: "Database 1", properties: {} }, + { id: "db2", name: "Database 2", properties: {} }, +]; + +const mockProps = { + params: { environmentId: "test-env-id" }, +}; + +describe("NotionIntegrationPage", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + beforeEach(() => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + environment: mockEnvironment, + isReadOnly: false, + } as TEnvironmentAuth); + vi.mocked(getSurveys).mockResolvedValue(mockSurveys); + vi.mocked(getIntegrationByType).mockResolvedValue(mockNotionIntegration); + vi.mocked(getNotionDatabases).mockResolvedValue(mockDatabases); + vi.mocked(findMatchingLocale).mockResolvedValue("en-US"); + mockNotionClientId = "test-client-id"; + mockNotionClientSecret = "test-client-secret"; + mockNotionAuthUrl = "https://notion.com/auth"; + mockNotionRedirectUri = "https://app.formbricks.com/redirect"; + }); + + test("renders the page with NotionWrapper when enabled and not read-only", async () => { + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect(screen.getByText("environments.integrations.notion.notion_integration")).toBeInTheDocument(); + expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument(); + expect(screen.getByTestId("enabled")).toHaveTextContent("true"); + expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id); + expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString()); + expect(screen.getByTestId("integrationId")).toHaveTextContent(mockNotionIntegration.id); + expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url"); + expect(screen.getByTestId("databaseCount")).toHaveTextContent(mockDatabases.length.toString()); + expect(screen.getByTestId("locale")).toHaveTextContent("en-US"); + expect(screen.getByTestId("go-back")).toHaveTextContent( + `test-webapp-url/environments/${mockProps.params.environmentId}/integrations` + ); + expect(vi.mocked(redirect)).not.toHaveBeenCalled(); + expect(vi.mocked(getNotionDatabases)).toHaveBeenCalledWith(mockEnvironment.id); + }); + + test("calls redirect when user is read-only", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + environment: mockEnvironment, + isReadOnly: true, + } as TEnvironmentAuth); + + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect(vi.mocked(redirect)).toHaveBeenCalledWith("./"); + }); + + test("passes enabled=false to NotionWrapper when constants are missing", async () => { + mockNotionClientId = undefined; // Simulate missing constant + + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect(screen.getByTestId("enabled")).toHaveTextContent("false"); + }); + + test("handles case where no Notion integration exists", async () => { + vi.mocked(getIntegrationByType).mockResolvedValue(null); // No integration + + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument(); + expect(screen.getByTestId("integrationId")).toBeEmptyDOMElement(); // No integration ID passed + expect(screen.getByTestId("databaseCount")).toHaveTextContent("0"); // No databases fetched + expect(vi.mocked(getNotionDatabases)).not.toHaveBeenCalled(); + }); + + test("handles case where integration exists but has no key (bot_id)", async () => { + const integrationWithoutKey = { + ...mockNotionIntegration, + config: { ...mockNotionIntegration.config, key: undefined }, + } as unknown as TIntegrationNotion; + vi.mocked(getIntegrationByType).mockResolvedValue(integrationWithoutKey); + + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument(); + expect(screen.getByTestId("integrationId")).toHaveTextContent(integrationWithoutKey.id); + expect(screen.getByTestId("databaseCount")).toHaveTextContent("0"); // No databases fetched + expect(vi.mocked(getNotionDatabases)).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx index e021be1d4503..9a5a296cad38 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx @@ -1,21 +1,21 @@ import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper"; -import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; -import { GoBackButton } from "@/modules/ui/components/go-back-button"; -import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; -import { PageHeader } from "@/modules/ui/components/page-header"; -import { getTranslate } from "@/tolgee/server"; -import { redirect } from "next/navigation"; import { NOTION_AUTH_URL, NOTION_OAUTH_CLIENT_ID, NOTION_OAUTH_CLIENT_SECRET, NOTION_REDIRECT_URI, WEBAPP_URL, -} from "@formbricks/lib/constants"; -import { getIntegrationByType } from "@formbricks/lib/integration/service"; -import { getNotionDatabases } from "@formbricks/lib/notion/service"; -import { findMatchingLocale } from "@formbricks/lib/utils/locale"; +} from "@/lib/constants"; +import { getIntegrationByType } from "@/lib/integration/service"; +import { getNotionDatabases } from "@/lib/notion/service"; +import { findMatchingLocale } from "@/lib/utils/locale"; +import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { GoBackButton } from "@/modules/ui/components/go-back-button"; +import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; +import { PageHeader } from "@/modules/ui/components/page-header"; +import { getTranslate } from "@/tolgee/server"; +import { redirect } from "next/navigation"; import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion"; const Page = async (props) => { diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/page.test.tsx new file mode 100644 index 000000000000..1e05ca154472 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/page.test.tsx @@ -0,0 +1,243 @@ +import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/integrations/lib/webhook"; +import Page from "@/app/(app)/environments/[environmentId]/integrations/page"; +import { getIntegrations } from "@/lib/integration/service"; +import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth"; +import { cleanup, render, screen } from "@testing-library/react"; +import { redirect } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TIntegration } from "@formbricks/types/integration"; + +// Mock dependencies +vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/webhook", () => ({ + getWebhookCountBySource: vi.fn(), +})); + +vi.mock("@/lib/integration/service", () => ({ + getIntegrations: vi.fn(), +})); + +vi.mock("@/modules/environments/lib/utils", () => ({ + getEnvironmentAuth: vi.fn(), +})); + +vi.mock("@/modules/ui/components/integration-card", () => ({ + Card: ({ label, description, statusText, disabled }) => ( +
+

{label}

+

{description}

+ {statusText} + {disabled && Disabled} +
+ ), +})); + +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: ({ children }) =>
{children}
, +})); + +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: ({ pageTitle }) =>

{pageTitle}

, +})); + +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); + +vi.mock("next/image", () => ({ + // eslint-disable-next-line @next/next/no-img-element + default: ({ alt }) => {alt}, +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +const mockEnvironment = { + id: "test-env-id", + createdAt: new Date(), + updatedAt: new Date(), + type: "development", + appSetupCompleted: true, +} as unknown as TEnvironment; + +const mockIntegrations: TIntegration[] = [ + { + id: "google-sheets-id", + type: "googleSheets", + environmentId: "test-env-id", + config: { data: [], email: "test@example.com" } as unknown as TIntegration["config"], + }, + { + id: "slack-id", + type: "slack", + environmentId: "test-env-id", + config: { data: [] } as unknown as TIntegration["config"], + }, +]; + +const mockParams = { environmentId: "test-env-id" }; +const mockProps = { params: mockParams }; + +describe("Integrations Page", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + beforeEach(() => { + vi.mocked(getWebhookCountBySource).mockResolvedValue(0); + vi.mocked(getIntegrations).mockResolvedValue([]); + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + environment: mockEnvironment, + isReadOnly: false, + isBilling: false, + } as unknown as TEnvironmentAuth); + }); + + test("renders the page header and integration cards", async () => { + vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => { + if (source === "zapier") return 1; + if (source === "user") return 2; + return 0; + }); + vi.mocked(getIntegrations).mockResolvedValue(mockIntegrations); + + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect(screen.getByText("common.integrations")).toBeInTheDocument(); // Page Header + expect(screen.getByTestId("card-Javascript SDK")).toBeInTheDocument(); + expect( + screen.getByText("environments.integrations.website_or_app_integration_description") + ).toBeInTheDocument(); + expect(screen.getAllByText("common.connected")[0]).toBeInTheDocument(); // JS SDK status + + expect(screen.getByTestId("card-Zapier")).toBeInTheDocument(); + expect(screen.getByText("environments.integrations.zapier_integration_description")).toBeInTheDocument(); + expect(screen.getByText("1 zap")).toBeInTheDocument(); // Zapier status + + expect(screen.getByTestId("card-Webhooks")).toBeInTheDocument(); + expect(screen.getByText("environments.integrations.webhook_integration_description")).toBeInTheDocument(); + expect(screen.getByText("2 webhooks")).toBeInTheDocument(); // Webhook status + + expect(screen.getByTestId("card-Google Sheets")).toBeInTheDocument(); + expect( + screen.getByText("environments.integrations.google_sheet_integration_description") + ).toBeInTheDocument(); + expect(screen.getAllByText("common.connected")[1]).toBeInTheDocument(); // Google Sheets status + + expect(screen.getByTestId("card-Airtable")).toBeInTheDocument(); + expect( + screen.getByText("environments.integrations.airtable_integration_description") + ).toBeInTheDocument(); + expect(screen.getAllByText("common.not_connected")[0]).toBeInTheDocument(); // Airtable status + + expect(screen.getByTestId("card-Slack")).toBeInTheDocument(); + expect(screen.getByText("environments.integrations.slack_integration_description")).toBeInTheDocument(); + expect(screen.getAllByText("common.connected")[2]).toBeInTheDocument(); // Slack status + + expect(screen.getByTestId("card-n8n")).toBeInTheDocument(); + expect(screen.getByText("environments.integrations.n8n_integration_description")).toBeInTheDocument(); + expect(screen.getAllByText("common.not_connected")[1]).toBeInTheDocument(); // n8n status + + expect(screen.getByTestId("card-Make.com")).toBeInTheDocument(); + expect(screen.getByText("environments.integrations.make_integration_description")).toBeInTheDocument(); + expect(screen.getAllByText("common.not_connected")[2]).toBeInTheDocument(); // Make status + + expect(screen.getByTestId("card-Notion")).toBeInTheDocument(); + expect(screen.getByText("environments.integrations.notion_integration_description")).toBeInTheDocument(); + expect(screen.getAllByText("common.not_connected")[3]).toBeInTheDocument(); // Notion status + + expect(screen.getByTestId("card-Activepieces")).toBeInTheDocument(); + expect( + screen.getByText("environments.integrations.activepieces_integration_description") + ).toBeInTheDocument(); + expect(screen.getAllByText("common.not_connected")[4]).toBeInTheDocument(); // Activepieces status + }); + + test("renders disabled cards when isReadOnly is true", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + environment: mockEnvironment, + isReadOnly: true, + isBilling: false, + } as unknown as TEnvironmentAuth); + + const PageComponent = await Page(mockProps); + render(PageComponent); + + // JS SDK and Webhooks should not be disabled + expect(screen.getByTestId("card-Javascript SDK")).not.toHaveTextContent("Disabled"); + expect(screen.getByTestId("card-Webhooks")).not.toHaveTextContent("Disabled"); + + // Other cards should be disabled + expect(screen.getByTestId("card-Zapier")).toHaveTextContent("Disabled"); + expect(screen.getByTestId("card-Google Sheets")).toHaveTextContent("Disabled"); + expect(screen.getByTestId("card-Airtable")).toHaveTextContent("Disabled"); + expect(screen.getByTestId("card-Slack")).toHaveTextContent("Disabled"); + expect(screen.getByTestId("card-n8n")).toHaveTextContent("Disabled"); + expect(screen.getByTestId("card-Make.com")).toHaveTextContent("Disabled"); + expect(screen.getByTestId("card-Notion")).toHaveTextContent("Disabled"); + expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("Disabled"); + }); + + test("redirects when isBilling is true", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + environment: mockEnvironment, + isReadOnly: false, + isBilling: true, + } as unknown as TEnvironmentAuth); + + await Page(mockProps); + + expect(vi.mocked(redirect)).toHaveBeenCalledWith( + `/environments/${mockParams.environmentId}/settings/billing` + ); + }); + + test("renders correct status text for single integration", async () => { + vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => { + if (source === "n8n") return 1; + if (source === "make") return 1; + if (source === "activepieces") return 1; + return 0; + }); + + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect(screen.getByTestId("card-n8n")).toHaveTextContent("1 common.integration"); + expect(screen.getByTestId("card-Make.com")).toHaveTextContent("1 common.integration"); + expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("1 common.integration"); + }); + + test("renders correct status text for multiple integrations", async () => { + vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => { + if (source === "n8n") return 3; + if (source === "make") return 4; + if (source === "activepieces") return 5; + return 0; + }); + + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect(screen.getByTestId("card-n8n")).toHaveTextContent("3 common.integrations"); + expect(screen.getByTestId("card-Make.com")).toHaveTextContent("4 common.integrations"); + expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("5 common.integrations"); + }); + + test("renders not connected status when widgetSetupCompleted is false", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + environment: { ...mockEnvironment, appSetupCompleted: false }, + isReadOnly: false, + isBilling: false, + } as unknown as TEnvironmentAuth); + + const PageComponent = await Page(mockProps); + render(PageComponent); + + expect(screen.getByTestId("card-Javascript SDK")).toHaveTextContent("common.not_connected"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx index 49ff5836a744..4a3715fab102 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/page.tsx @@ -9,6 +9,7 @@ import notionLogo from "@/images/notion.png"; import SlackLogo from "@/images/slacklogo.png"; import WebhookLogo from "@/images/webhook.png"; import ZapierLogo from "@/images/zapier-small.png"; +import { getIntegrations } from "@/lib/integration/service"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { Card } from "@/modules/ui/components/integration-card"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; @@ -16,7 +17,6 @@ import { PageHeader } from "@/modules/ui/components/page-header"; import { getTranslate } from "@/tolgee/server"; import Image from "next/image"; import { redirect } from "next/navigation"; -import { getIntegrations } from "@formbricks/lib/integration/service"; import { TIntegrationType } from "@formbricks/types/integration"; const Page = async (props) => { diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/actions.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/actions.ts index 708a156fa973..cd2cbf1248dc 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/actions.ts @@ -1,10 +1,10 @@ "use server"; +import { getSlackChannels } from "@/lib/slack/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper"; import { z } from "zod"; -import { getSlackChannels } from "@formbricks/lib/slack/service"; import { ZId } from "@formbricks/types/common"; const ZGetSlackChannelsAction = z.object({ diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.test.tsx new file mode 100644 index 000000000000..156509757cb0 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.test.tsx @@ -0,0 +1,763 @@ +import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TIntegrationItem } from "@formbricks/types/integration"; +import { + TIntegrationSlack, + TIntegrationSlackConfigData, + TIntegrationSlackCredential, +} from "@formbricks/types/integration/slack"; +import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { AddChannelMappingModal } from "./AddChannelMappingModal"; + +// Mock dependencies +vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({ + createOrUpdateIntegrationAction: vi.fn(), +})); +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: (value: any, _locale: string) => value?.default || "", +})); +vi.mock("@/lib/utils/recall", () => ({ + replaceHeadlineRecall: (survey: any) => survey, +})); +vi.mock("@/modules/ui/components/additional-integration-settings", () => ({ + AdditionalIntegrationSettings: ({ + includeVariables, + setIncludeVariables, + includeHiddenFields, + setIncludeHiddenFields, + includeMetadata, + setIncludeMetadata, + includeCreatedAt, + setIncludeCreatedAt, + }: any) => ( +
+ Additional Settings + setIncludeVariables(e.target.checked)} + /> + setIncludeHiddenFields(e.target.checked)} + /> + setIncludeMetadata(e.target.checked)} + /> + setIncludeCreatedAt(e.target.checked)} + /> +
+ ), +})); +vi.mock("@/modules/ui/components/dropdown-selector", () => ({ + DropdownSelector: ({ label, items, selectedItem, setSelectedItem, disabled }: any) => ( +
+ + +
+ ), +})); +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: ({ children, open, onOpenChange }: any) => + open ? ( +
+ {children} + +
+ ) : null, + DialogContent: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>

{children}

, + DialogBody: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, +})); +vi.mock("next/image", () => ({ + // eslint-disable-next-line @next/next/no-img-element + default: ({ src, alt }: { src: string; alt: string }) => {alt}, +})); +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: any) => ( + + {children} + + ), +})); +vi.mock("react-hook-form", () => ({ + useForm: () => ({ + handleSubmit: (callback: any) => (event: any) => { + event.preventDefault(); + callback(); + }, + }), +})); +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); +vi.mock("@tolgee/react", async () => { + const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}; + const useTranslate = () => ({ + t: (key: string, _?: any) => { + // NOSONAR + // Simple mock translation function + if (key === "common.all_questions") return "All questions"; + if (key === "common.selected_questions") return "Selected questions"; + if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel"; + if (key === "environments.integrations.slack.slack_integration_description") + return "Send responses directly to Slack."; + if (key === "common.update") return "Update"; + if (key === "common.delete") return "Delete"; + if (key === "common.cancel") return "Cancel"; + if (key === "environments.integrations.slack.select_channel") return "Select channel"; + if (key === "common.select_survey") return "Select survey"; + if (key === "common.questions") return "Questions"; + if (key === "environments.integrations.slack.please_select_a_channel") + return "Please select a channel."; + if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey."; + if (key === "environments.integrations.select_at_least_one_question_error") + return "Please select at least one question."; + if (key === "environments.integrations.integration_updated_successfully") + return "Integration updated successfully."; + if (key === "environments.integrations.integration_added_successfully") + return "Integration added successfully."; + if (key === "environments.integrations.integration_removed_successfully") + return "Integration removed successfully."; + if (key === "environments.integrations.slack.dont_see_your_channel") return "Don't see your channel?"; + if (key === "common.note") return "Note"; + if (key === "environments.integrations.slack.already_connected_another_survey") + return "This channel is already connected to another survey."; + if (key === "environments.integrations.slack.create_at_least_one_channel_error") + return "Please create at least one channel in Slack first."; + if (key === "environments.integrations.create_survey_warning") + return "You need to create a survey first."; + if (key === "environments.integrations.slack.link_channel") return "Link Channel"; + return key; // Return key if no translation is found + }, + }); + return { TolgeeProvider: MockTolgeeProvider, useTranslate }; +}); +vi.mock("lucide-react", () => ({ + CircleHelpIcon: () =>
, + Check: () =>
, // Add the Check icon mock + Loader2: () =>
, // Add the Loader2 icon mock +})); + +// Mock dependencies +const createOrUpdateIntegrationActionMock = vi.mocked(createOrUpdateIntegrationAction); +const toast = vi.mocked((await import("react-hot-toast")).default); + +const environmentId = "test-env-id"; +const mockSetOpen = vi.fn(); + +const surveys: TSurvey[] = [ + { + id: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Survey 1", + type: "app", + environmentId: environmentId, + status: "inProgress", + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Question 1?" }, + required: true, + } as unknown as TSurveyQuestion, + { + id: "q2", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Question 2?" }, + required: false, + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "c2", label: { default: "Choice 2" } }, + ], + }, + ], + triggers: [], + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + displayOption: "displayOnce", + displayPercentage: null, + autoComplete: null, + singleUse: null, + styling: null, + surveyClosedMessage: null, + segment: null, + languages: [], + variables: [], + welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], + hiddenFields: { enabled: true, fieldIds: [] }, + pin: null, + displayLimit: null, + } as unknown as TSurvey, + { + id: "survey2", + createdAt: new Date(), + updatedAt: new Date(), + name: "Survey 2", + type: "link", + environmentId: environmentId, + status: "draft", + questions: [ + { + id: "q3", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rate this?" }, + required: true, + scale: "number", + range: 5, + } as unknown as TSurveyQuestion, + ], + triggers: [], + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + displayOption: "displayOnce", + displayPercentage: null, + autoComplete: null, + singleUse: null, + styling: null, + surveyClosedMessage: null, + segment: null, + languages: [], + variables: [], + welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], + hiddenFields: { enabled: true, fieldIds: [] }, + pin: null, + displayLimit: null, + } as unknown as TSurvey, +]; + +const channels: TIntegrationItem[] = [ + { id: "channel1", name: "#general" }, + { id: "channel2", name: "#random" }, +]; + +const mockSlackIntegration: TIntegrationSlack = { + id: "integration1", + type: "slack", + environmentId: environmentId, + config: { + key: { + access_token: "xoxb-test-token", + team_name: "Test Team", + team_id: "T123", + } as unknown as TIntegrationSlackCredential, + data: [], // Initially empty + }, +}; + +const mockSelectedIntegration: TIntegrationSlackConfigData & { index: number } = { + channelId: channels[0].id, + channelName: channels[0].name, + surveyId: surveys[0].id, + surveyName: surveys[0].name, + questionIds: [surveys[0].questions[0].id], + questions: "Selected questions", + createdAt: new Date(), + includeVariables: true, + includeHiddenFields: false, + includeMetadata: true, + includeCreatedAt: false, + index: 0, +}; + +describe("AddChannelMappingModal", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + beforeEach(() => { + // Reset integration data before each test if needed + mockSlackIntegration.config.data = [ + { ...mockSelectedIntegration }, // Simulate existing data for update/delete tests + ]; + }); + + test("renders correctly when open (create mode)", () => { + render( + + ); + + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel"); + expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack."); + expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument(); + expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Link Channel" })).toBeInTheDocument(); + expect(screen.queryByText("Delete")).not.toBeInTheDocument(); + expect(screen.queryByText("Questions")).not.toBeInTheDocument(); + expect(screen.getByTestId("circle-help-icon")).toBeInTheDocument(); + expect(screen.getByText("Don't see your channel?")).toBeInTheDocument(); + }); + + test("renders correctly when open (update mode)", () => { + render( + + ); + + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel"); + expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack."); + expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id); + expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id); + expect(screen.getByText("Questions")).toBeInTheDocument(); + expect(screen.getByText("Delete")).toBeInTheDocument(); + expect(screen.getByText("Update")).toBeInTheDocument(); + expect(screen.queryByText("Cancel")).not.toBeInTheDocument(); + expect(screen.getByTestId("include-variables")).toBeChecked(); + expect(screen.getByTestId("include-hidden-fields")).not.toBeChecked(); + expect(screen.getByTestId("include-metadata")).toBeChecked(); + expect(screen.getByTestId("include-created-at")).not.toBeChecked(); + }); + + test("selects survey and shows questions", async () => { + render( + + ); + + const surveyDropdown = screen.getByTestId("survey-dropdown"); + await userEvent.selectOptions(surveyDropdown, surveys[1].id); + + expect(screen.getByText("Questions")).toBeInTheDocument(); + surveys[1].questions.forEach((q) => { + expect(screen.getByLabelText(q.headline.default)).toBeInTheDocument(); + // Initially all questions should be checked when a survey is selected in create mode + expect(screen.getByLabelText(q.headline.default)).toBeChecked(); + }); + }); + + test("handles question selection", async () => { + render( + + ); + + const surveyDropdown = screen.getByTestId("survey-dropdown"); + await userEvent.selectOptions(surveyDropdown, surveys[0].id); + + const firstQuestionCheckbox = screen.getByLabelText(surveys[0].questions[0].headline.default); + expect(firstQuestionCheckbox).toBeChecked(); // Initially checked + + await userEvent.click(firstQuestionCheckbox); + expect(firstQuestionCheckbox).not.toBeChecked(); // Unchecked after click + + await userEvent.click(firstQuestionCheckbox); + expect(firstQuestionCheckbox).toBeChecked(); // Checked again + }); + + test("creates integration successfully", async () => { + createOrUpdateIntegrationActionMock.mockResolvedValue({ data: null as any }); // Mock successful action + + render( + + ); + + const channelDropdown = screen.getByTestId("channel-dropdown"); + const surveyDropdown = screen.getByTestId("survey-dropdown"); + const submitButton = screen.getByRole("button", { name: "Link Channel" }); + + await userEvent.selectOptions(channelDropdown, channels[1].id); + await userEvent.selectOptions(surveyDropdown, surveys[0].id); + + // Wait for questions to appear and potentially uncheck one + const firstQuestionCheckbox = await screen.findByLabelText(surveys[0].questions[0].headline.default); + await userEvent.click(firstQuestionCheckbox); // Uncheck first question + + // Check additional settings + await userEvent.click(screen.getByTestId("include-variables")); + await userEvent.click(screen.getByTestId("include-metadata")); + + await userEvent.click(submitButton); + + await waitFor(() => { + expect(createOrUpdateIntegrationActionMock).toHaveBeenCalledWith({ + environmentId, + integrationData: expect.objectContaining({ + type: "slack", + config: expect.objectContaining({ + key: mockSlackIntegration.config.key, + data: expect.arrayContaining([ + expect.objectContaining({ + channelId: channels[1].id, + channelName: channels[1].name, + surveyId: surveys[0].id, + surveyName: surveys[0].name, + questionIds: surveys[0].questions.slice(1).map((q) => q.id), // Excludes the first question + questions: "Selected questions", + includeVariables: true, + includeHiddenFields: false, + includeMetadata: true, + includeCreatedAt: true, // Default + }), + ]), + }), + }), + }); + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith("Integration added successfully."); + }); + await waitFor(() => { + expect(mockSetOpen).toHaveBeenCalledWith(false); + }); + }); + + test("deletes integration successfully", async () => { + createOrUpdateIntegrationActionMock.mockResolvedValue({ data: null as any }); + + render( + + ); + + const deleteButton = screen.getByText("Delete"); + await userEvent.click(deleteButton); + + await waitFor(() => { + expect(createOrUpdateIntegrationActionMock).toHaveBeenCalledWith({ + environmentId, + integrationData: expect.objectContaining({ + config: expect.objectContaining({ + data: [], // Data array should be empty after deletion + }), + }), + }); + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith("Integration removed successfully."); + }); + await waitFor(() => { + expect(mockSetOpen).toHaveBeenCalledWith(false); + }); + }); + + test("shows validation error if no channel selected", async () => { + render( + + ); + + const surveyDropdown = screen.getByTestId("survey-dropdown"); + const submitButton = screen.getByRole("button", { name: "Link Channel" }); + + await userEvent.selectOptions(surveyDropdown, surveys[0].id); + // No channel selected + await userEvent.click(submitButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Please select a channel."); + }); + expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled(); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + test("shows validation error if no survey selected", async () => { + render( + + ); + + const channelDropdown = screen.getByTestId("channel-dropdown"); + const submitButton = screen.getByRole("button", { name: "Link Channel" }); + + await userEvent.selectOptions(channelDropdown, channels[0].id); + // No survey selected + await userEvent.click(submitButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Please select a survey."); + }); + expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled(); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + test("shows validation error if no questions selected", async () => { + render( + + ); + + const channelDropdown = screen.getByTestId("channel-dropdown"); + const surveyDropdown = screen.getByTestId("survey-dropdown"); + const submitButton = screen.getByRole("button", { name: "Link Channel" }); + + await userEvent.selectOptions(channelDropdown, channels[0].id); + await userEvent.selectOptions(surveyDropdown, surveys[0].id); + + // Uncheck all questions + for (const question of surveys[0].questions) { + const checkbox = await screen.findByLabelText(question.headline.default); + await userEvent.click(checkbox); + } + + await userEvent.click(submitButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Please select at least one question."); + }); + expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled(); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + test("shows error toast if createOrUpdateIntegrationAction fails", async () => { + const errorMessage = "Failed to update integration"; + createOrUpdateIntegrationActionMock.mockRejectedValue(new Error(errorMessage)); + + render( + + ); + + const channelDropdown = screen.getByTestId("channel-dropdown"); + const surveyDropdown = screen.getByTestId("survey-dropdown"); + const submitButton = screen.getByRole("button", { name: "Link Channel" }); + + await userEvent.selectOptions(channelDropdown, channels[0].id); + await userEvent.selectOptions(surveyDropdown, surveys[0].id); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(createOrUpdateIntegrationActionMock).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(errorMessage); + }); + expect(mockSetOpen).not.toHaveBeenCalled(); + }); + + test("calls setOpen(false) and resets form on cancel", async () => { + render( + + ); + + const channelDropdown = screen.getByTestId("channel-dropdown"); + const cancelButton = screen.getByText("Cancel"); + + // Simulate some interaction + await userEvent.selectOptions(channelDropdown, channels[0].id); + await userEvent.click(cancelButton); + + expect(mockSetOpen).toHaveBeenCalledWith(false); + // Re-render with open=true to check if state was reset (channel should be unselected) + cleanup(); + render( + + ); + expect(screen.getByTestId("channel-dropdown")).toHaveValue(""); + }); + + test("shows warning when selected channel is already connected (add mode)", async () => { + // Add an existing connection for channel1 + const integrationWithExisting = { + ...mockSlackIntegration, + config: { + ...mockSlackIntegration.config, + data: [ + { + channelId: "channel1", + channelName: "#general", + surveyId: "survey-other", + surveyName: "Other Survey", + questionIds: ["q-other"], + questions: "All questions", + createdAt: new Date(), + } as TIntegrationSlackConfigData, + ], + }, + }; + + render( + + ); + + const channelDropdown = screen.getByTestId("channel-dropdown"); + await userEvent.selectOptions(channelDropdown, "channel1"); + + expect(screen.getByText("This channel is already connected to another survey.")).toBeInTheDocument(); + }); + + test("does not show warning when selected channel is the one being edited", async () => { + // Edit the existing connection for channel1 + const integrationToEdit = { + ...mockSlackIntegration, + config: { + ...mockSlackIntegration.config, + data: [ + { + channelId: "channel1", + channelName: "#general", + surveyId: "survey1", + surveyName: "Survey 1", + questionIds: ["q1"], + questions: "Selected questions", + createdAt: new Date(), + index: 0, + } as TIntegrationSlackConfigData & { index: number }, + ], + }, + }; + const selectedIntegrationForEdit = integrationToEdit.config.data[0]; + + render( + + ); + + const channelDropdown = screen.getByTestId("channel-dropdown"); + // Channel is already selected via selectedIntegration prop + expect(channelDropdown).toHaveValue("channel1"); + + expect( + screen.queryByText("This channel is already connected to another survey.") + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.tsx index c3853e3303d8..ecc14defbffe 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal.tsx @@ -2,12 +2,22 @@ import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; import SlackLogo from "@/images/slacklogo.png"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { Button } from "@/modules/ui/components/button"; import { Checkbox } from "@/modules/ui/components/checkbox"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { Label } from "@/modules/ui/components/label"; -import { Modal } from "@/modules/ui/components/modal"; import { useTranslate } from "@tolgee/react"; import { CircleHelpIcon } from "lucide-react"; import Image from "next/image"; @@ -15,8 +25,6 @@ import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall"; import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationSlack, @@ -189,24 +197,28 @@ export const AddChannelMappingModal = ({ ); return ( - -
-
-
-
-
- Slack logo -
-
-
- {t("environments.integrations.slack.link_slack_channel")} -
-
+ + + +
+
+ {t("environments.integrations.slack.slack_logo")} +
+
+ {t("environments.integrations.slack.link_slack_channel")} + + {t("environments.integrations.slack.slack_integration_description")} +
-
-
-
+ + +
@@ -289,31 +301,29 @@ export const AddChannelMappingModal = ({
)}
-
-
-
- {selectedIntegration ? ( - - ) : ( - - )} - -
-
+ ) : ( + + )} + + -
- + + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration.test.tsx new file mode 100644 index 000000000000..1c2f2e271233 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration.test.tsx @@ -0,0 +1,158 @@ +import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack"; +import { ManageIntegration } from "./ManageIntegration"; + +vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({ + deleteIntegrationAction: vi.fn(), +})); +vi.mock("react-hot-toast", () => ({ default: { success: vi.fn(), error: vi.fn() } })); +vi.mock("@/modules/ui/components/delete-dialog", () => ({ + DeleteDialog: ({ open, setOpen, onDelete }: any) => + open ? ( +
+ + +
+ ) : null, +})); +vi.mock("@/modules/ui/components/empty-space-filler", () => ({ + EmptySpaceFiller: ({ emptyMessage }: any) =>
{emptyMessage}
, +})); + +const baseProps = { + environment: { id: "env1" } as TEnvironment, + setOpenAddIntegrationModal: vi.fn(), + setIsConnected: vi.fn(), + setSelectedIntegration: vi.fn(), + refreshChannels: vi.fn(), + handleSlackAuthorization: vi.fn(), + showReconnectButton: false, + locale: "en-US" as const, +}; + +describe("ManageIntegration (Slack)", () => { + afterEach(() => cleanup()); + + test("empty state", () => { + render( + + ); + expect(screen.getByText(/connect_your_first_slack_channel/)).toBeInTheDocument(); + expect(screen.getByText(/link_channel/)).toBeInTheDocument(); + }); + + test("link channel triggers handlers", async () => { + render( + + ); + await userEvent.click(screen.getByText(/link_channel/)); + expect(baseProps.refreshChannels).toHaveBeenCalled(); + expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null); + expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true); + }); + + test("show reconnect button and triggers authorization", async () => { + render( + + ); + expect(screen.getByText("environments.integrations.slack.slack_reconnect_button")).toBeInTheDocument(); + await userEvent.click(screen.getByText("environments.integrations.slack.slack_reconnect_button")); + expect(baseProps.handleSlackAuthorization).toHaveBeenCalled(); + }); + + test("list integrations and open edit", async () => { + const item = { + surveyName: "S", + channelName: "C", + questions: "Q", + createdAt: new Date().toISOString(), + surveyId: "s", + channelId: "c", + } as unknown as TIntegrationSlackConfigData; + render( + + ); + expect(screen.getByText("S")).toBeInTheDocument(); + await userEvent.click(screen.getByText("S")); + expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({ ...item, index: 0 }); + expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true); + }); + + test("delete integration success", async () => { + vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any); + render( + + ); + await userEvent.click(screen.getByText(/delete_integration/)); + expect(screen.getByTestId("delete-dialog")).toBeInTheDocument(); + await userEvent.click(screen.getByText("confirm")); + expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" }); + const { default: toast } = await import("react-hot-toast"); + expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully"); + expect(baseProps.setIsConnected).toHaveBeenCalledWith(false); + }); + + test("delete integration error", async () => { + vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any); + render( + + ); + await userEvent.click(screen.getByText(/delete_integration/)); + await userEvent.click(screen.getByText("confirm")); + const { default: toast } = await import("react-hot-toast"); + expect(toast.error).toHaveBeenCalledWith(expect.any(String)); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration.tsx index 0c9127cacd1c..33a0693a06dd 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration.tsx @@ -1,16 +1,15 @@ "use client"; import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions"; +import { timeSince } from "@/lib/time"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { Button } from "@/modules/ui/components/button"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler"; -import { useTranslate } from "@tolgee/react"; -import { T } from "@tolgee/react"; +import { T, useTranslate } from "@tolgee/react"; import { Trash2Icon } from "lucide-react"; import React, { useState } from "react"; import toast from "react-hot-toast"; -import { timeSince } from "@formbricks/lib/time"; import { TEnvironment } from "@formbricks/types/environment"; import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack"; import { TUserLocale } from "@formbricks/types/user"; @@ -43,11 +42,10 @@ export const ManageIntegration = ({ const { t } = useTranslate(); const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false); const [isDeleting, setisDeleting] = useState(false); - const integrationArray = slackIntegration - ? slackIntegration.config.data - ? slackIntegration.config.data - : [] - : []; + let integrationArray: TIntegrationSlackConfigData[] = []; + if (slackIntegration?.config.data) { + integrationArray = slackIntegration.config.data; + } const handleDeleteIntegration = async () => { setisDeleting(true); @@ -129,9 +127,9 @@ export const ManageIntegration = ({ {integrationArray && integrationArray.map((data, index) => { return ( -
{ editIntegration(index); }}> @@ -141,7 +139,7 @@ export const ManageIntegration = ({
{timeSince(data.createdAt.toString(), locale)}
-
+ ); })}
diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper.test.tsx new file mode 100644 index 000000000000..974d49ce87d7 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper.test.tsx @@ -0,0 +1,171 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TIntegrationItem } from "@formbricks/types/integration"; +import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUserLocale } from "@formbricks/types/user"; +import { getSlackChannelsAction } from "../actions"; +import { authorize } from "../lib/slack"; +import { SlackWrapper } from "./SlackWrapper"; + +// Mock child components and actions +vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/actions", () => ({ + getSlackChannelsAction: vi.fn(), +})); + +vi.mock( + "@/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal", + () => ({ + AddChannelMappingModal: vi.fn(({ open }) => (open ?
Add Modal
: null)), + }) +); + +vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration", () => ({ + ManageIntegration: vi.fn(({ setOpenAddIntegrationModal, setIsConnected, handleSlackAuthorization }) => ( +
+ + + +
+ )), +})); + +vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/lib/slack", () => ({ + authorize: vi.fn(), +})); + +vi.mock("@/images/slacklogo.png", () => ({ + default: "slack-logo-path", +})); + +vi.mock("@/modules/ui/components/connect-integration", () => ({ + ConnectIntegration: vi.fn(({ handleAuthorization, isEnabled }) => ( +
+ +
+ )), +})); + +// Mock window.location.replace +Object.defineProperty(window, "location", { + value: { + replace: vi.fn(), + }, + writable: true, +}); + +const mockEnvironment = { id: "test-env-id" } as TEnvironment; +const mockSurveys: TSurvey[] = []; +const mockWebAppUrl = "http://localhost:3000"; +const mockLocale: TUserLocale = "en-US"; +const mockSlackChannels: TIntegrationItem[] = [{ id: "C123", name: "general" }]; + +const mockSlackIntegration: TIntegrationSlack = { + id: "slack-int-1", + type: "slack", + environmentId: "test-env-id", + config: { + key: { access_token: "xoxb-valid-token" } as unknown as TIntegrationSlackCredential, + data: [], + }, +}; + +const baseProps = { + environment: mockEnvironment, + surveys: mockSurveys, + webAppUrl: mockWebAppUrl, + locale: mockLocale, +}; + +describe("SlackWrapper", () => { + beforeEach(() => { + vi.mocked(getSlackChannelsAction).mockResolvedValue({ data: mockSlackChannels }); + vi.mocked(authorize).mockResolvedValue("https://slack.com/auth"); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders ConnectIntegration when not connected (no integration)", () => { + render(); + expect(screen.getByTestId("connect-integration")).toBeInTheDocument(); + expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled(); + }); + + test("renders ConnectIntegration when not connected (integration without key)", () => { + const integrationWithoutKey = { ...mockSlackIntegration, config: { data: [], email: "test" } } as any; + render(); + expect(screen.getByTestId("connect-integration")).toBeInTheDocument(); + expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument(); + }); + + test("renders ConnectIntegration disabled when isEnabled is false", () => { + render(); + expect(screen.getByTestId("connect-integration")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled(); + }); + + test("calls authorize and redirects when Connect button is clicked", async () => { + render(); + const connectButton = screen.getByRole("button", { name: "Connect" }); + await userEvent.click(connectButton); + + expect(authorize).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl); + await waitFor(() => { + expect(window.location.replace).toHaveBeenCalledWith("https://slack.com/auth"); + }); + }); + + test("renders ManageIntegration and AddChannelMappingModal (hidden) when connected", () => { + render(); + expect(screen.getByTestId("manage-integration")).toBeInTheDocument(); + expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument(); + expect(screen.queryByTestId("add-modal")).not.toBeInTheDocument(); // Modal is initially hidden + }); + + test("calls getSlackChannelsAction on mount", async () => { + render(); + await waitFor(() => { + expect(getSlackChannelsAction).toHaveBeenCalledWith({ environmentId: mockEnvironment.id }); + }); + }); + + test("switches from ManageIntegration to ConnectIntegration when disconnected", async () => { + render(); + expect(screen.getByTestId("manage-integration")).toBeInTheDocument(); + + const disconnectButton = screen.getByRole("button", { name: "Disconnect" }); + await userEvent.click(disconnectButton); + + expect(screen.getByTestId("connect-integration")).toBeInTheDocument(); + expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument(); + }); + + test("opens AddChannelMappingModal when triggered from ManageIntegration", async () => { + render(); + expect(screen.queryByTestId("add-modal")).not.toBeInTheDocument(); + + const openModalButton = screen.getByRole("button", { name: "Open Modal" }); + await userEvent.click(openModalButton); + + expect(screen.getByTestId("add-modal")).toBeInTheDocument(); + }); + + test("calls handleSlackAuthorization when reconnect button is clicked in ManageIntegration", async () => { + render(); + const reconnectButton = screen.getByRole("button", { name: "Reconnect" }); + await userEvent.click(reconnectButton); + + expect(authorize).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl); + await waitFor(() => { + expect(window.location.replace).toHaveBeenCalledWith("https://slack.com/auth"); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/lib/slack.test.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/lib/slack.test.ts new file mode 100644 index 000000000000..b94b7ee95786 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/lib/slack.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; +import { authorize } from "./slack"; + +// Mock the logger +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +// Mock fetch +global.fetch = vi.fn(); + +describe("authorize", () => { + const environmentId = "test-env-id"; + const apiHost = "http://test.com"; + const expectedUrl = `${apiHost}/api/v1/integrations/slack`; + const expectedAuthUrl = "http://slack.com/auth"; + + test("should return authUrl on successful fetch", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { authUrl: expectedAuthUrl } }), + } as Response); + + const authUrl = await authorize(environmentId, apiHost); + + expect(fetch).toHaveBeenCalledWith(expectedUrl, { + method: "GET", + headers: { environmentId }, + }); + expect(authUrl).toBe(expectedAuthUrl); + }); + + test("should throw error and log error on failed fetch", async () => { + const errorText = "Failed to fetch"; + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + text: async () => errorText, + } as Response); + + await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response"); + + expect(fetch).toHaveBeenCalledWith(expectedUrl, { + method: "GET", + headers: { environmentId }, + }); + expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch slack config"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.test.tsx new file mode 100644 index 000000000000..b0cabe386654 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.test.tsx @@ -0,0 +1,221 @@ +import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; +import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper"; +import Page from "@/app/(app)/environments/[environmentId]/integrations/slack/page"; +import { getIntegrationByType } from "@/lib/integration/service"; +import { findMatchingLocale } from "@/lib/utils/locale"; +import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth"; +import { cleanup, render, screen } from "@testing-library/react"; +import { redirect } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack"; +import { TSurvey } from "@formbricks/types/surveys/types"; + +// Mock dependencies +vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({ + getSurveys: vi.fn(), +})); + +vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper", () => ({ + SlackWrapper: vi.fn(({ isEnabled, environment, surveys, slackIntegration, webAppUrl, locale }) => ( +
+ Mock SlackWrapper: isEnabled={isEnabled.toString()}, envId={environment.id}, surveys= + {surveys.length}, integrationId={slackIntegration?.id}, webAppUrl={webAppUrl}, locale={locale} +
+ )), +})); + +vi.mock("@/lib/constants", () => ({ + IS_PRODUCTION: true, + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + SENTRY_DSN: "mock-sentry-dsn", + SLACK_CLIENT_ID: "test-slack-client-id", + SLACK_CLIENT_SECRET: "test-slack-client-secret", + WEBAPP_URL: "http://test.formbricks.com", +})); + +vi.mock("@/lib/integration/service", () => ({ + getIntegrationByType: vi.fn(), +})); + +vi.mock("@/lib/utils/locale", () => ({ + findMatchingLocale: vi.fn(), +})); + +vi.mock("@/modules/environments/lib/utils", () => ({ + getEnvironmentAuth: vi.fn(), +})); + +vi.mock("@/modules/ui/components/go-back-button", () => ({ + GoBackButton: vi.fn(({ url }) =>
Go Back: {url}
), +})); + +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: vi.fn(({ children }) =>
{children}
), +})); + +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: vi.fn(({ pageTitle }) =>

{pageTitle}

), +})); + +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +// Mock data +const environmentId = "test-env-id"; +const mockEnvironment = { + id: environmentId, + createdAt: new Date(), + type: "development", +} as unknown as TEnvironment; +const mockSurveys: TSurvey[] = [ + { + id: "survey1", + name: "Survey 1", + createdAt: new Date(), + updatedAt: new Date(), + environmentId: environmentId, + status: "inProgress", + type: "link", + questions: [], + triggers: [], + recontactDays: null, + displayOption: "displayOnce", + autoClose: null, + delay: 0, + autoComplete: null, + surveyClosedMessage: null, + singleUse: null, + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + hiddenFields: { enabled: false }, + languages: [], + styling: null, + segment: null, + displayPercentage: null, + closeOnDate: null, + runOnDate: null, + } as unknown as TSurvey, +]; +const mockSlackIntegration = { + id: "slack-int-id", + type: "slack", + config: { + data: [], + key: "test-key" as unknown as TIntegrationSlackCredential, + }, +} as unknown as TIntegrationSlack; +const mockLocale = "en-US"; +const mockParams = { params: { environmentId } }; + +describe("SlackIntegrationPage", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + beforeEach(() => { + vi.mocked(getSurveys).mockResolvedValue(mockSurveys); + vi.mocked(getIntegrationByType).mockResolvedValue(mockSlackIntegration); + vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale); + }); + + test("renders correctly when user is not read-only", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + isReadOnly: false, + environment: mockEnvironment, + } as unknown as TEnvironmentAuth); + + const tree = await Page(mockParams); + render(tree); + + expect(screen.getByTestId("page-header")).toHaveTextContent( + "environments.integrations.slack.slack_integration" + ); + expect(screen.getByTestId("go-back-button")).toHaveTextContent( + `Go Back: http://test.formbricks.com/environments/${environmentId}/integrations` + ); + expect(screen.getByTestId("slack-wrapper")).toBeInTheDocument(); + + // Check props passed to SlackWrapper + expect(vi.mocked(SlackWrapper)).toHaveBeenCalledWith( + { + isEnabled: true, // Since SLACK_CLIENT_ID and SLACK_CLIENT_SECRET are mocked + environment: mockEnvironment, + surveys: mockSurveys, + slackIntegration: mockSlackIntegration, + webAppUrl: "http://test.formbricks.com", + locale: mockLocale, + }, + undefined + ); + + expect(vi.mocked(redirect)).not.toHaveBeenCalled(); + }); + + test("redirects when user is read-only", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + isReadOnly: true, + environment: mockEnvironment, + } as unknown as TEnvironmentAuth); + + // Need to actually call the component function to trigger the redirect logic + await Page(mockParams); + + expect(vi.mocked(redirect)).toHaveBeenCalledWith("./"); + expect(vi.mocked(SlackWrapper)).not.toHaveBeenCalled(); + }); + + test("renders correctly when Slack integration is not configured", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + isReadOnly: false, + environment: mockEnvironment, + } as unknown as TEnvironmentAuth); + vi.mocked(getIntegrationByType).mockResolvedValue(null); // Simulate no integration found + + const tree = await Page(mockParams); + render(tree); + + expect(screen.getByTestId("page-header")).toHaveTextContent( + "environments.integrations.slack.slack_integration" + ); + expect(screen.getByTestId("slack-wrapper")).toBeInTheDocument(); + + // Check props passed to SlackWrapper when integration is null + expect(vi.mocked(SlackWrapper)).toHaveBeenCalledWith( + { + isEnabled: true, + environment: mockEnvironment, + surveys: mockSurveys, + slackIntegration: null, // Expecting null here + webAppUrl: "http://test.formbricks.com", + locale: mockLocale, + }, + undefined + ); + + expect(vi.mocked(redirect)).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.tsx index 8cae88faaf97..86cc97399fc7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.tsx @@ -1,14 +1,14 @@ import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys"; import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper"; +import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants"; +import { getIntegrationByType } from "@/lib/integration/service"; +import { findMatchingLocale } from "@/lib/utils/locale"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getTranslate } from "@/tolgee/server"; import { redirect } from "next/navigation"; -import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; -import { getIntegrationByType } from "@formbricks/lib/integration/service"; -import { findMatchingLocale } from "@formbricks/lib/utils/locale"; import { TIntegrationSlack } from "@formbricks/types/integration/slack"; const Page = async (props) => { diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/page.test.tsx new file mode 100644 index 000000000000..4fc079ffad2f --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/webhooks/page.test.tsx @@ -0,0 +1,14 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import WebhooksPage from "./page"; + +vi.mock("@/modules/integrations/webhooks/page", () => ({ + WebhooksPage: vi.fn(() =>
WebhooksPageMock
), +})); + +describe("WebhooksIntegrationPage", () => { + test("renders WebhooksPage component", () => { + render(); + expect(WebhooksPage).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/layout.test.tsx b/apps/web/app/(app)/environments/[environmentId]/layout.test.tsx index 453e0a7573f4..cc720bdd6b49 100644 --- a/apps/web/app/(app)/environments/[environmentId]/layout.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/layout.test.tsx @@ -1,10 +1,12 @@ +import { getEnvironment } from "@/lib/environment/service"; +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getProjectByEnvironmentId } from "@/lib/project/service"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import { cleanup, render, screen } from "@testing-library/react"; import { Session } from "next-auth"; import { redirect } from "next/navigation"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; import { TMembership } from "@formbricks/types/memberships"; import { TOrganization } from "@formbricks/types/organizations"; import { TProject } from "@formbricks/types/project"; @@ -13,12 +15,20 @@ import EnvLayout from "./layout"; // Mock sub-components to render identifiable elements vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({ - EnvironmentLayout: ({ children }: any) =>
{children}
, + EnvironmentLayout: ({ children, environmentId, session }: any) => ( +
+ {children} +
+ ), })); vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({ - EnvironmentIdBaseLayout: ({ children, environmentId }: any) => ( -
- {environmentId} + EnvironmentIdBaseLayout: ({ children, environmentId, session, user, organization }: any) => ( +
{children}
), @@ -26,44 +36,71 @@ vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({ vi.mock("@/modules/ui/components/toaster-client", () => ({ ToasterClient: () =>
, })); -vi.mock("../../components/FormbricksClient", () => ({ - FormbricksClient: ({ userId, email }: any) => ( -
- {userId}-{email} +vi.mock("./components/EnvironmentStorageHandler", () => ({ + default: ({ environmentId }: any) => ( +
+ ), +})); +vi.mock("@/app/(app)/environments/[environmentId]/context/environment-context", () => ({ + EnvironmentContextWrapper: ({ children, environment, project }: any) => ( +
+ {children}
), })); -vi.mock("./components/EnvironmentStorageHandler", () => ({ - default: ({ environmentId }: any) =>
{environmentId}
, + +// Mock navigation +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), })); // Mocks for dependencies vi.mock("@/modules/environments/lib/utils", () => ({ environmentIdLayoutChecks: vi.fn(), })); -vi.mock("@formbricks/lib/project/service", () => ({ +vi.mock("@/lib/project/service", () => ({ getProjectByEnvironmentId: vi.fn(), })); -vi.mock("@formbricks/lib/membership/service", () => ({ +vi.mock("@/lib/environment/service", () => ({ + getEnvironment: vi.fn(), +})); +vi.mock("@/lib/membership/service", () => ({ getMembershipByUserIdOrganizationId: vi.fn(), })); describe("EnvLayout", () => { + const mockSession = { user: { id: "user1" } } as Session; + const mockUser = { id: "user1", email: "user1@example.com" } as TUser; + const mockOrganization = { id: "org1", name: "Org1", billing: {} } as TOrganization; + const mockProject = { id: "proj1", name: "Test Project" } as TProject; + const mockEnvironment = { id: "env1", type: "production" } as TEnvironment; + const mockMembership = { + id: "member1", + role: "owner", + organizationId: "org1", + userId: "user1", + accepted: true, + } as TMembership; + const mockTranslation = ((key: string) => key) as any; + afterEach(() => { cleanup(); + vi.clearAllMocks(); }); - it("renders successfully when all dependencies return valid data", async () => { + test("renders successfully when all dependencies return valid data", async () => { vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ - t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test - session: { user: { id: "user1" } } as Session, - user: { id: "user1", email: "user1@example.com" } as TUser, - organization: { id: "org1", name: "Org1", billing: {} } as TOrganization, + t: mockTranslation, + session: mockSession, + user: mockUser, + organization: mockOrganization, }); - vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject); - vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({ - id: "member1", - } as unknown as TMembership); + vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject); + vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership); const result = await EnvLayout({ params: Promise.resolve({ environmentId: "env1" }), @@ -71,86 +108,220 @@ describe("EnvLayout", () => { }); render(result); - expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1"); - expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1"); - expect(screen.getByTestId("EnvironmentLayout")).toBeDefined(); + // Verify main layout structure + expect(screen.getByTestId("EnvironmentIdBaseLayout")).toBeInTheDocument(); + expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-environment-id", "env1"); + expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-session", "user1"); + expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-user", "user1"); + expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-organization", "org1"); + + // Verify environment storage handler + expect(screen.getByTestId("EnvironmentStorageHandler")).toBeInTheDocument(); + expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveAttribute("data-environment-id", "env1"); + + // Verify context wrapper + expect(screen.getByTestId("EnvironmentContextWrapper")).toBeInTheDocument(); + expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-environment-id", "env1"); + expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-project-id", "proj1"); + + // Verify environment layout + expect(screen.getByTestId("EnvironmentLayout")).toBeInTheDocument(); + expect(screen.getByTestId("EnvironmentLayout")).toHaveAttribute("data-environment-id", "env1"); + expect(screen.getByTestId("EnvironmentLayout")).toHaveAttribute("data-session", "user1"); + + // Verify children are rendered expect(screen.getByTestId("child")).toHaveTextContent("Content"); + + // Verify all services were called with correct parameters + expect(environmentIdLayoutChecks).toHaveBeenCalledWith("env1"); + expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1"); + expect(getEnvironment).toHaveBeenCalledWith("env1"); + expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1"); }); - it("throws error if project is not found", async () => { + test("redirects when session is null", async () => { vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ - t: ((key: string) => key) as any, - session: { user: { id: "user1" } } as Session, - user: { id: "user1", email: "user1@example.com" } as TUser, - organization: { id: "org1", name: "Org1", billing: {} } as TOrganization, + t: mockTranslation, + session: null as unknown as Session, + user: mockUser, + organization: mockOrganization, + }); + vi.mocked(redirect).mockImplementationOnce(() => { + throw new Error("Redirect called"); }); - vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null); - vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({ - id: "member1", - } as unknown as TMembership); await expect( EnvLayout({ params: Promise.resolve({ environmentId: "env1" }), children:
Content
, }) - ).rejects.toThrow("common.project_not_found"); + ).rejects.toThrow("Redirect called"); + + expect(redirect).toHaveBeenCalledWith("/auth/login"); }); - it("throws error if membership is not found", async () => { + test("throws error if user is null", async () => { vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ - t: ((key: string) => key) as any, - session: { user: { id: "user1" } } as Session, - user: { id: "user1", email: "user1@example.com" } as TUser, - organization: { id: "org1", name: "Org1", billing: {} } as TOrganization, + t: mockTranslation, + session: mockSession, + user: null as unknown as TUser, + organization: mockOrganization, }); - vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject); - vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null); await expect( EnvLayout({ params: Promise.resolve({ environmentId: "env1" }), children:
Content
, }) - ).rejects.toThrow("common.membership_not_found"); + ).rejects.toThrow("common.user_not_found"); + + // Verify redirect was not called + expect(redirect).not.toHaveBeenCalled(); }); - it("calls redirect when session is null", async () => { + test("throws error if project is not found", async () => { vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ - t: ((key: string) => key) as any, - session: undefined as unknown as Session, - user: undefined as unknown as TUser, - organization: { id: "org1", name: "Org1", billing: {} } as TOrganization, - }); - vi.mocked(redirect).mockImplementationOnce(() => { - throw new Error("Redirect called"); + t: mockTranslation, + session: mockSession, + user: mockUser, + organization: mockOrganization, }); + vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null); + vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment); await expect( EnvLayout({ params: Promise.resolve({ environmentId: "env1" }), children:
Content
, }) - ).rejects.toThrow("Redirect called"); + ).rejects.toThrow("common.project_not_found"); + + // Verify both project and environment were called in Promise.all + expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1"); + expect(getEnvironment).toHaveBeenCalledWith("env1"); }); - it("throws error if user is null", async () => { + test("throws error if environment is not found", async () => { vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ - t: ((key: string) => key) as any, - session: { user: { id: "user1" } } as Session, - user: undefined as unknown as TUser, - organization: { id: "org1", name: "Org1", billing: {} } as TOrganization, + t: mockTranslation, + session: mockSession, + user: mockUser, + organization: mockOrganization, }); + vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject); + vi.mocked(getEnvironment).mockResolvedValueOnce(null); - vi.mocked(redirect).mockImplementationOnce(() => { - throw new Error("Redirect called"); + await expect( + EnvLayout({ + params: Promise.resolve({ environmentId: "env1" }), + children:
Content
, + }) + ).rejects.toThrow("common.environment_not_found"); + + // Verify both project and environment were called in Promise.all + expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1"); + expect(getEnvironment).toHaveBeenCalledWith("env1"); + }); + + test("throws error if membership is not found", async () => { + vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ + t: mockTranslation, + session: mockSession, + user: mockUser, + organization: mockOrganization, }); + vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject); + vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null); await expect( EnvLayout({ params: Promise.resolve({ environmentId: "env1" }), children:
Content
, }) - ).rejects.toThrow("common.user_not_found"); + ).rejects.toThrow("common.membership_not_found"); + + expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1"); + }); + + test("handles Promise.all correctly for project and environment", async () => { + vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ + t: mockTranslation, + session: mockSession, + user: mockUser, + organization: mockOrganization, + }); + + // Mock Promise.all to verify it's called correctly + const getProjectSpy = vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject); + const getEnvironmentSpy = vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership); + + const result = await EnvLayout({ + params: Promise.resolve({ environmentId: "env1" }), + children:
Content
, + }); + render(result); + + // Verify both calls were made + expect(getProjectSpy).toHaveBeenCalledWith("env1"); + expect(getEnvironmentSpy).toHaveBeenCalledWith("env1"); + + // Verify successful rendering + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); + + test("handles different environment types correctly", async () => { + const developmentEnvironment = { id: "env1", type: "development" } as TEnvironment; + + vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ + t: mockTranslation, + session: mockSession, + user: mockUser, + organization: mockOrganization, + }); + vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject); + vi.mocked(getEnvironment).mockResolvedValueOnce(developmentEnvironment); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership); + + const result = await EnvLayout({ + params: Promise.resolve({ environmentId: "env1" }), + children:
Content
, + }); + render(result); + + // Verify context wrapper receives the development environment + expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-environment-id", "env1"); + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); + + test("handles different user roles correctly", async () => { + const memberMembership = { + id: "member1", + role: "member", + organizationId: "org1", + userId: "user1", + accepted: true, + } as TMembership; + + vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ + t: mockTranslation, + session: mockSession, + user: mockUser, + organization: mockOrganization, + }); + vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject); + vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(memberMembership); + + const result = await EnvLayout({ + params: Promise.resolve({ environmentId: "env1" }), + children:
Content
, + }); + render(result); + + // Verify successful rendering with member role + expect(screen.getByTestId("child")).toBeInTheDocument(); + expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1"); }); }); diff --git a/apps/web/app/(app)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/layout.tsx index e1acd6442142..85c60fd65467 100644 --- a/apps/web/app/(app)/environments/[environmentId]/layout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/layout.tsx @@ -1,9 +1,11 @@ import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"; +import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context"; +import { getEnvironment } from "@/lib/environment/service"; +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getProjectByEnvironmentId } from "@/lib/project/service"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout"; import { redirect } from "next/navigation"; -import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler"; const EnvLayout = async (props: { @@ -11,7 +13,6 @@ const EnvLayout = async (props: { children: React.ReactNode; }) => { const params = await props.params; - const { children } = props; const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId); @@ -24,11 +25,19 @@ const EnvLayout = async (props: { throw new Error(t("common.user_not_found")); } - const project = await getProjectByEnvironmentId(params.environmentId); + const [project, environment] = await Promise.all([ + getProjectByEnvironmentId(params.environmentId), + getEnvironment(params.environmentId), + ]); + if (!project) { throw new Error(t("common.project_not_found")); } + if (!environment) { + throw new Error(t("common.environment_not_found")); + } + const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id); if (!membership) { @@ -42,9 +51,11 @@ const EnvLayout = async (props: { user={user} organization={organization}> - - {children} - + + + {children} + + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/page.test.tsx new file mode 100644 index 000000000000..9a23e2d3ed74 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/page.test.tsx @@ -0,0 +1,138 @@ +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getAccessFlags } from "@/lib/membership/utils"; +import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { redirect } from "next/navigation"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TMembership } from "@formbricks/types/memberships"; +import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations"; +import EnvironmentPage from "./page"; + +vi.mock("@/lib/membership/service", () => ({ + getMembershipByUserIdOrganizationId: vi.fn(), +})); + +vi.mock("@/lib/membership/utils", () => ({ + getAccessFlags: vi.fn(), +})); + +vi.mock("@/modules/environments/lib/utils", () => ({ + getEnvironmentAuth: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +describe("EnvironmentPage", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const mockEnvironmentId = "test-environment-id"; + const mockUserId = "test-user-id"; + const mockOrganizationId = "test-organization-id"; + + const mockSession = { + user: { + id: mockUserId, + name: "Test User", + email: "test@example.com", + imageUrl: "", + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + emailVerified: new Date(), + role: "user", + objective: "other", + }, + expires: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour from now + } as any; + + const mockOrganization: TOrganization = { + id: mockOrganizationId, + name: "Test Organization", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: "cus_123", + } as unknown as TOrganizationBilling, + } as unknown as TOrganization; + + test("should redirect to billing settings if isBilling is true", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + session: mockSession, + organization: mockOrganization, + environment: { id: mockEnvironmentId, type: "production", product: { id: "prodId" } }, + } as any); // Using 'any' for brevity as environment type is complex and not core to this test + + const mockMembership: TMembership = { + userId: mockUserId, + organizationId: mockOrganizationId, + role: "owner" as any, + accepted: true, + }; + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getAccessFlags).mockReturnValue({ isBilling: true, isOwner: true } as any); + + await EnvironmentPage({ params: { environmentId: mockEnvironmentId } }); + + expect(redirect).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/settings/billing`); + }); + + test("should redirect to surveys if isBilling is false", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + session: mockSession, + organization: mockOrganization, + environment: { id: mockEnvironmentId, type: "production", product: { id: "prodId" } }, + } as any); + + const mockMembership: TMembership = { + userId: mockUserId, + organizationId: mockOrganizationId, + role: "developer" as any, // Role that would result in isBilling: false + accepted: true, + }; + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getAccessFlags).mockReturnValue({ isBilling: false, isOwner: false } as any); + + await EnvironmentPage({ params: { environmentId: mockEnvironmentId } }); + + expect(redirect).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/surveys`); + }); + + test("should handle session being null", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + session: null, // Simulate no active session + organization: mockOrganization, + environment: { id: mockEnvironmentId, type: "production", product: { id: "prodId" } }, + } as any); + + // Membership fetch might return null or throw, depending on implementation when userId is undefined + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null); + // Access flags would likely be all false if membership is null + vi.mocked(getAccessFlags).mockReturnValue({ isBilling: false, isOwner: false } as any); + + await EnvironmentPage({ params: { environmentId: mockEnvironmentId } }); + + // Expect redirect to surveys as default when isBilling is false + expect(redirect).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/surveys`); + }); + + test("should handle currentUserMembership being null", async () => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + session: mockSession, + organization: mockOrganization, + environment: { id: mockEnvironmentId, type: "production", product: { id: "prodId" } }, + } as any); + + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null); // Simulate no membership found + // Access flags would likely be all false if membership is null + vi.mocked(getAccessFlags).mockReturnValue({ isBilling: false, isOwner: false } as any); + + await EnvironmentPage({ params: { environmentId: mockEnvironmentId } }); + + // Expect redirect to surveys as default when isBilling is false + expect(redirect).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/surveys`); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/page.tsx b/apps/web/app/(app)/environments/[environmentId]/page.tsx index 062dfe781ebb..b71aed10a53c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/page.tsx @@ -1,7 +1,7 @@ +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getAccessFlags } from "@/lib/membership/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { redirect } from "next/navigation"; -import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; const EnvironmentPage = async (props) => { const params = await props.params; diff --git a/apps/web/app/(app)/environments/[environmentId]/project/(setup)/app-connection/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/(setup)/app-connection/loading.test.tsx new file mode 100644 index 000000000000..33cf380178dc --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/project/(setup)/app-connection/loading.test.tsx @@ -0,0 +1,15 @@ +import { AppConnectionLoading as OriginalAppConnectionLoading } from "@/modules/projects/settings/(setup)/app-connection/loading"; +import { describe, expect, test, vi } from "vitest"; +import AppConnectionLoading from "./loading"; + +// Mock the original component to ensure we are testing the re-export +vi.mock("@/modules/projects/settings/(setup)/app-connection/loading", () => ({ + AppConnectionLoading: () =>
Mock AppConnectionLoading
, +})); + +describe("AppConnectionLoading Re-export", () => { + test("should re-export AppConnectionLoading from the correct module", () => { + // Check if the re-exported component is the same as the original (mocked) component + expect(AppConnectionLoading).toBe(OriginalAppConnectionLoading); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/project/(setup)/app-connection/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/(setup)/app-connection/page.test.tsx new file mode 100644 index 000000000000..3458738c67b5 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/project/(setup)/app-connection/page.test.tsx @@ -0,0 +1,42 @@ +import { AppConnectionPage as OriginalAppConnectionPage } from "@/modules/projects/settings/(setup)/app-connection/page"; +import { describe, expect, test, vi } from "vitest"; +import AppConnectionPage from "./page"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: true, +})); + +vi.mock("@/lib/env", () => ({ + env: { + PUBLIC_URL: "https://example.com", + }, +})); + +describe("AppConnectionPage Re-export", () => { + test("should re-export AppConnectionPage correctly", () => { + expect(AppConnectionPage).toBe(OriginalAppConnectionPage); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/project/general/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/general/loading.test.tsx new file mode 100644 index 000000000000..ff4928e52fb9 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/project/general/loading.test.tsx @@ -0,0 +1,17 @@ +import { GeneralSettingsLoading as OriginalGeneralSettingsLoading } from "@/modules/projects/settings/general/loading"; +import { describe, expect, test, vi } from "vitest"; +import GeneralSettingsLoadingPage from "./loading"; + +// Mock the original component to ensure we are testing the re-export +vi.mock("@/modules/projects/settings/general/loading", () => ({ + GeneralSettingsLoading: () => ( +
Mock GeneralSettingsLoading
+ ), +})); + +describe("GeneralSettingsLoadingPage Re-export", () => { + test("should re-export GeneralSettingsLoading from the correct module", () => { + // Check if the re-exported component is the same as the original (mocked) component + expect(GeneralSettingsLoadingPage).toBe(OriginalGeneralSettingsLoading); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/project/general/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/general/page.test.tsx new file mode 100644 index 000000000000..489f33a4c710 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/project/general/page.test.tsx @@ -0,0 +1,42 @@ +import { GeneralSettingsPage } from "@/modules/projects/settings/general/page"; +import { describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: 1, +})); + +vi.mock("@/lib/env", () => ({ + env: { + PUBLIC_URL: "https://public-domain.com", + }, +})); + +describe("GeneralSettingsPage re-export", () => { + test("should re-export GeneralSettingsPage component", () => { + expect(Page).toBe(GeneralSettingsPage); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/project/languages/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/languages/loading.test.tsx new file mode 100644 index 000000000000..df5b013693b4 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/project/languages/loading.test.tsx @@ -0,0 +1,15 @@ +import { LanguagesLoading as OriginalLanguagesLoading } from "@/modules/ee/languages/loading"; +import { describe, expect, test, vi } from "vitest"; +import LanguagesLoading from "./loading"; + +// Mock the original component to ensure we are testing the re-export +vi.mock("@/modules/ee/languages/loading", () => ({ + LanguagesLoading: () =>
Mock LanguagesLoading
, +})); + +describe("LanguagesLoadingPage Re-export", () => { + test("should re-export LanguagesLoading from the correct module", () => { + // Check if the re-exported component is the same as the original (mocked) component + expect(LanguagesLoading).toBe(OriginalLanguagesLoading); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/project/languages/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/languages/page.test.tsx new file mode 100644 index 000000000000..740381159a7c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/project/languages/page.test.tsx @@ -0,0 +1,36 @@ +import { LanguagesPage } from "@/modules/ee/languages/page"; +import { describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: 1, +})); + +describe("LanguagesPage re-export", () => { + test("should re-export LanguagesPage component", () => { + expect(Page).toBe(LanguagesPage); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/project/layout.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/layout.test.tsx new file mode 100644 index 000000000000..788d7fff09b0 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/project/layout.test.tsx @@ -0,0 +1,24 @@ +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import ProjectLayout, { metadata as layoutMetadata } from "./layout"; + +vi.mock("@/modules/projects/settings/layout", () => ({ + ProjectSettingsLayout: ({ children }) =>
{children}
, + metadata: { title: "Mocked Project Settings" }, +})); + +describe("ProjectLayout", () => { + afterEach(() => { + cleanup(); + }); + + test("renders ProjectSettingsLayout", () => { + const { getByTestId } = render(Child Content); + expect(getByTestId("project-settings-layout")).toBeInTheDocument(); + expect(getByTestId("project-settings-layout")).toHaveTextContent("Child Content"); + }); + + test("exports metadata from @/modules/projects/settings/layout", () => { + expect(layoutMetadata).toEqual({ title: "Mocked Project Settings" }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/project/look/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/look/loading.test.tsx new file mode 100644 index 000000000000..4c0c7e61bf87 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/project/look/loading.test.tsx @@ -0,0 +1,17 @@ +import { ProjectLookSettingsLoading as OriginalProjectLookSettingsLoading } from "@/modules/projects/settings/look/loading"; +import { describe, expect, test, vi } from "vitest"; +import ProjectLookSettingsLoading from "./loading"; + +// Mock the original component to ensure we are testing the re-export +vi.mock("@/modules/projects/settings/look/loading", () => ({ + ProjectLookSettingsLoading: () => ( +
Mock ProjectLookSettingsLoading
+ ), +})); + +describe("ProjectLookSettingsLoadingPage Re-export", () => { + test("should re-export ProjectLookSettingsLoading from the correct module", () => { + // Check if the re-exported component is the same as the original (mocked) component + expect(ProjectLookSettingsLoading).toBe(OriginalProjectLookSettingsLoading); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/project/look/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/look/page.test.tsx new file mode 100644 index 000000000000..4d04031bec39 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/project/look/page.test.tsx @@ -0,0 +1,42 @@ +import { ProjectLookSettingsPage } from "@/modules/projects/settings/look/page"; +import { describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: 1, +})); + +vi.mock("@/lib/env", () => ({ + env: { + PUBLIC_URL: "https://public-domain.com", + }, +})); + +describe("ProjectLookSettingsPage re-export", () => { + test("should re-export ProjectLookSettingsPage component", () => { + expect(Page).toBe(ProjectLookSettingsPage); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/project/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/page.test.tsx new file mode 100644 index 000000000000..e890bce7033c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/project/page.test.tsx @@ -0,0 +1,33 @@ +import { ProjectSettingsPage } from "@/modules/projects/settings/page"; +import { describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", +})); + +describe("ProjectSettingsPage re-export", () => { + test("should re-export ProjectSettingsPage component", () => { + expect(Page).toBe(ProjectSettingsPage); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/project/tags/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/tags/loading.test.tsx new file mode 100644 index 000000000000..836ab270eabc --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/project/tags/loading.test.tsx @@ -0,0 +1,15 @@ +import { TagsLoading as OriginalTagsLoading } from "@/modules/projects/settings/tags/loading"; +import { describe, expect, test, vi } from "vitest"; +import TagsLoading from "./loading"; + +// Mock the original component to ensure we are testing the re-export +vi.mock("@/modules/projects/settings/tags/loading", () => ({ + TagsLoading: () =>
Mock TagsLoading
, +})); + +describe("TagsLoadingPage Re-export", () => { + test("should re-export TagsLoading from the correct module", () => { + // Check if the re-exported component is the same as the original (mocked) component + expect(TagsLoading).toBe(OriginalTagsLoading); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/project/tags/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/tags/page.test.tsx new file mode 100644 index 000000000000..0c69c66468e9 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/project/tags/page.test.tsx @@ -0,0 +1,36 @@ +import { TagsPage } from "@/modules/projects/settings/tags/page"; +import { describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: 1, +})); + +describe("TagsPage re-export", () => { + test("should re-export TagsPage component", () => { + expect(Page).toBe(TagsPage); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/project/teams/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/teams/page.test.tsx new file mode 100644 index 000000000000..6d1b01200e9f --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/project/teams/page.test.tsx @@ -0,0 +1,36 @@ +import { ProjectTeams } from "@/modules/ee/teams/project-teams/page"; +import { describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: true, +})); + +describe("ProjectTeams re-export", () => { + test("should re-export ProjectTeams component", () => { + expect(Page).toBe(ProjectTeams); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar.test.tsx new file mode 100644 index 000000000000..ac5569d1a602 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar.test.tsx @@ -0,0 +1,148 @@ +import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation"; +import { cleanup, render } from "@testing-library/react"; +import { usePathname } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { AccountSettingsNavbar } from "./AccountSettingsNavbar"; + +vi.mock("next/navigation", () => ({ + usePathname: vi.fn(), +})); + +vi.mock("@/modules/ui/components/secondary-navigation", () => ({ + SecondaryNavigation: vi.fn(() =>
SecondaryNavigationMock
), +})); + +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => { + if (key === "common.profile") return "Profile"; + if (key === "common.notifications") return "Notifications"; + return key; + }, + }), +})); + +describe("AccountSettingsNavbar", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("renders correctly and sets profile as current when pathname includes /profile", () => { + vi.mocked(usePathname).mockReturnValue("/environments/testEnvId/settings/profile"); + render(); + + expect(SecondaryNavigation).toHaveBeenCalledWith( + { + navigation: [ + { + id: "profile", + label: "Profile", + href: "/environments/testEnvId/settings/profile", + current: true, + }, + { + id: "notifications", + label: "Notifications", + href: "/environments/testEnvId/settings/notifications", + current: false, + }, + ], + activeId: "profile", + loading: undefined, + }, + undefined + ); + }); + + test("sets notifications as current when pathname includes /notifications", () => { + vi.mocked(usePathname).mockReturnValue("/environments/testEnvId/settings/notifications"); + render(); + + expect(SecondaryNavigation).toHaveBeenCalledWith( + expect.objectContaining({ + navigation: [ + { + id: "profile", + label: "Profile", + href: "/environments/testEnvId/settings/profile", + current: false, + }, + { + id: "notifications", + label: "Notifications", + href: "/environments/testEnvId/settings/notifications", + current: true, + }, + ], + activeId: "notifications", + }), + undefined + ); + }); + + test("passes loading prop to SecondaryNavigation", () => { + vi.mocked(usePathname).mockReturnValue("/environments/testEnvId/settings/profile"); + render(); + + expect(SecondaryNavigation).toHaveBeenCalledWith( + expect.objectContaining({ + loading: true, + }), + undefined + ); + }); + + test("handles undefined environmentId gracefully in hrefs", () => { + vi.mocked(usePathname).mockReturnValue("/environments/undefined/settings/profile"); + render(); // environmentId is undefined + + expect(SecondaryNavigation).toHaveBeenCalledWith( + expect.objectContaining({ + navigation: [ + { + id: "profile", + label: "Profile", + href: "/environments/undefined/settings/profile", + current: true, + }, + { + id: "notifications", + label: "Notifications", + href: "/environments/undefined/settings/notifications", + current: false, + }, + ], + }), + undefined + ); + }); + + test("handles null pathname gracefully", () => { + vi.mocked(usePathname).mockReturnValue(""); + render(); + + expect(SecondaryNavigation).toHaveBeenCalledWith( + expect.objectContaining({ + navigation: [ + { + id: "profile", + label: "Profile", + href: "/environments/testEnvId/settings/profile", + current: false, + }, + { + id: "notifications", + label: "Notifications", + href: "/environments/testEnvId/settings/notifications", + current: false, + }, + ], + }), + undefined + ); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/layout.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/layout.test.tsx new file mode 100644 index 000000000000..8107a446afcb --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/layout.test.tsx @@ -0,0 +1,98 @@ +import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import { getProjectByEnvironmentId } from "@/lib/project/service"; +import { cleanup, render, screen } from "@testing-library/react"; +import { Session, getServerSession } from "next-auth"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TProject } from "@formbricks/types/project"; +import AccountSettingsLayout from "./layout"; + +// Mock dependencies +vi.mock("@/lib/organization/service"); +vi.mock("@/lib/project/service"); +vi.mock("next-auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getServerSession: vi.fn(), + }; +}); + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: true, +})); + +const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId); +const mockGetProjectByEnvironmentId = vi.mocked(getProjectByEnvironmentId); +const mockGetServerSession = vi.mocked(getServerSession); + +const mockOrganization = { id: "org_test_id" } as unknown as TOrganization; +const mockProject = { id: "project_test_id" } as unknown as TProject; +const mockSession = { user: { id: "user_test_id" } } as unknown as Session; + +const t = (key: any) => key; +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => t, +})); + +const mockProps = { + params: { environmentId: "env_test_id" }, + children:
Child Content
, +}; + +describe("AccountSettingsLayout", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + vi.resetAllMocks(); + + mockGetOrganizationByEnvironmentId.mockResolvedValue(mockOrganization); + mockGetProjectByEnvironmentId.mockResolvedValue(mockProject); + mockGetServerSession.mockResolvedValue(mockSession); + }); + + test("should render children when all data is fetched successfully", async () => { + render(await AccountSettingsLayout(mockProps)); + expect(screen.getByText("Child Content")).toBeInTheDocument(); + }); + + test("should throw error if organization is not found", async () => { + mockGetOrganizationByEnvironmentId.mockResolvedValue(null); + await expect(AccountSettingsLayout(mockProps)).rejects.toThrowError("common.organization_not_found"); + }); + + test("should throw error if project is not found", async () => { + mockGetProjectByEnvironmentId.mockResolvedValue(null); + await expect(AccountSettingsLayout(mockProps)).rejects.toThrowError("common.project_not_found"); + }); + + test("should throw error if session is not found", async () => { + mockGetServerSession.mockResolvedValue(null); + await expect(AccountSettingsLayout(mockProps)).rejects.toThrowError("common.session_not_found"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/layout.tsx index 0823dc0a8d1e..7dbd8b6badca 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/layout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/layout.tsx @@ -1,8 +1,8 @@ +import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import { getProjectByEnvironmentId } from "@/lib/project/service"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { getTranslate } from "@/tolgee/server"; import { getServerSession } from "next-auth"; -import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; const AccountSettingsLayout = async (props) => { const params = await props.params; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/actions.ts index 87730fe6631f..c9a59aafc3ce 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/actions.ts @@ -1,8 +1,10 @@ "use server"; +import { getUser, updateUser } from "@/lib/user/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { z } from "zod"; -import { updateUser } from "@formbricks/lib/user/service"; import { ZUserNotificationSettings } from "@formbricks/types/user"; const ZUpdateNotificationSettingsAction = z.object({ @@ -11,8 +13,25 @@ const ZUpdateNotificationSettingsAction = z.object({ export const updateNotificationSettingsAction = authenticatedActionClient .schema(ZUpdateNotificationSettingsAction) - .action(async ({ ctx, parsedInput }) => { - await updateUser(ctx.user.id, { - notificationSettings: parsedInput.notificationSettings, - }); - }); + .action( + withAuditLogging( + "updated", + "user", + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: Record; + }) => { + const oldObject = await getUser(ctx.user.id); + const result = await updateUser(ctx.user.id, { + notificationSettings: parsedInput.notificationSettings, + }); + ctx.auditLoggingCtx.userId = ctx.user.id; + ctx.auditLoggingCtx.oldObject = oldObject; + ctx.auditLoggingCtx.newObject = result; + return result; + } + ) + ); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/EditAlerts.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/EditAlerts.test.tsx new file mode 100644 index 000000000000..3f9b56d57992 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/EditAlerts.test.tsx @@ -0,0 +1,267 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TUser } from "@formbricks/types/user"; +import { Membership } from "../types"; +import { EditAlerts } from "./EditAlerts"; + +// Mock dependencies +vi.mock("@/modules/ui/components/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + TooltipProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("lucide-react", () => ({ + HelpCircleIcon: () =>
, + UsersIcon: () =>
, +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + + {children} + + ), +})); + +const mockNotificationSwitch = vi.fn(); +vi.mock("./NotificationSwitch", () => ({ + NotificationSwitch: (props: any) => { + mockNotificationSwitch(props); + return ( +
+ NotificationSwitch +
+ ); + }, +})); + +const mockUser = { + id: "user1", + name: "Test User", + email: "test@example.com", + notificationSettings: { + alert: {}, + unsubscribedOrganizationIds: [], + }, + role: "project_manager", + objective: "other", + emailVerified: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + identityProvider: "email", + twoFactorEnabled: false, +} as unknown as TUser; + +const mockMemberships: Membership[] = [ + { + organization: { + id: "org1", + name: "Organization 1", + projects: [ + { + id: "proj1", + name: "Project 1", + environments: [ + { + id: "env1", + surveys: [ + { id: "survey1", name: "Survey 1 Org 1 Proj 1" }, + { id: "survey2", name: "Survey 2 Org 1 Proj 1" }, + ], + }, + ], + }, + { + id: "proj2", + name: "Project 2", + environments: [ + { + id: "env2", + surveys: [{ id: "survey3", name: "Survey 3 Org 1 Proj 2" }], + }, + ], + }, + ], + }, + }, + { + organization: { + id: "org2", + name: "Organization 2", + projects: [ + { + id: "proj3", + name: "Project 3", + environments: [ + { + id: "env3", + surveys: [{ id: "survey4", name: "Survey 4 Org 2 Proj 3" }], + }, + ], + }, + ], + }, + }, + { + organization: { + id: "org3", + name: "Organization 3 No Surveys", + projects: [ + { + id: "proj4", + name: "Project 4", + environments: [ + { + id: "env4", + surveys: [], // No surveys in this environment + }, + ], + }, + ], + }, + }, +]; + +const environmentId = "test-env-id"; +const autoDisableNotificationType = "someType"; +const autoDisableNotificationElementId = "someElementId"; + +describe("EditAlerts", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders correctly with multiple memberships and surveys", () => { + render( + + ); + + // Check organization names + expect(screen.getByText("Organization 1")).toBeInTheDocument(); + expect(screen.getByText("Organization 2")).toBeInTheDocument(); + expect(screen.getByText("Organization 3 No Surveys")).toBeInTheDocument(); + + // Check survey names and project names as subtext + expect(screen.getByText("Survey 1 Org 1 Proj 1")).toBeInTheDocument(); + expect(screen.getAllByText("Project 1")[0]).toBeInTheDocument(); // Project name under survey + expect(screen.getByText("Survey 2 Org 1 Proj 1")).toBeInTheDocument(); + expect(screen.getByText("Survey 3 Org 1 Proj 2")).toBeInTheDocument(); + expect(screen.getAllByText("Project 2")[0]).toBeInTheDocument(); + expect(screen.getByText("Survey 4 Org 2 Proj 3")).toBeInTheDocument(); + expect(screen.getAllByText("Project 3")[0]).toBeInTheDocument(); + + // Check "No surveys found" message for org3 + const org3Heading = screen.getByText("Organization 3 No Surveys"); + expect(org3Heading.parentElement?.parentElement?.parentElement).toHaveTextContent( + "common.no_surveys_found" + ); + + // Check NotificationSwitch calls + // Org 1 auto-subscribe + expect(mockNotificationSwitch).toHaveBeenCalledWith( + expect.objectContaining({ + surveyOrProjectOrOrganizationId: "org1", + notificationType: "unsubscribedOrganizationIds", + autoDisableNotificationType, + autoDisableNotificationElementId, + }) + ); + // Survey 1 + expect(mockNotificationSwitch).toHaveBeenCalledWith( + expect.objectContaining({ + surveyOrProjectOrOrganizationId: "survey1", + notificationType: "alert", + autoDisableNotificationType, + autoDisableNotificationElementId, + }) + ); + // Survey 4 + expect(mockNotificationSwitch).toHaveBeenCalledWith( + expect.objectContaining({ + surveyOrProjectOrOrganizationId: "survey4", + notificationType: "alert", + autoDisableNotificationType, + autoDisableNotificationElementId, + }) + ); + + // Check tooltip + expect(screen.getAllByTestId("tooltip-provider").length).toBeGreaterThan(0); + expect(screen.getAllByTestId("tooltip").length).toBeGreaterThan(0); + expect(screen.getAllByTestId("tooltip-trigger").length).toBeGreaterThan(0); + expect(screen.getAllByTestId("tooltip-content")[0]).toHaveTextContent( + "environments.settings.notifications.every_response_tooltip" + ); + expect(screen.getAllByTestId("help-circle-icon").length).toBeGreaterThan(0); + + // Check invite link + const inviteLinks = screen.getAllByTestId("link"); + const specificInviteLink = inviteLinks.find( + (link) => link.getAttribute("href") === `/environments/${environmentId}/settings/general` + ); + expect(specificInviteLink).toBeInTheDocument(); + expect(specificInviteLink).toHaveTextContent("common.invite_them"); + + // Check UsersIcon + expect(screen.getAllByTestId("users-icon").length).toBe(mockMemberships.length); + }); + + test("renders correctly when a membership has no surveys", () => { + const singleMembershipNoSurveys: Membership[] = [ + { + organization: { + id: "org-no-survey", + name: "Org Without Surveys", + projects: [ + { + id: "proj-no-survey", + name: "Project Without Surveys", + environments: [ + { + id: "env-no-survey", + surveys: [], + }, + ], + }, + ], + }, + }, + ]; + render( + + ); + + expect(screen.getByText("Org Without Surveys")).toBeInTheDocument(); + expect(screen.getByText("common.no_surveys_found")).toBeInTheDocument(); + expect(screen.queryByText("Survey 1 Org 1 Proj 1")).not.toBeInTheDocument(); // Ensure other surveys aren't rendered + + // Check NotificationSwitch for organization auto-subscribe + expect(mockNotificationSwitch).toHaveBeenCalledWith( + expect.objectContaining({ + surveyOrProjectOrOrganizationId: "org-no-survey", + notificationType: "unsubscribedOrganizationIds", + }) + ); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/EditWeeklySummary.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/EditWeeklySummary.tsx deleted file mode 100644 index 5f99be8309d0..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/EditWeeklySummary.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { useTranslate } from "@tolgee/react"; -import { UsersIcon } from "lucide-react"; -import Link from "next/link"; -import { TUser } from "@formbricks/types/user"; -import { Membership } from "../types"; -import { NotificationSwitch } from "./NotificationSwitch"; - -interface EditAlertsProps { - memberships: Membership[]; - user: TUser; - environmentId: string; -} - -export const EditWeeklySummary = ({ memberships, user, environmentId }: EditAlertsProps) => { - const { t } = useTranslate(); - return ( - <> - {memberships.map((membership) => ( -
-
- - -

{membership.organization.name}

-
-
-
-
{t("common.project")}
-
{t("common.weekly_summary")}
-
-
- {membership.organization.projects.map((project) => ( -
-
{project?.name}
-
- -
-
- ))} -
-

- {t("environments.settings.notifications.want_to_loop_in_organization_mates")}?{" "} - - {t("common.invite_them")} - -

-
-
- ))} - - ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/IntegrationsTip.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/IntegrationsTip.test.tsx new file mode 100644 index 000000000000..019b8c526c2a --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/IntegrationsTip.test.tsx @@ -0,0 +1,36 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { IntegrationsTip } from "./IntegrationsTip"; + +vi.mock("@/modules/ui/components/icons", () => ({ + SlackIcon: () =>
, +})); + +const mockT = vi.fn((key) => key); +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: mockT, + }), +})); + +const environmentId = "test-env-id"; + +describe("IntegrationsTip", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders the component with correct text and link", () => { + render(); + + expect(screen.getByTestId("slack-icon")).toBeInTheDocument(); + expect( + screen.getByText("environments.settings.notifications.need_slack_or_discord_notifications?") + ).toBeInTheDocument(); + + const linkElement = screen.getByText("environments.settings.notifications.use_the_integration"); + expect(linkElement).toBeInTheDocument(); + expect(linkElement).toHaveAttribute("href", `/environments/${environmentId}/integrations`); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.test.tsx new file mode 100644 index 000000000000..7143498be26b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.test.tsx @@ -0,0 +1,410 @@ +import { act, cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TUserNotificationSettings } from "@formbricks/types/user"; +import { updateNotificationSettingsAction } from "../actions"; +import { NotificationSwitch } from "./NotificationSwitch"; + +vi.mock("@/modules/ui/components/switch", () => ({ + Switch: vi.fn(({ checked, disabled, onCheckedChange, id, "aria-label": ariaLabel }) => ( + + )), +})); + +vi.mock("../actions", () => ({ + updateNotificationSettingsAction: vi.fn(() => Promise.resolve({ data: true })), +})); + +const surveyId = "survey1"; +const projectId = "project1"; +const organizationId = "org1"; + +const baseNotificationSettings: TUserNotificationSettings = { + alert: {}, + unsubscribedOrganizationIds: [], +}; + +describe("NotificationSwitch", () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + const renderSwitch = (props: Partial>) => { + const defaultProps: React.ComponentProps = { + surveyOrProjectOrOrganizationId: surveyId, + notificationSettings: JSON.parse(JSON.stringify(baseNotificationSettings)), + notificationType: "alert", + }; + return render(); + }; + + test("renders with initial checked state for 'alert' (true)", () => { + const settings = { ...baseNotificationSettings, alert: { [surveyId]: true } }; + renderSwitch({ notificationSettings: settings, notificationType: "alert" }); + const switchInput = screen.getByLabelText("toggle notification settings for alert") as HTMLInputElement; + expect(switchInput.checked).toBe(true); + }); + + test("renders with initial checked state for 'alert' (false)", () => { + const settings = { ...baseNotificationSettings, alert: { [surveyId]: false } }; + renderSwitch({ notificationSettings: settings, notificationType: "alert" }); + const switchInput = screen.getByLabelText("toggle notification settings for alert") as HTMLInputElement; + expect(switchInput.checked).toBe(false); + }); + + test("renders with initial checked state for 'unsubscribedOrganizationIds' (subscribed initially, so checked is true)", () => { + const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] }; + renderSwitch({ + surveyOrProjectOrOrganizationId: organizationId, + notificationSettings: settings, + notificationType: "unsubscribedOrganizationIds", + }); + const switchInput = screen.getByLabelText( + "toggle notification settings for unsubscribedOrganizationIds" + ) as HTMLInputElement; + expect(switchInput.checked).toBe(true); // Not in unsubscribed list means subscribed + }); + + test("renders with initial checked state for 'unsubscribedOrganizationIds' (unsubscribed initially, so checked is false)", () => { + const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [organizationId] }; + renderSwitch({ + surveyOrProjectOrOrganizationId: organizationId, + notificationSettings: settings, + notificationType: "unsubscribedOrganizationIds", + }); + const switchInput = screen.getByLabelText( + "toggle notification settings for unsubscribedOrganizationIds" + ) as HTMLInputElement; + expect(switchInput.checked).toBe(false); // In unsubscribed list means unsubscribed + }); + + test("handles switch change for 'alert' type", async () => { + const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } }; + renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" }); + const switchInput = screen.getByLabelText("toggle notification settings for alert"); + + await act(async () => { + await user.click(switchInput); + }); + + expect(updateNotificationSettingsAction).toHaveBeenCalledWith({ + notificationSettings: { ...initialSettings, alert: { [surveyId]: true } }, + }); + expect(toast.success).toHaveBeenCalledWith( + "environments.settings.notifications.notification_settings_updated", + { id: "notification-switch" } + ); + expect(switchInput).toBeEnabled(); // Check if not disabled after action + }); + + test("handles switch change for 'unsubscribedOrganizationIds' (subscribe)", async () => { + const initialSettings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [organizationId] }; // initially unsubscribed + renderSwitch({ + surveyOrProjectOrOrganizationId: organizationId, + notificationSettings: initialSettings, + notificationType: "unsubscribedOrganizationIds", + }); + const switchInput = screen.getByLabelText("toggle notification settings for unsubscribedOrganizationIds"); + + await act(async () => { + await user.click(switchInput); + }); + + expect(updateNotificationSettingsAction).toHaveBeenCalledWith({ + notificationSettings: { ...initialSettings, unsubscribedOrganizationIds: [] }, // should be removed from list + }); + expect(toast.success).toHaveBeenCalledWith( + "environments.settings.notifications.notification_settings_updated", + { id: "notification-switch" } + ); + }); + + test("handles switch change for 'unsubscribedOrganizationIds' (unsubscribe)", async () => { + const initialSettings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] }; // initially subscribed + renderSwitch({ + surveyOrProjectOrOrganizationId: organizationId, + notificationSettings: initialSettings, + notificationType: "unsubscribedOrganizationIds", + }); + const switchInput = screen.getByLabelText("toggle notification settings for unsubscribedOrganizationIds"); + + await act(async () => { + await user.click(switchInput); + }); + + expect(updateNotificationSettingsAction).toHaveBeenCalledWith({ + notificationSettings: { ...initialSettings, unsubscribedOrganizationIds: [organizationId] }, // should be added to list + }); + expect(toast.success).toHaveBeenCalledWith( + "environments.settings.notifications.notification_settings_updated", + { id: "notification-switch" } + ); + }); + + test("useEffect: auto-disables 'alert' notification if conditions met", () => { + const settings = { ...baseNotificationSettings, alert: { [surveyId]: true } }; // Initially true + renderSwitch({ + surveyOrProjectOrOrganizationId: surveyId, + notificationSettings: settings, + notificationType: "alert", + autoDisableNotificationType: "alert", + autoDisableNotificationElementId: surveyId, + }); + + expect(updateNotificationSettingsAction).toHaveBeenCalledWith({ + notificationSettings: { ...settings, alert: { [surveyId]: false } }, + }); + expect(toast.success).toHaveBeenCalledWith( + "environments.settings.notifications.you_will_not_receive_any_more_emails_for_responses_on_this_survey", + { id: "notification-switch" } + ); + }); + + test("useEffect: auto-disables 'unsubscribedOrganizationIds' (auto-unsubscribes) if conditions met", () => { + const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] }; // Initially subscribed + renderSwitch({ + surveyOrProjectOrOrganizationId: organizationId, + notificationSettings: settings, + notificationType: "unsubscribedOrganizationIds", + autoDisableNotificationType: "someOtherType", // This prop is used to trigger the effect, not directly for type matching in this case + autoDisableNotificationElementId: organizationId, + }); + + expect(updateNotificationSettingsAction).toHaveBeenCalledWith({ + notificationSettings: { ...settings, unsubscribedOrganizationIds: [organizationId] }, + }); + expect(toast.success).toHaveBeenCalledWith( + "environments.settings.notifications.you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore", + { id: "notification-switch" } + ); + }); + + test("useEffect: does not auto-disable if 'autoDisableNotificationElementId' does not match", () => { + const settings = { ...baseNotificationSettings, alert: { [surveyId]: true } }; + renderSwitch({ + surveyOrProjectOrOrganizationId: surveyId, + notificationSettings: settings, + notificationType: "alert", + autoDisableNotificationType: "alert", + autoDisableNotificationElementId: "otherId", // Mismatch + }); + expect(updateNotificationSettingsAction).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalledWith( + "environments.settings.notifications.you_will_not_receive_any_more_emails_for_responses_on_this_survey" + ); + }); + + test("useEffect: does not auto-disable if not checked initially for 'alert'", () => { + const settings = { ...baseNotificationSettings, alert: { [surveyId]: false } }; // Initially false + renderSwitch({ + surveyOrProjectOrOrganizationId: surveyId, + notificationSettings: settings, + notificationType: "alert", + autoDisableNotificationType: "alert", + autoDisableNotificationElementId: surveyId, + }); + expect(updateNotificationSettingsAction).not.toHaveBeenCalled(); + }); + + test("useEffect: does not auto-disable if not checked initially for 'unsubscribedOrganizationIds' (already unsubscribed)", () => { + const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [organizationId] }; // Initially unsubscribed + renderSwitch({ + surveyOrProjectOrOrganizationId: organizationId, + notificationSettings: settings, + notificationType: "unsubscribedOrganizationIds", + autoDisableNotificationType: "someType", + autoDisableNotificationElementId: organizationId, + }); + expect(updateNotificationSettingsAction).not.toHaveBeenCalled(); + }); + + test("shows error toast when updateNotificationSettingsAction fails for 'alert' type", async () => { + const mockErrorResponse = { serverError: "Failed to update notification settings" }; + vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse); + + const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } }; + renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" }); + const switchInput = screen.getByLabelText("toggle notification settings for alert"); + + await act(async () => { + await user.click(switchInput); + }); + + expect(updateNotificationSettingsAction).toHaveBeenCalledWith({ + notificationSettings: { ...initialSettings, alert: { [surveyId]: true } }, + }); + expect(toast.error).toHaveBeenCalledWith("Failed to update notification settings", { + id: "notification-switch", + }); + expect(toast.success).not.toHaveBeenCalled(); + }); + + test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => { + const mockErrorResponse = { serverError: "Permission denied" }; + vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse); + + const initialSettings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] }; + renderSwitch({ + surveyOrProjectOrOrganizationId: organizationId, + notificationSettings: initialSettings, + notificationType: "unsubscribedOrganizationIds", + }); + const switchInput = screen.getByLabelText("toggle notification settings for unsubscribedOrganizationIds"); + + await act(async () => { + await user.click(switchInput); + }); + + expect(updateNotificationSettingsAction).toHaveBeenCalledWith({ + notificationSettings: { ...initialSettings, unsubscribedOrganizationIds: [organizationId] }, + }); + expect(toast.error).toHaveBeenCalledWith("Permission denied", { + id: "notification-switch", + }); + expect(toast.success).not.toHaveBeenCalled(); + }); + + test("shows error toast when updateNotificationSettingsAction returns null", async () => { + const mockErrorResponse = { serverError: "An error occurred" }; + vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse); + + const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } }; + renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" }); + const switchInput = screen.getByLabelText("toggle notification settings for alert"); + + await act(async () => { + await user.click(switchInput); + }); + + expect(updateNotificationSettingsAction).toHaveBeenCalledWith({ + notificationSettings: { ...initialSettings, alert: { [surveyId]: true } }, + }); + expect(toast.error).toHaveBeenCalledWith("An error occurred", { + id: "notification-switch", + }); + expect(toast.success).not.toHaveBeenCalled(); + }); + + test("shows error toast when updateNotificationSettingsAction returns undefined", async () => { + const mockErrorResponse = { serverError: "An error occurred" }; + vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse); + + const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } }; + renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" }); + const switchInput = screen.getByLabelText("toggle notification settings for alert"); + + await act(async () => { + await user.click(switchInput); + }); + + expect(updateNotificationSettingsAction).toHaveBeenCalledWith({ + notificationSettings: { ...initialSettings, alert: { [surveyId]: true } }, + }); + expect(toast.error).toHaveBeenCalledWith("An error occurred", { + id: "notification-switch", + }); + expect(toast.success).not.toHaveBeenCalled(); + }); + + test("shows error toast when updateNotificationSettingsAction returns response without data property", async () => { + const mockErrorResponse = { validationErrors: { _errors: ["Invalid input"] } }; + vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse); + + const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } }; + renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" }); + const switchInput = screen.getByLabelText("toggle notification settings for alert"); + + await act(async () => { + await user.click(switchInput); + }); + + expect(updateNotificationSettingsAction).toHaveBeenCalledWith({ + notificationSettings: { ...initialSettings, alert: { [surveyId]: true } }, + }); + expect(toast.error).toHaveBeenCalledWith("Invalid input", { + id: "notification-switch", + }); + expect(toast.success).not.toHaveBeenCalled(); + }); + + test("shows error toast when updateNotificationSettingsAction throws an exception", async () => { + const mockErrorResponse = { serverError: "Network error" }; + vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse); + + const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } }; + renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" }); + const switchInput = screen.getByLabelText("toggle notification settings for alert"); + + await act(async () => { + await user.click(switchInput); + }); + + expect(updateNotificationSettingsAction).toHaveBeenCalledWith({ + notificationSettings: { ...initialSettings, alert: { [surveyId]: true } }, + }); + expect(toast.error).toHaveBeenCalledWith("Network error", { + id: "notification-switch", + }); + expect(toast.success).not.toHaveBeenCalled(); + }); + + test("switch remains enabled after error occurs", async () => { + const mockErrorResponse = { serverError: "Failed to update" }; + vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse); + + const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } }; + renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" }); + const switchInput = screen.getByLabelText("toggle notification settings for alert"); + + await act(async () => { + await user.click(switchInput); + }); + + expect(toast.error).toHaveBeenCalledWith("Failed to update", { + id: "notification-switch", + }); + expect(switchInput).toBeEnabled(); // Switch should be re-enabled after error + }); + + test("shows error toast with validation errors for specific fields", async () => { + const mockErrorResponse = { + validationErrors: { + notificationSettings: { + _errors: ["Invalid notification settings"], + }, + }, + }; + vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse); + + const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } }; + renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" }); + const switchInput = screen.getByLabelText("toggle notification settings for alert"); + + await act(async () => { + await user.click(switchInput); + }); + + expect(updateNotificationSettingsAction).toHaveBeenCalledWith({ + notificationSettings: { ...initialSettings, alert: { [surveyId]: true } }, + }); + expect(toast.error).toHaveBeenCalledWith("notificationSettingsInvalid notification settings", { + id: "notification-switch", + }); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.tsx index 20ab38f206cf..deedc049b57d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/NotificationSwitch.tsx @@ -1,7 +1,9 @@ "use client"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { Switch } from "@/modules/ui/components/switch"; import { useTranslate } from "@tolgee/react"; +import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { TUserNotificationSettings } from "@formbricks/types/user"; @@ -10,7 +12,7 @@ import { updateNotificationSettingsAction } from "../actions"; interface NotificationSwitchProps { surveyOrProjectOrOrganizationId: string; notificationSettings: TUserNotificationSettings; - notificationType: "alert" | "weeklySummary" | "unsubscribedOrganizationIds"; + notificationType: "alert" | "unsubscribedOrganizationIds"; autoDisableNotificationType?: string; autoDisableNotificationElementId?: string; } @@ -24,6 +26,7 @@ export const NotificationSwitch = ({ }: NotificationSwitchProps) => { const [isLoading, setIsLoading] = useState(false); const { t } = useTranslate(); + const router = useRouter(); const isChecked = notificationType === "unsubscribedOrganizationIds" ? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId) @@ -50,7 +53,20 @@ export const NotificationSwitch = ({ !updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId]; } - await updateNotificationSettingsAction({ notificationSettings: updatedNotificationSettings }); + const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({ + notificationSettings: updatedNotificationSettings, + }); + if (updatedNotificationSettingsActionResponse?.data) { + toast.success(t("environments.settings.notifications.notification_settings_updated"), { + id: "notification-switch", + }); + router.refresh(); + } else { + const errorMessage = getFormattedErrorMessage(updatedNotificationSettingsActionResponse); + toast.error(errorMessage, { + id: "notification-switch", + }); + } setIsLoading(false); }; @@ -104,9 +120,6 @@ export const NotificationSwitch = ({ disabled={isLoading} onCheckedChange={async () => { await handleSwitchChange(); - toast.success(t("environments.settings.notifications.notification_settings_updated"), { - id: "notification-switch", - }); }} /> ); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/loading.test.tsx new file mode 100644 index 000000000000..ea2fbf0cd0d0 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/loading.test.tsx @@ -0,0 +1,38 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import Loading from "./loading"; + +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: ({ pageTitle }: { pageTitle: string }) =>
{pageTitle}
, +})); + +describe("Loading Notifications Settings", () => { + afterEach(() => { + cleanup(); + }); + + test("renders loading state correctly", () => { + render(); + + expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument(); + const pageHeader = screen.getByTestId("page-header"); + expect(pageHeader).toBeInTheDocument(); + expect(pageHeader).toHaveTextContent("common.account_settings"); + + // Check for Alerts LoadingCard + expect(screen.getByText("environments.settings.notifications.email_alerts_surveys")).toBeInTheDocument(); + expect( + screen.getByText("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses") + ).toBeInTheDocument(); + const alertsCard = screen + .getByText("environments.settings.notifications.email_alerts_surveys") + .closest("div[class*='rounded-xl']"); // Find parent card + expect(alertsCard).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/loading.tsx index d636572eb17a..2b074d0963e8 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/loading.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/loading.tsx @@ -14,11 +14,6 @@ const Loading = () => { description: t("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"), skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }], }, - { - title: t("environments.settings.notifications.weekly_summary_projects"), - description: t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday"), - skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }], - }, ]; return ( diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/page.test.tsx new file mode 100644 index 000000000000..40a86c30cd4e --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/page.test.tsx @@ -0,0 +1,228 @@ +import { getUser } from "@/lib/user/service"; +import { cleanup, render, screen } from "@testing-library/react"; +import { getServerSession } from "next-auth"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { TUser } from "@formbricks/types/user"; +import { EditAlerts } from "./components/EditAlerts"; +import Page from "./page"; +import { Membership } from "./types"; + +// Mock external dependencies +vi.mock( + "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar", + () => ({ + AccountSettingsNavbar: ({ activeId }) =>
AccountSettingsNavbar activeId={activeId}
, + }) +); +vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({ + SettingsCard: ({ title, description, children }) => ( +
+

{title}

+

{description}

+ {children} +
+ ), +})); +vi.mock("@/lib/user/service", () => ({ + getUser: vi.fn(), +})); +vi.mock("@/modules/auth/lib/authOptions", () => ({ + authOptions: {}, +})); +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: ({ children }) =>
{children}
, +})); +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: ({ pageTitle, children }) => ( +
+

{pageTitle}

+ {children} +
+ ), +})); +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); +vi.mock("@formbricks/database", () => ({ + prisma: { + membership: { + findMany: vi.fn(), + }, + }, +})); +vi.mock("./components/EditAlerts", () => ({ + EditAlerts: vi.fn(() =>
EditAlertsComponent
), +})); + +vi.mock("./components/IntegrationsTip", () => ({ + IntegrationsTip: () =>
IntegrationsTipComponent
, +})); + +const mockUser: Partial = { + id: "user-1", + name: "Test User", + email: "test@example.com", + notificationSettings: { + alert: { "survey-old": true }, + unsubscribedOrganizationIds: ["org-unsubscribed"], + }, +}; + +const mockMemberships: Membership[] = [ + { + organization: { + id: "org-1", + name: "Org 1", + projects: [ + { + id: "project-1", + name: "Project 1", + environments: [ + { + id: "env-prod-1", + surveys: [ + { id: "survey-1", name: "Survey 1" }, + { id: "survey-2", name: "Survey 2" }, + ], + }, + ], + }, + ], + }, + }, +]; + +const mockSession = { + user: { + id: "user-1", + }, +} as any; + +const mockParams = { environmentId: "env-1" }; +const mockSearchParams = { + type: "alertTest", + elementId: "elementTestId", +}; + +describe("NotificationsPage", () => { + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + beforeEach(() => { + vi.mocked(getServerSession).mockResolvedValue(mockSession); + vi.mocked(getUser).mockResolvedValue(mockUser as TUser); + vi.mocked(prisma.membership.findMany).mockResolvedValue(mockMemberships as any); // Prisma types can be complex + }); + + test("renders correctly with user and memberships, and processes notification settings", async () => { + const props = { params: mockParams, searchParams: mockSearchParams }; + const PageComponent = await Page(props); + render(PageComponent); + + expect(screen.getByText("common.account_settings")).toBeInTheDocument(); + expect(screen.getByText("AccountSettingsNavbar activeId=notifications")).toBeInTheDocument(); + expect(screen.getByText("environments.settings.notifications.email_alerts_surveys")).toBeInTheDocument(); + expect( + screen.getByText("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses") + ).toBeInTheDocument(); + expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument(); + expect(screen.getByText("IntegrationsTipComponent")).toBeInTheDocument(); + + // The actual `user.notificationSettings` passed to EditAlerts will be a new object + // after `setCompleteNotificationSettings` processes it. + // We verify the structure and defaults. + const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0]; + expect(editAlertsCall.user.notificationSettings.alert["survey-1"]).toBe(false); + expect(editAlertsCall.user.notificationSettings.alert["survey-2"]).toBe(false); + // If "survey-old" was not part of any membership survey, it might be removed or kept depending on exact logic. + // The current logic only adds keys from memberships. So "survey-old" would be gone from .alert + // Let's adjust expectation based on `setCompleteNotificationSettings` + // It iterates memberships, then projects, then environments, then surveys. + // `newNotificationSettings.alert[survey.id] = notificationSettings[survey.id]?.responseFinished || (notificationSettings.alert && notificationSettings.alert[survey.id]) || false;` + // This means only survey IDs found in memberships will be in the new `alert` object. + + const finalExpectedSettings = { + alert: { + "survey-1": false, + "survey-2": false, + }, + unsubscribedOrganizationIds: ["org-unsubscribed"], + }; + + expect(editAlertsCall.user.notificationSettings).toEqual(finalExpectedSettings); + expect(editAlertsCall.memberships).toEqual(mockMemberships); + expect(editAlertsCall.environmentId).toBe(mockParams.environmentId); + expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type); + expect(editAlertsCall.autoDisableNotificationElementId).toBe(mockSearchParams.elementId); + }); + + test("throws error if session is not found", async () => { + vi.mocked(getServerSession).mockResolvedValue(null); + const props = { params: mockParams, searchParams: {} }; + await expect(Page(props)).rejects.toThrow("common.session_not_found"); + }); + + test("throws error if user is not found", async () => { + vi.mocked(getUser).mockResolvedValue(null); + const props = { params: mockParams, searchParams: {} }; + await expect(Page(props)).rejects.toThrow("common.user_not_found"); + }); + + test("renders with empty memberships and default notification settings", async () => { + vi.mocked(prisma.membership.findMany).mockResolvedValue([]); + const userWithNoSpecificSettings = { + ...mockUser, + notificationSettings: { unsubscribedOrganizationIds: [] }, // Start fresh + }; + vi.mocked(getUser).mockResolvedValue(userWithNoSpecificSettings as unknown as TUser); + + const props = { params: mockParams, searchParams: {} }; + const PageComponent = await Page(props); + render(PageComponent); + + expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument(); + + const expectedEmptySettings = { + alert: {}, + unsubscribedOrganizationIds: [], + }; + + const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0]; + expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings); + expect(editAlertsCall.memberships).toEqual([]); + }); + + test("handles legacy notification settings correctly", async () => { + const userWithLegacySettings: Partial = { + id: "user-legacy", + notificationSettings: { + "survey-1": { responseFinished: true }, // Legacy alert for survey-1 + unsubscribedOrganizationIds: [], + } as any, // To allow legacy structure + }; + vi.mocked(getUser).mockResolvedValue(userWithLegacySettings as TUser); + // Memberships define survey-1 and project-1 + vi.mocked(prisma.membership.findMany).mockResolvedValue(mockMemberships as any); + + const props = { params: mockParams, searchParams: {} }; + const PageComponent = await Page(props); + render(PageComponent); + + const expectedProcessedSettings = { + alert: { + "survey-1": true, // Should be true due to legacy setting + "survey-2": false, // Default for other surveys in membership + }, + unsubscribedOrganizationIds: [], + }; + + const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0]; + expect(editAlertsCall.user.notificationSettings).toEqual(expectedProcessedSettings); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/page.tsx index 51e941b4f38e..8cc08c7b0266 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/notifications/page.tsx @@ -1,15 +1,14 @@ import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; +import { getUser } from "@/lib/user/service"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getTranslate } from "@/tolgee/server"; import { getServerSession } from "next-auth"; import { prisma } from "@formbricks/database"; -import { getUser } from "@formbricks/lib/user/service"; import { TUserNotificationSettings } from "@formbricks/types/user"; import { EditAlerts } from "./components/EditAlerts"; -import { EditWeeklySummary } from "./components/EditWeeklySummary"; import { IntegrationsTip } from "./components/IntegrationsTip"; import type { Membership } from "./types"; @@ -19,14 +18,10 @@ const setCompleteNotificationSettings = ( ): TUserNotificationSettings => { const newNotificationSettings = { alert: {}, - weeklySummary: {}, unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [], }; for (const membership of memberships) { for (const project of membership.organization.projects) { - // set default values for weekly summary - newNotificationSettings.weeklySummary[project.id] = - (notificationSettings.weeklySummary && notificationSettings.weeklySummary[project.id]) || false; // set default values for alerts for (const environment of project.environments) { for (const survey of environment.surveys) { @@ -183,11 +178,6 @@ const Page = async (props) => { /> - - - ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts index fda5844b9f12..dc334479148b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts @@ -1,49 +1,168 @@ "use server"; +import { + getIsEmailUnique, + verifyUserPassword, +} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user"; +import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants"; +import { deleteFile } from "@/lib/storage/service"; +import { getFileNameWithIdFromUrl } from "@/lib/storage/utils"; +import { getUser, updateUser } from "@/lib/user/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; +import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email"; import { z } from "zod"; -import { deleteFile } from "@formbricks/lib/storage/service"; -import { getFileNameWithIdFromUrl } from "@formbricks/lib/storage/utils"; -import { updateUser } from "@formbricks/lib/user/service"; import { ZId } from "@formbricks/types/common"; -import { ZUserUpdateInput } from "@formbricks/types/user"; +import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors"; +import { + TUserPersonalInfoUpdateInput, + TUserUpdateInput, + ZUserPersonalInfoUpdateInput, +} from "@formbricks/types/user"; -export const updateUserAction = authenticatedActionClient - .schema(ZUserUpdateInput.partial()) - .action(async ({ parsedInput, ctx }) => { - return await updateUser(ctx.user.id, parsedInput); - }); +function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput { + return { + ...(parsedInput.name && { name: parsedInput.name }), + ...(parsedInput.locale && { locale: parsedInput.locale }), + }; +} + +async function handleEmailUpdate({ + ctx, + parsedInput, + payload, +}: { + ctx: AuthenticatedActionClientCtx; + parsedInput: TUserPersonalInfoUpdateInput; + payload: TUserUpdateInput; +}) { + const inputEmail = parsedInput.email?.trim().toLowerCase(); + if (!inputEmail || ctx.user.email === inputEmail) return payload; + + await applyRateLimit(rateLimitConfigs.actions.emailUpdate, ctx.user.id); + + if (ctx.user.identityProvider !== "email") { + throw new OperationNotAllowedError("Email update is not allowed for non-credential users."); + } + if (!parsedInput.password) { + throw new AuthenticationError("Password is required to update email."); + } + const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password); + if (!isCorrectPassword) { + throw new AuthorizationError("Incorrect credentials"); + } + const isEmailUnique = await getIsEmailUnique(inputEmail); + if (!isEmailUnique) return payload; + + if (EMAIL_VERIFICATION_DISABLED) { + payload.email = inputEmail; + await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail }); + } else { + await sendVerificationNewEmail(ctx.user.id, inputEmail); + } + return payload; +} + +export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action( + withAuditLogging( + "updated", + "user", + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: TUserPersonalInfoUpdateInput; + }) => { + const oldObject = await getUser(ctx.user.id); + let payload = buildUserUpdatePayload(parsedInput); + payload = await handleEmailUpdate({ ctx, parsedInput, payload }); + + // Only proceed with updateUser if we have actual changes to make + let newObject = oldObject; + if (Object.keys(payload).length > 0) { + newObject = await updateUser(ctx.user.id, payload); + } + + ctx.auditLoggingCtx.userId = ctx.user.id; + ctx.auditLoggingCtx.oldObject = oldObject; + ctx.auditLoggingCtx.newObject = newObject; + + return true; + } + ) +); const ZUpdateAvatarAction = z.object({ avatarUrl: z.string(), }); -export const updateAvatarAction = authenticatedActionClient - .schema(ZUpdateAvatarAction) - .action(async ({ parsedInput, ctx }) => { - return await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl }); - }); +export const updateAvatarAction = authenticatedActionClient.schema(ZUpdateAvatarAction).action( + withAuditLogging( + "updated", + "user", + async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => { + const oldObject = await getUser(ctx.user.id); + const result = await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl }); + ctx.auditLoggingCtx.userId = ctx.user.id; + ctx.auditLoggingCtx.oldObject = oldObject; + ctx.auditLoggingCtx.newObject = result; + return result; + } + ) +); const ZRemoveAvatarAction = z.object({ environmentId: ZId, }); -export const removeAvatarAction = authenticatedActionClient - .schema(ZRemoveAvatarAction) - .action(async ({ parsedInput, ctx }) => { - const imageUrl = ctx.user.imageUrl; - if (!imageUrl) { - throw new Error("Image not found"); - } +export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatarAction).action( + withAuditLogging( + "updated", + "user", + async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => { + const oldObject = await getUser(ctx.user.id); + const imageUrl = ctx.user.imageUrl; + if (!imageUrl) { + throw new Error("Image not found"); + } + + const fileName = getFileNameWithIdFromUrl(imageUrl); + if (!fileName) { + throw new Error("Invalid filename"); + } - const fileName = getFileNameWithIdFromUrl(imageUrl); - if (!fileName) { - throw new Error("Invalid filename"); + const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName); + if (!deletionResult.success) { + throw new Error("Deletion failed"); + } + const result = await updateUser(ctx.user.id, { imageUrl: null }); + ctx.auditLoggingCtx.userId = ctx.user.id; + ctx.auditLoggingCtx.oldObject = oldObject; + ctx.auditLoggingCtx.newObject = result; + return result; } + ) +); + +export const resetPasswordAction = authenticatedActionClient.action( + withAuditLogging( + "passwordReset", + "user", + async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => { + if (ctx.user.identityProvider !== "email") { + throw new OperationNotAllowedError("Password reset is not allowed for this user."); + } + + await sendForgotPasswordEmail(ctx.user); + + ctx.auditLoggingCtx.userId = ctx.user.id; - const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName); - if (!deletionResult.success) { - throw new Error("Deletion failed"); + return { success: true }; } - return await updateUser(ctx.user.id, { imageUrl: null }); - }); + ) +); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity.test.tsx new file mode 100644 index 000000000000..835e669e5f07 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity.test.tsx @@ -0,0 +1,70 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TUser } from "@formbricks/types/user"; +import { AccountSecurity } from "./AccountSecurity"; + +vi.mock("@/modules/ee/two-factor-auth/components/enable-two-factor-modal", () => ({ + EnableTwoFactorModal: ({ open }) => + open ?
EnableTwoFactorModal
: null, +})); + +vi.mock("@/modules/ee/two-factor-auth/components/disable-two-factor-modal", () => ({ + DisableTwoFactorModal: ({ open }) => + open ?
DisableTwoFactorModal
: null, +})); + +const mockUser = { + id: "test-user-id", + name: "Test User", + email: "test@example.com", + notificationSettings: { + alert: {}, + + unsubscribedOrganizationIds: [], + }, + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + role: "project_manager", + objective: "other", +} as unknown as TUser; + +describe("AccountSecurity", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("renders correctly with 2FA disabled", () => { + render(); + expect(screen.getByText("environments.settings.profile.two_factor_authentication")).toBeInTheDocument(); + expect( + screen.getByText("environments.settings.profile.two_factor_authentication_description") + ).toBeInTheDocument(); + expect(screen.getByRole("switch")).not.toBeChecked(); + }); + + test("renders correctly with 2FA enabled", () => { + render(); + expect(screen.getByRole("switch")).toBeChecked(); + }); + + test("opens EnableTwoFactorModal when switch is turned on", async () => { + render(); + const switchControl = screen.getByRole("switch"); + await userEvent.click(switchControl); + expect(screen.getByTestId("enable-2fa-modal")).toBeInTheDocument(); + }); + + test("opens DisableTwoFactorModal when switch is turned off", async () => { + render(); + const switchControl = screen.getByRole("switch"); + await userEvent.click(switchControl); + expect(screen.getByTestId("disable-2fa-modal")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.test.tsx new file mode 100644 index 000000000000..156be7ee0ccb --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.test.tsx @@ -0,0 +1,97 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Session } from "next-auth"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TUser } from "@formbricks/types/user"; +import { DeleteAccount } from "./DeleteAccount"; + +vi.mock("@/modules/account/components/DeleteAccountModal", () => ({ + DeleteAccountModal: ({ open }) => + open ?
DeleteAccountModal
: null, +})); + +const mockUser = { + id: "user1", + name: "Test User", + email: "test@example.com", + notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] }, + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + role: "project_manager", + objective: "other", +} as unknown as TUser; + +const mockSession: Session = { + user: mockUser, + expires: new Date(Date.now() + 2 * 86400).toISOString(), +}; + +const mockOrganizations: TOrganization[] = [ + { + id: "org1", + name: "Org 1", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: "cus_123", + } as unknown as TOrganization["billing"], + } as unknown as TOrganization, +]; + +describe("DeleteAccount", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("renders correctly and opens modal on click", async () => { + render( + + ); + + expect(screen.getByText("environments.settings.profile.warning_cannot_undo")).toBeInTheDocument(); + const deleteButton = screen.getByText("environments.settings.profile.confirm_delete_my_account"); + expect(deleteButton).toBeEnabled(); + await userEvent.click(deleteButton); + expect(screen.getByTestId("delete-account-modal")).toBeInTheDocument(); + }); + + test("renders null if session is not provided", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + test("enables delete button if multi-org enabled even if user is single owner", () => { + render( + + ); + const deleteButton = screen.getByText("environments.settings.profile.confirm_delete_my_account"); + expect(deleteButton).toBeEnabled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.tsx index 2afe7f47e410..a83687403ddb 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount.tsx @@ -1,6 +1,5 @@ "use client"; -import { formbricksLogout } from "@/app/lib/formbricks"; import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal"; import { Button } from "@/modules/ui/components/button"; import { TooltipRenderer } from "@/modules/ui/components/tooltip"; @@ -37,7 +36,6 @@ export const DeleteAccount = ({ setOpen={setModalOpen} user={user} isFormbricksCloud={IS_FORMBRICKS_CLOUD} - formbricksLogout={formbricksLogout} organizationsWithSingleOwner={organizationsWithSingleOwner} />

diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileAvatarForm.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileAvatarForm.test.tsx new file mode 100644 index 000000000000..8d599df81e1f --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileAvatarForm.test.tsx @@ -0,0 +1,104 @@ +import * as profileActions from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions"; +import * as fileUploadHooks from "@/app/lib/fileUpload"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Session } from "next-auth"; +import toast from "react-hot-toast"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { EditProfileAvatarForm } from "./EditProfileAvatarForm"; + +vi.mock("@/modules/ui/components/avatars", () => ({ + ProfileAvatar: ({ imageUrl }) =>

{imageUrl || "No Avatar"}
, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: vi.fn(), + }), +})); + +vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({ + updateAvatarAction: vi.fn(), + removeAvatarAction: vi.fn(), +})); + +vi.mock("@/app/lib/fileUpload", () => ({ + handleFileUpload: vi.fn(), +})); + +const mockSession: Session = { + user: { id: "user-id" }, + expires: "session-expires-at", +}; +const environmentId = "test-env-id"; + +describe("EditProfileAvatarForm", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + beforeEach(() => { + vi.mocked(profileActions.updateAvatarAction).mockResolvedValue({}); + vi.mocked(profileActions.removeAvatarAction).mockResolvedValue({}); + vi.mocked(fileUploadHooks.handleFileUpload).mockResolvedValue({ + url: "new-avatar.jpg", + error: undefined, + }); + }); + + test("renders correctly without an existing image", () => { + render(); + expect(screen.getByTestId("profile-avatar")).toHaveTextContent("No Avatar"); + expect(screen.getByText("environments.settings.profile.upload_image")).toBeInTheDocument(); + expect(screen.queryByText("environments.settings.profile.remove_image")).not.toBeInTheDocument(); + }); + + test("renders correctly with an existing image", () => { + render( + + ); + expect(screen.getByTestId("profile-avatar")).toHaveTextContent("existing-avatar.jpg"); + expect(screen.getByText("environments.settings.profile.change_image")).toBeInTheDocument(); + expect(screen.getByText("environments.settings.profile.remove_image")).toBeInTheDocument(); + }); + + test("handles image removal successfully", async () => { + render( + + ); + const removeButton = screen.getByText("environments.settings.profile.remove_image"); + await userEvent.click(removeButton); + + await waitFor(() => { + expect(profileActions.removeAvatarAction).toHaveBeenCalledWith({ environmentId }); + }); + }); + + test("shows error if removeAvatarAction fails", async () => { + vi.mocked(profileActions.removeAvatarAction).mockRejectedValue(new Error("API error")); + render( + + ); + const removeButton = screen.getByText("environments.settings.profile.remove_image"); + await userEvent.click(removeButton); + + await waitFor(() => { + expect(vi.mocked(toast.error)).toHaveBeenCalledWith( + "environments.settings.profile.avatar_update_failed" + ); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx new file mode 100644 index 000000000000..bbfb31327d05 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx @@ -0,0 +1,209 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TUser } from "@formbricks/types/user"; +import { resetPasswordAction, updateUserAction } from "../actions"; +import { EditProfileDetailsForm } from "./EditProfileDetailsForm"; + +const mockUser = { + id: "test-user-id", + name: "Old Name", + email: "test@example.com", + locale: "en-US", + notificationSettings: { + alert: {}, + + unsubscribedOrganizationIds: [], + }, + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + role: "project_manager", + objective: "other", +} as unknown as TUser; + +vi.mock("next-auth/react", () => ({ signOut: vi.fn() })); + +// Mock window.location.reload +const originalLocation = window.location; +beforeEach(() => { + vi.stubGlobal("location", { + ...originalLocation, + reload: vi.fn(), + }); +}); + +vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({ + updateUserAction: vi.fn(), + resetPasswordAction: vi.fn(), +})); + +vi.mock("@/modules/auth/forgot-password/actions", () => ({ + forgotPasswordAction: vi.fn(), +})); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("EditProfileDetailsForm", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders with initial user data and updates successfully", async () => { + vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any); + + render( + + ); + + const nameInput = screen.getByPlaceholderText("common.full_name"); + expect(nameInput).toHaveValue(mockUser.name); + // Check initial language (English) + expect(screen.getByText("English (US)")).toBeInTheDocument(); + + await userEvent.clear(nameInput); + await userEvent.type(nameInput, "New Name"); + + // Change language + const languageDropdownTrigger = screen.getByRole("button", { name: /English/ }); + await userEvent.click(languageDropdownTrigger); + const germanOption = await screen.findByText("German"); // Assuming 'German' is an option + await userEvent.click(germanOption); + + const updateButton = screen.getByText("common.update"); + expect(updateButton).toBeEnabled(); + await userEvent.click(updateButton); + + await waitFor(() => { + expect(updateUserAction).toHaveBeenCalledWith({ + name: "New Name", + locale: "de-DE", + email: mockUser.email, + }); + }); + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + "environments.settings.profile.profile_updated_successfully" + ); + }); + await waitFor(() => { + expect(window.location.reload).toHaveBeenCalled(); + }); + }); + + test("shows error toast if update fails", async () => { + const errorMessage = "Update failed"; + vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage)); + + render( + + ); + + const nameInput = screen.getByPlaceholderText("common.full_name"); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, "Another Name"); + + const updateButton = screen.getByText("common.update"); + await userEvent.click(updateButton); + + await waitFor(() => { + expect(updateUserAction).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(`common.error: ${errorMessage}`); + }); + }); + + test("update button is disabled initially and enables on change", async () => { + render( + + ); + const updateButton = screen.getByText("common.update"); + expect(updateButton).toBeDisabled(); + + const nameInput = screen.getByPlaceholderText("common.full_name"); + await userEvent.type(nameInput, " updated"); + expect(updateButton).toBeEnabled(); + }); + + test("reset password button works", async () => { + vi.mocked(resetPasswordAction).mockResolvedValue({ data: { success: true } }); + + render( + + ); + + const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(resetButton); + + await waitFor(() => { + expect(resetPasswordAction).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading"); + }); + }); + + test("reset password button handles error correctly", async () => { + const errorMessage = "Reset failed"; + vi.mocked(resetPasswordAction).mockResolvedValue({ serverError: errorMessage }); + + render( + + ); + + const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(resetButton); + + await waitFor(() => { + expect(resetPasswordAction).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(errorMessage); + }); + }); + + test("reset password button shows loading state", async () => { + vi.mocked(resetPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves + + render( + + ); + + const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(resetButton); + + expect(resetButton).toBeDisabled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx index 0af8119077c2..9d1c5017b4a1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.tsx @@ -1,136 +1,278 @@ "use client"; +import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal"; +import { appLanguages } from "@/lib/i18n/utils"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; 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, - FormProvider, -} from "@/modules/ui/components/form"; +import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; import { Input } from "@/modules/ui/components/input"; import { Label } from "@/modules/ui/components/label"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslate } from "@tolgee/react"; import { ChevronDownIcon } from "lucide-react"; -import { SubmitHandler, useForm } from "react-hook-form"; +import { useState } from "react"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; -import { appLanguages } from "@formbricks/lib/i18n/utils"; -import { TUser, ZUser } from "@formbricks/types/user"; -import { updateUserAction } from "../actions"; +import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user"; +import { resetPasswordAction, updateUserAction } from "../actions"; -const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true }); +// Schema & types +const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({ + email: ZUserEmail.transform((val) => val?.trim().toLowerCase()), +}); type TEditProfileNameForm = z.infer; -export const EditProfileDetailsForm = ({ user }: { user: TUser }) => { +interface IEditProfileDetailsFormProps { + user: TUser; + isPasswordResetEnabled?: boolean; + emailVerificationDisabled: boolean; +} + +export const EditProfileDetailsForm = ({ + user, + isPasswordResetEnabled, + emailVerificationDisabled, +}: IEditProfileDetailsFormProps) => { + const { t } = useTranslate(); + const form = useForm({ - defaultValues: { name: user.name, locale: user.locale || "en" }, + defaultValues: { + name: user.name, + locale: user.locale, + email: user.email, + }, mode: "onChange", resolver: zodResolver(ZEditProfileNameFormSchema), }); const { isSubmitting, isDirty } = form.formState; - const { t } = useTranslate(); + + const [isResettingPassword, setIsResettingPassword] = useState(false); + const [showModal, setShowModal] = useState(false); + const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); + + const handleConfirmPassword = async (password: string) => { + const values = form.getValues(); + const dirtyFields = form.formState.dirtyFields; + + const emailChanged = "email" in dirtyFields; + const nameChanged = "name" in dirtyFields; + const localeChanged = "locale" in dirtyFields; + + const name = values.name.trim(); + const email = values.email.trim().toLowerCase(); + const locale = values.locale; + + const data: TUserUpdateInput = {}; + + if (emailChanged) { + data.email = email; + data.password = password; + } + if (nameChanged) { + data.name = name; + } + if (localeChanged) { + data.locale = locale; + } + + const updatedUserResult = await updateUserAction(data); + + if (updatedUserResult?.data) { + if (!emailVerificationDisabled) { + toast.success(t("auth.verification-requested.new_email_verification_success")); + } else { + toast.success(t("environments.settings.profile.email_change_initiated")); + await signOutWithAudit({ + reason: "email_change", + redirectUrl: "/email-change-without-verification-success", + redirect: true, + callbackUrl: "/email-change-without-verification-success", + clearEnvironmentId: true, + }); + return; + } + } else { + const errorMessage = getFormattedErrorMessage(updatedUserResult); + toast.error(errorMessage); + return; + } + + window.location.reload(); + setShowModal(false); + }; const onSubmit: SubmitHandler = async (data) => { - try { - const name = data.name.trim(); - const locale = data.locale; - await updateUserAction({ name, locale }); - toast.success(t("environments.settings.profile.profile_updated_successfully")); - window.location.reload(); - form.reset({ name, locale }); - } catch (error) { - toast.error(`${t("common.error")}: ${error.message}`); + if (data.email !== user.email) { + setShowModal(true); + } else { + try { + await updateUserAction({ + ...data, + name: data.name.trim(), + }); + toast.success(t("environments.settings.profile.profile_updated_successfully")); + window.location.reload(); + form.reset(data); + } catch (error: any) { + toast.error(`${t("common.error")}: ${error.message}`); + } } }; + const handleResetPassword = async () => { + setIsResettingPassword(true); + + const result = await resetPasswordAction(); + if (result?.data) { + toast.success(t("auth.forgot-password.email-sent.heading")); + + await signOutWithAudit({ + reason: "password_reset", + redirectUrl: "/auth/login", + redirect: true, + callbackUrl: "/auth/login", + clearEnvironmentId: true, + }); + } else { + const errorMessage = getFormattedErrorMessage(result); + toast.error(errorMessage); + } + + setIsResettingPassword(false); + }; + return ( - -
- ( - - {t("common.full_name")} - - - - - - )} - /> - - {/* disabled email field */} -
- - -
- - ( - - {t("common.language")} - - - - - - - {appLanguages.map((language) => ( - field.onChange(language.code)} - className="min-h-8 cursor-pointer"> - {language.label[field.value]} - - ))} - - - - - + <> + + + ( + + {t("common.full_name")} + + + + + + )} + /> + + ( + + {t("common.email")} + + + + + + )} + /> + + ( + + {t("common.language")} + + + + + + + + {appLanguages.map((lang) => ( + + {lang.label["en-US"]} + + ))} + + + + + + + )} + /> + + {isPasswordResetEnabled && ( +
+ +

+ {t("auth.forgot-password.reset_password_description")} +

+
+ + +
+
)} - /> - - - -
+ + + +
+ + + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.test.tsx new file mode 100644 index 000000000000..602aa2d7f44f --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.test.tsx @@ -0,0 +1,141 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { PasswordConfirmationModal } from "./password-confirmation-modal"; + +// Mock the Dialog component +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: ({ children, open, onOpenChange }: any) => + open ? ( +
+ {children} + +
+ ) : null, + DialogContent: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, + DialogDescription: ({ children }: any) =>

{children}

, + DialogBody: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, +})); + +// Mock the PasswordInput component +vi.mock("@/modules/ui/components/password-input", () => ({ + PasswordInput: ({ onChange, value, placeholder }: any) => ( + onChange(e.target.value)} + placeholder={placeholder} + data-testid="password-input" + /> + ), +})); + +// Mock the useTranslate hook +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +describe("PasswordConfirmationModal", () => { + const defaultProps = { + open: true, + setOpen: vi.fn(), + oldEmail: "old@example.com", + newEmail: "new@example.com", + onConfirm: vi.fn(), + }; + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders nothing when open is false", () => { + render(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + }); + + test("renders dialog content when open is true", () => { + render(); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("dialog-title")).toBeInTheDocument(); + }); + + test("displays old and new email addresses", () => { + render(); + expect(screen.getByText("old@example.com")).toBeInTheDocument(); + expect(screen.getByText("new@example.com")).toBeInTheDocument(); + }); + + test("shows password input field", () => { + render(); + const passwordInput = screen.getByTestId("password-input"); + expect(passwordInput).toBeInTheDocument(); + expect(passwordInput).toHaveAttribute("placeholder", "*******"); + }); + + test("disables confirm button when form is not dirty", () => { + render(); + const confirmButton = screen.getByText("common.confirm"); + expect(confirmButton).toBeDisabled(); + }); + + test("disables confirm button when old and new emails are the same", () => { + render( + + ); + const confirmButton = screen.getByText("common.confirm"); + expect(confirmButton).toBeDisabled(); + }); + + test("enables confirm button when password is entered and emails are different", async () => { + const user = userEvent.setup(); + render(); + + const passwordInput = screen.getByTestId("password-input"); + await user.type(passwordInput, "password123"); + + const confirmButton = screen.getByText("common.confirm"); + expect(confirmButton).not.toBeDisabled(); + }); + + test("shows error message when password is too short", async () => { + const user = userEvent.setup(); + render(); + + const passwordInput = screen.getByTestId("password-input"); + await user.type(passwordInput, "short"); + + const confirmButton = screen.getByText("common.confirm"); + await user.click(confirmButton); + + expect(screen.getByText("String must contain at least 8 character(s)")).toBeInTheDocument(); + }); + + test("handles cancel button click and resets form", async () => { + const user = userEvent.setup(); + render(); + + const passwordInput = screen.getByTestId("password-input"); + await user.type(passwordInput, "password123"); + + const cancelButton = screen.getByText("common.cancel"); + await user.click(cancelButton); + + expect(defaultProps.setOpen).toHaveBeenCalledWith(false); + await waitFor(() => { + expect(passwordInput).toHaveValue(""); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.tsx new file mode 100644 index 000000000000..0cd3edca87e3 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { Button } from "@/modules/ui/components/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; +import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form"; +import { PasswordInput } from "@/modules/ui/components/password-input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslate } from "@tolgee/react"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { z } from "zod"; +import { ZUserPassword } from "@formbricks/types/user"; + +interface PasswordConfirmationModalProps { + open: boolean; + setOpen: (open: boolean) => void; + oldEmail: string; + newEmail: string; + onConfirm: (password: string) => Promise; +} + +const PasswordConfirmationSchema = z.object({ + password: ZUserPassword, +}); + +type FormValues = z.infer; + +export const PasswordConfirmationModal = ({ + open, + setOpen, + oldEmail, + newEmail, + onConfirm, +}: PasswordConfirmationModalProps) => { + const { t } = useTranslate(); + + const form = useForm({ + resolver: zodResolver(PasswordConfirmationSchema), + }); + const { isSubmitting, isDirty } = form.formState; + + const onSubmit: SubmitHandler = async (data) => { + try { + await onConfirm(data.password); + form.reset(); + } catch (error) { + form.setError("password", { + message: error instanceof Error ? error.message : "Authentication failed", + }); + } + }; + const handleCancel = () => { + form.reset(); + setOpen(false); + }; + + return ( + + + + {t("auth.forgot-password.reset.confirm_password")} + {t("auth.email-change.confirm_password_description")} + + +
+ +
+
+

+ {t("auth.email-change.old_email")}: +
{oldEmail.toLowerCase()} +

+

+ {t("auth.email-change.new_email")}: +
{newEmail.toLowerCase()} +

+
+ + ( + + +
+ field.onChange(password)} + /> + {error?.message && {error.message}} +
+
+
+ )} + /> +
+
+ + + + +
+
+
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.test.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.test.ts new file mode 100644 index 000000000000..b16aca023fcc --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.test.ts @@ -0,0 +1,133 @@ +import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { getIsEmailUnique, verifyUserPassword } from "./user"; + +vi.mock("@/modules/auth/lib/utils", () => ({ + verifyPassword: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + }, + }, +})); + +const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique); +const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported); + +describe("User Library Tests", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("verifyUserPassword", () => { + const userId = "test-user-id"; + const password = "test-password"; + + test("should return true for correct password", async () => { + mockPrismaUserFindUnique.mockResolvedValue({ + password: "hashed-password", + identityProvider: "email", + } as any); + mockVerifyPasswordUtil.mockResolvedValue(true); + + const result = await verifyUserPassword(userId, password); + expect(result).toBe(true); + expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ + where: { id: userId }, + select: { password: true, identityProvider: true }, + }); + expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password"); + }); + + test("should return false for incorrect password", async () => { + mockPrismaUserFindUnique.mockResolvedValue({ + password: "hashed-password", + identityProvider: "email", + } as any); + mockVerifyPasswordUtil.mockResolvedValue(false); + + const result = await verifyUserPassword(userId, password); + expect(result).toBe(false); + expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ + where: { id: userId }, + select: { password: true, identityProvider: true }, + }); + expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password"); + }); + + test("should throw ResourceNotFoundError if user not found", async () => { + mockPrismaUserFindUnique.mockResolvedValue(null); + + await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError); + await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`); + expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ + where: { id: userId }, + select: { password: true, identityProvider: true }, + }); + expect(mockVerifyPasswordUtil).not.toHaveBeenCalled(); + }); + + test("should throw InvalidInputError if identityProvider is not email", async () => { + mockPrismaUserFindUnique.mockResolvedValue({ + password: "hashed-password", + identityProvider: "google", // Not 'email' + } as any); + + await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError); + await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user"); + expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ + where: { id: userId }, + select: { password: true, identityProvider: true }, + }); + expect(mockVerifyPasswordUtil).not.toHaveBeenCalled(); + }); + + test("should throw InvalidInputError if password is not set for email provider", async () => { + mockPrismaUserFindUnique.mockResolvedValue({ + password: null, // Password not set + identityProvider: "email", + } as any); + + await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError); + await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user"); + expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ + where: { id: userId }, + select: { password: true, identityProvider: true }, + }); + expect(mockVerifyPasswordUtil).not.toHaveBeenCalled(); + }); + }); + + describe("getIsEmailUnique", () => { + const email = "test@example.com"; + + test("should return false if user exists", async () => { + mockPrismaUserFindUnique.mockResolvedValue({ + id: "some-user-id", + } as any); + + const result = await getIsEmailUnique(email); + expect(result).toBe(false); + expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ + where: { email }, + select: { id: true }, + }); + }); + + test("should return true if user does not exist", async () => { + mockPrismaUserFindUnique.mockResolvedValue(null); + + const result = await getIsEmailUnique(email); + expect(result).toBe(true); + expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ + where: { email }, + select: { id: true }, + }); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.ts new file mode 100644 index 000000000000..78f8a7f154cb --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.ts @@ -0,0 +1,52 @@ +import { verifyPassword } from "@/modules/auth/lib/utils"; +import { User } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; + +export const getUserById = reactCache( + async (userId: string): Promise> => { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + select: { + password: true, + identityProvider: true, + }, + }); + if (!user) { + throw new ResourceNotFoundError("user", userId); + } + return user; + } +); + +export const verifyUserPassword = async (userId: string, password: string): Promise => { + const user = await getUserById(userId); + + if (user.identityProvider !== "email" || !user.password) { + throw new InvalidInputError("Password is not set for this user"); + } + + const isCorrectPassword = await verifyPassword(password, user.password); + + if (!isCorrectPassword) { + return false; + } + + return true; +}; + +export const getIsEmailUnique = reactCache(async (email: string): Promise => { + const user = await prisma.user.findUnique({ + where: { + email: email.toLowerCase(), + }, + select: { + id: true, + }, + }); + + return !user; +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/loading.test.tsx new file mode 100644 index 000000000000..78ffbb484158 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/loading.test.tsx @@ -0,0 +1,63 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import Loading from "./loading"; + +vi.mock( + "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar", + () => ({ + AccountSettingsNavbar: ({ activeId, loading }) => ( +
+ AccountSettingsNavbar - active: {activeId}, loading: {loading?.toString()} +
+ ), + }) +); + +vi.mock("@/app/(app)/components/LoadingCard", () => ({ + LoadingCard: ({ title, description }) => ( +
+
{title}
+
{description}
+
+ ), +})); + +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: ({ pageTitle, children }) => ( +
+

{pageTitle}

+ {children} +
+ ), +})); + +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: ({ children }) =>
{children}
, +})); + +describe("Loading", () => { + afterEach(() => { + cleanup(); + }); + + test("renders loading state correctly", () => { + render(); + + expect(screen.getByText("common.account_settings")).toBeInTheDocument(); + expect(screen.getByTestId("account-settings-navbar")).toHaveTextContent( + "AccountSettingsNavbar - active: profile, loading: true" + ); + + const loadingCards = screen.getAllByTestId("loading-card"); + expect(loadingCards).toHaveLength(3); + + expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.personal_information"); + expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.update_personal_info"); + + expect(loadingCards[1]).toHaveTextContent("common.avatar"); + expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.organization_identification"); + + expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.delete_account"); + expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.confirm_delete_account"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.test.tsx new file mode 100644 index 000000000000..87ea84cda849 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.test.tsx @@ -0,0 +1,191 @@ +import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; +import { getUser } from "@/lib/user/service"; +import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils"; +import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { Session } from "next-auth"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TUser } from "@formbricks/types/user"; +import Page from "./page"; + +// Mock services and utils +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: 1, + PASSWORD_RESET_DISABLED: 1, + EMAIL_VERIFICATION_DISABLED: true, +})); +vi.mock("@/lib/organization/service", () => ({ + getOrganizationsWhereUserIsSingleOwner: vi.fn(), +})); +vi.mock("@/lib/user/service", () => ({ + getUser: vi.fn(), +})); +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getIsMultiOrgEnabled: vi.fn(), + getIsTwoFactorAuthEnabled: vi.fn(), +})); +vi.mock("@/modules/environments/lib/utils", () => ({ + getEnvironmentAuth: vi.fn(), +})); + +const t = (key: any) => key; +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => t, +})); + +// Mock child components +vi.mock( + "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar", + () => ({ + AccountSettingsNavbar: ({ environmentId, activeId }) => ( +
+ AccountSettingsNavbar: {environmentId} {activeId} +
+ ), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity", + () => ({ + AccountSecurity: ({ user }) =>
AccountSecurity: {user.id}
, + }) +); +vi.mock("./components/DeleteAccount", () => ({ + DeleteAccount: ({ user }) =>
DeleteAccount: {user.id}
, +})); +vi.mock("./components/EditProfileAvatarForm", () => ({ + EditProfileAvatarForm: ({ _, environmentId }) => ( +
EditProfileAvatarForm: {environmentId}
+ ), +})); +vi.mock("./components/EditProfileDetailsForm", () => ({ + EditProfileDetailsForm: ({ user }) => ( +
EditProfileDetailsForm: {user.id}
+ ), +})); +vi.mock("@/modules/ui/components/upgrade-prompt", () => ({ + UpgradePrompt: ({ title }) =>
{title}
, +})); + +const mockUser = { + id: "user-123", + name: "Test User", + email: "test@example.com", + imageUrl: "http://example.com/avatar.png", + twoFactorEnabled: false, + identityProvider: "email", + notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] }, + createdAt: new Date(), + updatedAt: new Date(), + role: "project_manager", + objective: "other", +} as unknown as TUser; + +const mockSession: Session = { + user: mockUser, + expires: "never", +}; + +const mockOrganizations: TOrganization[] = []; + +const params = { environmentId: "env-123" }; + +describe("ProfilePage", () => { + beforeEach(() => { + vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValue(mockOrganizations); + vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + session: mockSession, + } as unknown as TEnvironmentAuth); + vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); + vi.mocked(getIsTwoFactorAuthEnabled).mockResolvedValue(true); + }); + + afterEach(() => { + vi.clearAllMocks(); + cleanup(); + }); + + test("renders profile page with all sections for email user with 2FA license", async () => { + render(await Page({ params: Promise.resolve(params) })); + + await waitFor(() => { + expect(screen.getByText("common.account_settings")).toBeInTheDocument(); + expect(screen.getByTestId("account-settings-navbar")).toHaveTextContent( + "AccountSettingsNavbar: env-123 profile" + ); + expect(screen.getByTestId("edit-profile-details-form")).toBeInTheDocument(); + expect(screen.getByTestId("edit-profile-avatar-form")).toBeInTheDocument(); + expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled + expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument(); + expect(screen.getByTestId("delete-account")).toBeInTheDocument(); + // Check for IdBadge content + expect(screen.getByText("common.profile_id")).toBeInTheDocument(); + expect(screen.getByText(mockUser.id)).toBeInTheDocument(); + }); + }); + + test("renders UpgradePrompt when 2FA license is disabled and user 2FA is off", async () => { + vi.mocked(getIsTwoFactorAuthEnabled).mockResolvedValue(false); // License disabled + const userWith2FAOff = { ...mockUser, twoFactorEnabled: false }; + vi.mocked(getUser).mockResolvedValue(userWith2FAOff); + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + session: { ...mockSession, user: userWith2FAOff }, + } as unknown as TEnvironmentAuth); + + render(await Page({ params: Promise.resolve(params) })); + + await waitFor(() => { + expect(screen.getByTestId("upgrade-prompt")).toBeInTheDocument(); + expect(screen.getByTestId("upgrade-prompt")).toHaveTextContent( + "environments.settings.profile.unlock_two_factor_authentication" + ); + expect(screen.queryByTestId("account-security")).not.toBeInTheDocument(); + }); + }); + + test("renders AccountSecurity when 2FA license is disabled but user 2FA is on", async () => { + vi.mocked(getIsTwoFactorAuthEnabled).mockResolvedValue(false); // License disabled + const userWith2FAOn = { ...mockUser, twoFactorEnabled: true }; + vi.mocked(getUser).mockResolvedValue(userWith2FAOn); + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + session: { ...mockSession, user: userWith2FAOn }, + } as unknown as TEnvironmentAuth); + + render(await Page({ params: Promise.resolve(params) })); + + await waitFor(() => { + expect(screen.getByTestId("account-security")).toBeInTheDocument(); + expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument(); + }); + }); + + test("does not render security card if identityProvider is not email", async () => { + const nonEmailUser = { ...mockUser, identityProvider: "google" as "email" | "github" | "google" }; // type assertion + vi.mocked(getUser).mockResolvedValue(nonEmailUser); + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + session: { ...mockSession, user: nonEmailUser }, + } as unknown as TEnvironmentAuth); + + render(await Page({ params: Promise.resolve(params) })); + + await waitFor(() => { + expect(screen.queryByTestId("account-security")).not.toBeInTheDocument(); + expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument(); + expect(screen.queryByText("common.security")).not.toBeInTheDocument(); + }); + }); + + test("throws error if user is not found", async () => { + vi.mocked(getUser).mockResolvedValue(null); + // Need to catch the promise rejection for async component errors + try { + // We don't await the render directly, but the component execution + await Page({ params: Promise.resolve(params) }); + } catch (e) { + expect(e.message).toBe("common.user_not_found"); + } + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx index 89d7a773419c..dacd4fa81120 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx @@ -1,15 +1,15 @@ import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity"; +import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants"; +import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; +import { getUser } from "@/lib/user/service"; import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; -import { SettingsId } from "@/modules/ui/components/settings-id"; import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt"; import { getTranslate } from "@/tolgee/server"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; -import { getOrganizationsWhereUserIsSingleOwner } from "@formbricks/lib/organization/service"; -import { getUser } from "@formbricks/lib/user/service"; import { SettingsCard } from "../../components/SettingsCard"; import { DeleteAccount } from "./components/DeleteAccount"; import { EditProfileAvatarForm } from "./components/EditProfileAvatarForm"; @@ -32,6 +32,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => { throw new Error(t("common.user_not_found")); } + const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email"; + return ( @@ -42,7 +44,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => { - + }) => { isMultiOrgEnabled={isMultiOrgEnabled} /> - +
)} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/loading.test.tsx new file mode 100644 index 000000000000..337cc384ba8c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/loading.test.tsx @@ -0,0 +1,29 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import LoadingPage from "./loading"; + +// Mock the IS_FORMBRICKS_CLOUD constant +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: true, +})); + +// Mock the actual Loading component that is being imported +vi.mock("@/modules/organization/settings/api-keys/loading", () => ({ + default: ({ isFormbricksCloud }: { isFormbricksCloud: boolean }) => ( +
isFormbricksCloud: {String(isFormbricksCloud)}
+ ), +})); + +describe("LoadingPage for API Keys", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the underlying Loading component with correct isFormbricksCloud prop", () => { + render(); + const mockedLoadingComponent = screen.getByTestId("mocked-loading-component"); + expect(mockedLoadingComponent).toBeInTheDocument(); + // Check if the prop is passed correctly based on the mocked constant value + expect(mockedLoadingComponent).toHaveTextContent("isFormbricksCloud: true"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/loading.tsx index b3139471f6be..42fe2727235e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/loading.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/loading.tsx @@ -1,5 +1,5 @@ +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import Loading from "@/modules/organization/settings/api-keys/loading"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; export default function LoadingPage() { return ; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/page.test.tsx new file mode 100644 index 000000000000..2322e618bb48 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/api-keys/page.test.tsx @@ -0,0 +1,21 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +// Mock the APIKeysPage component +vi.mock("@/modules/organization/settings/api-keys/page", () => ({ + APIKeysPage: () =>
APIKeysPage Content
, +})); + +describe("APIKeys Page", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the APIKeysPage component", () => { + render(); + const apiKeysPageComponent = screen.getByTestId("mocked-api-keys-page"); + expect(apiKeysPageComponent).toBeInTheDocument(); + expect(apiKeysPageComponent).toHaveTextContent("APIKeysPage Content"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/loading.test.tsx new file mode 100644 index 000000000000..4986f711debc --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/loading.test.tsx @@ -0,0 +1,74 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import Loading from "./loading"; + +// Mock constants +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: true, +})); + +// Mock server-side translation +vi.mock("@/tolgee/server", () => ({ + getTranslate: vi.fn(), +})); + +// Mock child components +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: ({ pageTitle, children }: { pageTitle: string; children: React.ReactNode }) => ( +
+

{pageTitle}

+ {children} +
+ ), +})); + +vi.mock( + "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar", + () => ({ + OrganizationSettingsNavbar: ({ activeId, loading }: { activeId: string; loading?: boolean }) => ( +
+ Active: {activeId}, Loading: {String(loading)} +
+ ), + }) +); + +describe("Billing Loading Page", () => { + beforeEach(async () => { + const mockTranslate = vi.fn((key) => key); + vi.mocked(await import("@/tolgee/server")).getTranslate.mockResolvedValue(mockTranslate); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders PageContentWrapper, PageHeader, and OrganizationSettingsNavbar", async () => { + render(await Loading()); + + expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument(); + const pageHeader = screen.getByTestId("page-header"); + expect(pageHeader).toBeInTheDocument(); + expect(pageHeader).toHaveTextContent("environments.settings.general.organization_settings"); + + const navbar = screen.getByTestId("org-settings-navbar"); + expect(navbar).toBeInTheDocument(); + expect(navbar).toHaveTextContent("Active: billing"); + expect(navbar).toHaveTextContent("Loading: true"); + }); + + test("renders placeholder divs", async () => { + render(await Loading()); + // Check for the presence of divs with animate-pulse, assuming they are the placeholders + const placeholders = screen.getAllByRole("generic", { hidden: true }); // Using a generic role as divs don't have implicit roles + const animatedPlaceholders = placeholders.filter((el) => el.classList.contains("animate-pulse")); + expect(animatedPlaceholders.length).toBeGreaterThanOrEqual(2); // Expecting at least two placeholder divs + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/loading.tsx index 95ff1640dfd6..623a30b52c88 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/loading.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/loading.tsx @@ -1,8 +1,8 @@ import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getTranslate } from "@/tolgee/server"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; const Loading = async () => { const t = await getTranslate(); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/page.test.tsx new file mode 100644 index 000000000000..1bfd1e29da41 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/page.test.tsx @@ -0,0 +1,21 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +// Mock the PricingPage component +vi.mock("@/modules/ee/billing/page", () => ({ + PricingPage: () =>
PricingPage Content
, +})); + +describe("Billing Page", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the PricingPage component", () => { + render(); + const pricingPageComponent = screen.getByTestId("mocked-pricing-page"); + expect(pricingPageComponent).toBeInTheDocument(); + expect(pricingPageComponent).toHaveTextContent("PricingPage Content"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.test.tsx new file mode 100644 index 000000000000..2ee8118f837b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.test.tsx @@ -0,0 +1,134 @@ +import { getAccessFlags } from "@/lib/membership/utils"; +import { cleanup, render, screen } from "@testing-library/react"; +import { usePathname } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TOrganizationRole } from "@formbricks/types/memberships"; +import { OrganizationSettingsNavbar } from "./OrganizationSettingsNavbar"; + +vi.mock("next/navigation", () => ({ + usePathname: vi.fn(), +})); + +vi.mock("@/lib/membership/utils", () => ({ + getAccessFlags: vi.fn(), +})); + +// Mock SecondaryNavigation to inspect its props +let mockSecondaryNavigationProps: any; +vi.mock("@/modules/ui/components/secondary-navigation", () => ({ + SecondaryNavigation: (props: any) => { + mockSecondaryNavigationProps = props; + return
Mocked SecondaryNavigation
; + }, +})); + +describe("OrganizationSettingsNavbar", () => { + beforeEach(() => { + mockSecondaryNavigationProps = null; // Reset before each test + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const defaultProps = { + environmentId: "env123", + isFormbricksCloud: true, + membershipRole: "owner" as TOrganizationRole, + activeId: "general", + loading: false, + }; + + test.each([ + { + pathname: "/environments/env123/settings/general", + role: "owner", + isCloud: true, + expectedVisibility: { general: true, billing: true, teams: true, enterprise: false, "api-keys": true }, + }, + { + pathname: "/environments/env123/settings/teams", + role: "member", + isCloud: false, + expectedVisibility: { + general: true, + billing: false, + teams: true, + enterprise: false, + "api-keys": false, + }, + }, // enterprise hidden if not cloud, api-keys hidden if not owner + { + pathname: "/environments/env123/settings/api-keys", + role: "admin", + isCloud: true, + expectedVisibility: { general: true, billing: true, teams: true, enterprise: false, "api-keys": false }, + }, // api-keys hidden if not owner + { + pathname: "/environments/env123/settings/enterprise", + role: "owner", + isCloud: false, + expectedVisibility: { general: true, billing: false, teams: true, enterprise: true, "api-keys": true }, + }, // enterprise shown if not cloud and not member + ])( + "renders correct navigation items based on props and path ($pathname, $role, $isCloud)", + ({ pathname, role, isCloud, expectedVisibility }) => { + vi.mocked(usePathname).mockReturnValue(pathname); + vi.mocked(getAccessFlags).mockReturnValue({ + isOwner: role === "owner", + isMember: role === "member", + } as any); + + render( + + ); + + expect(screen.getByTestId("secondary-navigation")).toBeInTheDocument(); + expect(mockSecondaryNavigationProps).not.toBeNull(); + + const visibleNavItems = mockSecondaryNavigationProps.navigation.filter((item: any) => !item.hidden); + const visibleIds = visibleNavItems.map((item: any) => item.id); + + Object.entries(expectedVisibility).forEach(([id, shouldBeVisible]) => { + if (shouldBeVisible) { + expect(visibleIds).toContain(id); + } else { + expect(visibleIds).not.toContain(id); + } + }); + + // Check current status + mockSecondaryNavigationProps.navigation.forEach((item: any) => { + if (item.href === pathname) { + expect(item.current).toBe(true); + } + }); + } + ); + + test("passes loading prop to SecondaryNavigation", () => { + vi.mocked(usePathname).mockReturnValue("/environments/env123/settings/general"); + vi.mocked(getAccessFlags).mockReturnValue({ + isOwner: true, + isMember: false, + } as any); + render(); + expect(mockSecondaryNavigationProps.loading).toBe(true); + }); + + test("hides billing when loading is true", () => { + vi.mocked(usePathname).mockReturnValue("/environments/env123/settings/general"); + vi.mocked(getAccessFlags).mockReturnValue({ + isOwner: true, + isMember: false, + } as any); + render(); + const billingItem = mockSecondaryNavigationProps.navigation.find((item: any) => item.id === "billing"); + expect(billingItem.hidden).toBe(true); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx index 18a0b6737ea2..2f763ededa37 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx @@ -1,9 +1,9 @@ "use client"; +import { getAccessFlags } from "@/lib/membership/utils"; import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation"; import { useTranslate } from "@tolgee/react"; import { usePathname } from "next/navigation"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { TOrganizationRole } from "@formbricks/types/memberships"; interface OrganizationSettingsNavbarProps { @@ -22,7 +22,7 @@ export const OrganizationSettingsNavbar = ({ loading, }: OrganizationSettingsNavbarProps) => { const pathname = usePathname(); - const { isMember } = getAccessFlags(membershipRole); + const { isMember, isOwner } = getAccessFlags(membershipRole); const isPricingDisabled = isMember; const { t } = useTranslate(); @@ -59,6 +59,7 @@ export const OrganizationSettingsNavbar = ({ label: t("common.api_keys"), href: `/environments/${environmentId}/settings/api-keys`, current: pathname?.includes("/api-keys"), + hidden: !isOwner, }, ]; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/loading.test.tsx new file mode 100644 index 000000000000..74d4b5572662 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/loading.test.tsx @@ -0,0 +1,68 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import Loading from "./loading"; + +// Mock constants +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, // Enterprise page is typically for self-hosted +})); + +// Mock server-side translation +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); + +// Mock child components +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: ({ pageTitle, children }: { pageTitle: string; children: React.ReactNode }) => ( +
+

{pageTitle}

+ {children} +
+ ), +})); + +vi.mock( + "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar", + () => ({ + OrganizationSettingsNavbar: ({ activeId, loading }: { activeId: string; loading?: boolean }) => ( +
+ Active: {activeId}, Loading: {String(loading)} +
+ ), + }) +); + +describe("Enterprise Loading Page", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders PageContentWrapper, PageHeader, and OrganizationSettingsNavbar", async () => { + render(await Loading()); + + expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument(); + const pageHeader = screen.getByTestId("page-header"); + expect(pageHeader).toBeInTheDocument(); + expect(pageHeader).toHaveTextContent("environments.settings.general.organization_settings"); + + const navbar = screen.getByTestId("org-settings-navbar"); + expect(navbar).toBeInTheDocument(); + expect(navbar).toHaveTextContent("Active: enterprise"); + expect(navbar).toHaveTextContent("Loading: true"); + }); + + test("renders placeholder divs", async () => { + render(await Loading()); + const placeholders = screen.getAllByRole("generic", { hidden: true }); + const animatedPlaceholders = placeholders.filter((el) => el.classList.contains("animate-pulse")); + expect(animatedPlaceholders.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/loading.tsx index 87476cc33737..ccd0a48bab97 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/loading.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/loading.tsx @@ -1,8 +1,8 @@ import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getTranslate } from "@/tolgee/server"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; const Loading = async () => { const t = await getTranslate(); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.test.tsx new file mode 100644 index 000000000000..8d1a82d43b0e --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.test.tsx @@ -0,0 +1,200 @@ +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getAccessFlags } from "@/lib/membership/utils"; +import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import { getUser } from "@/lib/user/service"; +import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { cleanup, render, screen } from "@testing-library/react"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TMembership } from "@formbricks/types/memberships"; +import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations"; +import { TUser } from "@formbricks/types/user"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + membership: { + findMany: vi.fn(), + }, + environment: { + findUnique: vi.fn(), + }, + project: { + findFirst: vi.fn(), + }, + }, +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), + usePathname: vi.fn(), + notFound: vi.fn(), +})); + +vi.mock("@/lib/organization/service", () => ({ + getOrganizationByEnvironmentId: vi.fn(), +})); + +vi.mock("@/lib/user/service", () => ({ + getUser: vi.fn(), +})); + +vi.mock("@/lib/membership/service", () => ({ + getMembershipByUserIdOrganizationId: vi.fn(), +})); + +vi.mock("@/lib/membership/utils", () => ({ + getAccessFlags: vi.fn(), +})); + +vi.mock("@/modules/environments/lib/utils", () => ({ + getEnvironmentAuth: vi.fn(), +})); + +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/modules/ui/components/settings-card", () => ({ + SettingsCard: ({ title, description, children }: any) => ( +
+

{title}

+

{description}

+ {children} +
+ ), +})); + +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); + +let mockIsFormbricksCloud = false; +vi.mock("@/lib/constants", async () => ({ + get IS_FORMBRICKS_CLOUD() { + return mockIsFormbricksCloud; + }, + IS_PRODUCTION: false, + FB_LOGO_URL: "https://example.com/mock-logo.png", + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "mock-github-secret", + GOOGLE_CLIENT_ID: "mock-google-client-id", + GOOGLE_CLIENT_SECRET: "mock-google-client-secret", + AZUREAD_CLIENT_ID: "mock-azuread-client-id", + AZUREAD_CLIENT_SECRET: "mock-azure-client-secret", + AZUREAD_TENANT_ID: "mock-azuread-tenant-id", + OIDC_CLIENT_ID: "mock-oidc-client-id", + OIDC_CLIENT_SECRET: "mock-oidc-client-secret", + OIDC_ISSUER: "mock-oidc-issuer", + OIDC_DISPLAY_NAME: "mock-oidc-display-name", + OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm", + SAML_DATABASE_URL: "mock-saml-database-url", + WEBAPP_URL: "mock-webapp-url", + SMTP_HOST: "mock-smtp-host", + SMTP_PORT: "mock-smtp-port", + E2E_TESTING: "mock-e2e-testing", +})); + +const mockEnvironmentId = "c6x2k3vq00000e5twdfh8x9xg"; +const mockOrganizationId = "test-org-id"; +const mockUserId = "test-user-id"; + +const mockSession = { + user: { + id: mockUserId, + }, +}; + +const mockUser = { + id: mockUserId, + name: "Test User", + email: "test@example.com", + createdAt: new Date(), + updatedAt: new Date(), + emailVerified: new Date(), + imageUrl: "", + twoFactorEnabled: false, + identityProvider: "email", + notificationSettings: { alert: {} }, + role: "project_manager", + objective: "other", +} as unknown as TUser; + +const mockOrganization = { + id: mockOrganizationId, + name: "Test Organization", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: null, + plan: "free", + limits: { monthly: { responses: null, miu: null }, projects: null }, + features: { + isUsageBasedSubscriptionEnabled: false, + isSubscriptionUpdateDisabled: false, + }, + } as unknown as TOrganizationBilling, +} as unknown as TOrganization; + +const mockMembership: TMembership = { + organizationId: mockOrganizationId, + userId: mockUserId, + accepted: true, + role: "owner", +}; + +describe("EnterpriseSettingsPage", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockIsFormbricksCloud = false; + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + environmentId: mockEnvironmentId, + organizationId: mockOrganizationId, + userId: mockUserId, + } as any); + vi.mocked(getServerSession).mockResolvedValue(mockSession as any); + vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getAccessFlags).mockReturnValue({ isOwner: true, isAdmin: true } as any); // Ensure isAdmin is also covered if relevant + }); + + afterEach(() => { + cleanup(); + }); + + test("renders correctly for an owner when not on Formbricks Cloud", async () => { + vi.resetModules(); + await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ + active: false, + isPendingDowngrade: false, + features: { isMultiOrgEnabled: false }, + lastChecked: new Date(), + fallbackLevel: "live", + }), + })); + const { default: EnterpriseSettingsPage } = await import("./page"); + const Page = await EnterpriseSettingsPage({ params: { environmentId: mockEnvironmentId } }); + render(Page); + expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument(); + expect(screen.getByTestId("page-header")).toBeInTheDocument(); + expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument(); + expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument(); + expect(redirect).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx index 9cab9f666278..30dad51bb2b9 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx @@ -1,5 +1,6 @@ import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; -import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { Button } from "@/modules/ui/components/button"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; @@ -8,7 +9,6 @@ import { getTranslate } from "@/tolgee/server"; import { CheckIcon } from "lucide-react"; import Link from "next/link"; import { notFound } from "next/navigation"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; const Page = async (props) => { const params = await props.params; @@ -58,11 +58,6 @@ const Page = async (props) => { comingSoon: false, onRequest: false, }, - { - title: t("environments.settings.enterprise.ai"), - comingSoon: false, - onRequest: true, - }, { title: t("environments.settings.enterprise.audit_logs"), comingSoon: false, diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/actions.ts index ec5fbb93d147..1f5b1a23c899 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/actions.ts @@ -1,10 +1,12 @@ "use server"; +import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { z } from "zod"; -import { deleteOrganization, updateOrganization } from "@formbricks/lib/organization/service"; import { ZId } from "@formbricks/types/common"; import { OperationNotAllowedError } from "@formbricks/types/errors"; import { ZOrganizationUpdateInput } from "@formbricks/types/organizations"; @@ -16,43 +18,65 @@ const ZUpdateOrganizationNameAction = z.object({ export const updateOrganizationNameAction = authenticatedActionClient .schema(ZUpdateOrganizationNameAction) - .action(async ({ parsedInput, ctx }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - schema: ZOrganizationUpdateInput.pick({ name: true }), - data: parsedInput.data, - roles: ["owner"], - }, - ], - }); - - return await updateOrganization(parsedInput.organizationId, parsedInput.data); - }); + .action( + withAuditLogging( + "updated", + "organization", + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: Record; + }) => { + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: parsedInput.organizationId, + access: [ + { + type: "organization", + schema: ZOrganizationUpdateInput.pick({ name: true }), + data: parsedInput.data, + roles: ["owner"], + }, + ], + }); + ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; + const oldObject = await getOrganization(parsedInput.organizationId); + const result = await updateOrganization(parsedInput.organizationId, parsedInput.data); + ctx.auditLoggingCtx.oldObject = oldObject; + ctx.auditLoggingCtx.newObject = result; + return result; + } + ) + ); const ZDeleteOrganizationAction = z.object({ organizationId: ZId, }); -export const deleteOrganizationAction = authenticatedActionClient - .schema(ZDeleteOrganizationAction) - .action(async ({ parsedInput, ctx }) => { - const isMultiOrgEnabled = await getIsMultiOrgEnabled(); - if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled"); - - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner"], - }, - ], - }); +export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action( + withAuditLogging( + "deleted", + "organization", + async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => { + const isMultiOrgEnabled = await getIsMultiOrgEnabled(); + if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled"); - return await deleteOrganization(parsedInput.organizationId); - }); + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: parsedInput.organizationId, + access: [ + { + type: "organization", + roles: ["owner"], + }, + ], + }); + ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; + const oldObject = await getOrganization(parsedInput.organizationId); + ctx.auditLoggingCtx.oldObject = oldObject; + return await deleteOrganization(parsedInput.organizationId); + } + ) +); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle.tsx deleted file mode 100644 index a0cc71077ae9..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client"; - -import { getFormattedErrorMessage } from "@/lib/utils/helper"; -import { updateOrganizationAIEnabledAction } from "@/modules/ee/insights/actions"; -import { Alert, AlertDescription } from "@/modules/ui/components/alert"; -import { Label } from "@/modules/ui/components/label"; -import { Switch } from "@/modules/ui/components/switch"; -import { useTranslate } from "@tolgee/react"; -import Link from "next/link"; -import { useState } from "react"; -import toast from "react-hot-toast"; -import { TOrganization } from "@formbricks/types/organizations"; - -interface AIToggleProps { - environmentId: string; - organization: TOrganization; - isOwnerOrManager: boolean; -} - -export const AIToggle = ({ organization, isOwnerOrManager }: AIToggleProps) => { - const { t } = useTranslate(); - const [isAIEnabled, setIsAIEnabled] = useState(organization.isAIEnabled); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleUpdateOrganization = async (data) => { - try { - setIsAIEnabled(data.enabled); - setIsSubmitting(true); - const updatedOrganizationResponse = await updateOrganizationAIEnabledAction({ - organizationId: organization.id, - data: { - isAIEnabled: data.enabled, - }, - }); - - if (updatedOrganizationResponse?.data) { - if (data.enabled) { - toast.success(t("environments.settings.general.formbricks_ai_enable_success_message")); - } else { - toast.success(t("environments.settings.general.formbricks_ai_disable_success_message")); - } - } else { - const errorMessage = getFormattedErrorMessage(updatedOrganizationResponse); - toast.error(errorMessage); - } - } catch (err) { - toast.error(`Error: ${err.message}`); - } finally { - setIsSubmitting(false); - if (typeof window !== "undefined") { - setTimeout(() => { - window.location.reload(); - }, 500); - } - } - }; - - return ( - <> -
-
- - { - e.stopPropagation(); - handleUpdateOrganization({ enabled: !organization.isAIEnabled }); - }} - /> -
-
- {t("environments.settings.general.formbricks_ai_privacy_policy_text")}{" "} - - {t("common.privacy_policy")} - - . -
-
- {!isOwnerOrManager && ( - - - {t("environments.settings.general.only_org_owner_can_perform_action")} - - - )} - - ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/DeleteOrganization.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/DeleteOrganization.test.tsx new file mode 100644 index 000000000000..1a2615928692 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/DeleteOrganization.test.tsx @@ -0,0 +1,192 @@ +import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions"; +import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; +import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRouter } from "next/navigation"; +import toast from "react-hot-toast"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations"; +import { DeleteOrganization } from "./DeleteOrganization"; + +vi.mock("next/navigation", () => ({ + useRouter: vi.fn(), +})); + +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); + +vi.mock("@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions", () => ({ + deleteOrganizationAction: vi.fn(), +})); + +const mockT = (key: string, params?: any) => { + if (params && typeof params === "object") { + let translation = key; + for (const p in params) { + translation = translation.replace(`{{${p}}}`, params[p]); + } + return translation; + } + return key; +}; + +const organizationMock = { + id: "org_123", + name: "Test Organization", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: null, + plan: "free", + } as unknown as TOrganizationBilling, +} as unknown as TOrganization; + +const mockRouterPush = vi.fn(); + +const renderComponent = (props: Partial[0]> = {}) => { + const defaultProps = { + organization: organizationMock, + isDeleteDisabled: false, + isUserOwner: true, + ...props, + }; + return render(); +}; + +describe("DeleteOrganization", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useRouter).mockReturnValue({ push: mockRouterPush } as any); + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + }); + + test("renders delete button and info text when delete is not disabled", () => { + renderComponent(); + expect(screen.getByText("environments.settings.general.once_its_gone_its_gone")).toBeInTheDocument(); + const deleteButton = screen.getByRole("button", { name: "common.delete" }); + expect(deleteButton).toBeInTheDocument(); + expect(deleteButton).not.toBeDisabled(); + }); + + test("renders warning and no delete button when delete is disabled and user is owner", () => { + renderComponent({ isDeleteDisabled: true, isUserOwner: true }); + expect( + screen.getByText("environments.settings.general.cannot_delete_only_organization") + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "common.delete" })).not.toBeInTheDocument(); + }); + + test("renders warning and no delete button when delete is disabled and user is not owner", () => { + renderComponent({ isDeleteDisabled: true, isUserOwner: false }); + expect( + screen.getByText("environments.settings.general.only_org_owner_can_perform_action") + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "common.delete" })).not.toBeInTheDocument(); + }); + + test("opens delete dialog on button click", async () => { + renderComponent(); + const deleteButton = screen.getByRole("button", { name: "common.delete" }); + await userEvent.click(deleteButton); + expect(screen.getByText("environments.settings.general.delete_organization_warning")).toBeInTheDocument(); + expect( + screen.getByText( + mockT("environments.settings.general.delete_organization_warning_3", { + organizationName: organizationMock.name, + }) + ) + ).toBeInTheDocument(); + }); + + test("delete button in modal is disabled until correct organization name is typed", async () => { + renderComponent(); + const deleteButton = screen.getByRole("button", { name: "common.delete" }); + await userEvent.click(deleteButton); + + const dialog = screen.getByRole("dialog"); + const modalDeleteButton = within(dialog).getByRole("button", { name: "common.delete" }); + expect(modalDeleteButton).toBeDisabled(); + + const inputField = screen.getByPlaceholderText(organizationMock.name); + await userEvent.type(inputField, organizationMock.name); + expect(modalDeleteButton).not.toBeDisabled(); + + await userEvent.clear(inputField); + await userEvent.type(inputField, "Wrong Name"); + expect(modalDeleteButton).toBeDisabled(); + }); + + test("calls deleteOrganizationAction on confirm, shows success, clears localStorage, and navigates", async () => { + vi.mocked(deleteOrganizationAction).mockResolvedValue({} as any); + localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, "some-env-id"); + renderComponent(); + + const deleteButton = screen.getByRole("button", { name: "common.delete" }); + await userEvent.click(deleteButton); + + const inputField = screen.getByPlaceholderText(organizationMock.name); + await userEvent.type(inputField, organizationMock.name); + + const dialog = screen.getByRole("dialog"); + const modalDeleteButton = within(dialog).getByRole("button", { name: "common.delete" }); + await userEvent.click(modalDeleteButton); + + await waitFor(() => { + expect(deleteOrganizationAction).toHaveBeenCalledWith({ organizationId: organizationMock.id }); + expect(toast.success).toHaveBeenCalledWith( + "environments.settings.general.organization_deleted_successfully" + ); + expect(localStorage.getItem(FORMBRICKS_ENVIRONMENT_ID_LS)).toBeNull(); + expect(mockRouterPush).toHaveBeenCalledWith("/"); + expect( + screen.queryByText("environments.settings.general.delete_organization_warning") + ).not.toBeInTheDocument(); // Modal should close + }); + }); + + test("shows error toast on deleteOrganizationAction failure", async () => { + vi.mocked(deleteOrganizationAction).mockRejectedValue(new Error("Deletion failed")); + renderComponent(); + + const deleteButton = screen.getByRole("button", { name: "common.delete" }); + await userEvent.click(deleteButton); + + const inputField = screen.getByPlaceholderText(organizationMock.name); + await userEvent.type(inputField, organizationMock.name); + + const dialog = screen.getByRole("dialog"); + const modalDeleteButton = within(dialog).getByRole("button", { name: "common.delete" }); + await userEvent.click(modalDeleteButton); + + await waitFor(() => { + expect(deleteOrganizationAction).toHaveBeenCalledWith({ organizationId: organizationMock.id }); + expect(toast.error).toHaveBeenCalledWith( + "environments.settings.general.error_deleting_organization_please_try_again" + ); + expect( + screen.queryByText("environments.settings.general.delete_organization_warning") + ).not.toBeInTheDocument(); // Modal should close + }); + }); + + test("closes modal on cancel click", async () => { + renderComponent(); + const deleteButton = screen.getByRole("button", { name: "common.delete" }); + await userEvent.click(deleteButton); + + expect(screen.getByText("environments.settings.general.delete_organization_warning")).toBeInTheDocument(); + const cancelButton = screen.getByRole("button", { name: "common.cancel" }); + await userEvent.click(cancelButton); + + await waitFor(() => { + expect( + screen.queryByText("environments.settings.general.delete_organization_warning") + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/DeleteOrganization.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/DeleteOrganization.tsx index 5a088d9659f5..5e8780840da7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/DeleteOrganization.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/DeleteOrganization.tsx @@ -1,6 +1,7 @@ "use client"; import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions"; +import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Button } from "@/modules/ui/components/button"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; @@ -9,7 +10,6 @@ import { useTranslate } from "@tolgee/react"; import { useRouter } from "next/navigation"; import { Dispatch, SetStateAction, useState } from "react"; import toast from "react-hot-toast"; -import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage"; import { TOrganization } from "@formbricks/types/organizations"; type DeleteOrganizationProps = { diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditOrganizationNameForm.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditOrganizationNameForm.test.tsx new file mode 100644 index 000000000000..22077eef5085 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditOrganizationNameForm.test.tsx @@ -0,0 +1,149 @@ +import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TOrganization } from "@formbricks/types/organizations"; +import { EditOrganizationNameForm } from "./EditOrganizationNameForm"; + +vi.mock("@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions", () => ({ + updateOrganizationNameAction: vi.fn(), +})); + +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); + +const organizationMock = { + id: "org_123", + name: "Old Organization Name", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: null, + plan: "free", + } as unknown as TOrganization["billing"], +} as unknown as TOrganization; + +const renderForm = (membershipRole: "owner" | "member") => { + return render( + + ); +}; + +describe("EditOrganizationNameForm", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + beforeEach(() => { + vi.mocked(updateOrganizationNameAction).mockReset(); + }); + + test("renders with initial organization name and allows owner to update", async () => { + renderForm("owner"); + + const nameInput = screen.getByPlaceholderText( + "environments.settings.general.organization_name_placeholder" + ); + expect(nameInput).toHaveValue(organizationMock.name); + expect(nameInput).not.toBeDisabled(); + + const updateButton = screen.getByText("common.update"); + expect(updateButton).toBeDisabled(); // Initially disabled as form is not dirty + + await userEvent.clear(nameInput); + await userEvent.type(nameInput, "New Organization Name"); + expect(updateButton).not.toBeDisabled(); // Enabled after change + + vi.mocked(updateOrganizationNameAction).mockResolvedValueOnce({ + data: { ...organizationMock, name: "New Organization Name" }, + }); + + await userEvent.click(updateButton); + + await waitFor(() => { + expect(updateOrganizationNameAction).toHaveBeenCalledWith({ + organizationId: organizationMock.id, + data: { name: "New Organization Name" }, + }); + expect( + screen.getByPlaceholderText("environments.settings.general.organization_name_placeholder") + ).toHaveValue("New Organization Name"); + expect(toast.success).toHaveBeenCalledWith( + "environments.settings.general.organization_name_updated_successfully" + ); + }); + expect(updateButton).toBeDisabled(); // Disabled after successful submit and reset + }); + + test("shows error toast on update failure", async () => { + renderForm("owner"); + + const nameInput = screen.getByPlaceholderText( + "environments.settings.general.organization_name_placeholder" + ); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, "Another Name"); + + const updateButton = screen.getByText("common.update"); + + vi.mocked(updateOrganizationNameAction).mockResolvedValueOnce({ + data: null as any, + }); + + await userEvent.click(updateButton); + + await waitFor(() => { + expect(updateOrganizationNameAction).toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith(""); + }); + expect(nameInput).toHaveValue("Another Name"); // Name should not reset on error + }); + + test("shows generic error toast on exception during update", async () => { + renderForm("owner"); + + const nameInput = screen.getByPlaceholderText( + "environments.settings.general.organization_name_placeholder" + ); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, "Exception Name"); + + const updateButton = screen.getByText("common.update"); + + vi.mocked(updateOrganizationNameAction).mockRejectedValueOnce(new Error("Network error")); + + await userEvent.click(updateButton); + + await waitFor(() => { + expect(updateOrganizationNameAction).toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith("Error: Network error"); + }); + }); + + test("disables input and button for non-owner roles and shows warning", async () => { + const roles: "member"[] = ["member"]; + for (const role of roles) { + renderForm(role); + + const nameInput = screen.getByPlaceholderText( + "environments.settings.general.organization_name_placeholder" + ); + expect(nameInput).toBeDisabled(); + + const updateButton = screen.getByText("common.update"); + expect(updateButton).toBeDisabled(); + + expect( + screen.getByText("environments.settings.general.only_org_owner_can_perform_action") + ).toBeInTheDocument(); + cleanup(); + } + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditOrganizationNameForm.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditOrganizationNameForm.tsx index 3f525d4d7a78..e10679107099 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditOrganizationNameForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditOrganizationNameForm.tsx @@ -1,6 +1,7 @@ "use client"; import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions"; +import { getAccessFlags } from "@/lib/membership/utils"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Button } from "@/modules/ui/components/button"; @@ -18,7 +19,6 @@ import { useTranslate } from "@tolgee/react"; import { SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganization, ZOrganization } from "@formbricks/types/organizations"; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/loading.test.tsx new file mode 100644 index 000000000000..a6f8614d086b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/loading.test.tsx @@ -0,0 +1,67 @@ +import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { getTranslate } from "@/tolgee/server"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import Loading from "./loading"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, +})); + +vi.mock("@/tolgee/server", () => ({ + getTranslate: vi.fn(), +})); + +vi.mock( + "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar", + () => ({ + OrganizationSettingsNavbar: vi.fn(() =>
OrganizationSettingsNavbar
), + }) +); + +vi.mock("@/app/(app)/components/LoadingCard", () => ({ + LoadingCard: vi.fn(({ title, description }) => ( +
+
{title}
+
{description}
+
+ )), +})); + +describe("Loading", () => { + const mockTranslate = vi.fn((key) => key); + + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getTranslate).mockResolvedValue(mockTranslate); + }); + + test("renders loading state correctly", async () => { + const LoadingComponent = await Loading(); + render(LoadingComponent); + + expect(screen.getByText("environments.settings.general.organization_settings")).toBeInTheDocument(); + expect(OrganizationSettingsNavbar).toHaveBeenCalledWith( + { + isFormbricksCloud: IS_FORMBRICKS_CLOUD, + activeId: "general", + loading: true, + }, + undefined + ); + + expect(screen.getByText("environments.settings.general.organization_name")).toBeInTheDocument(); + expect( + screen.getByText("environments.settings.general.organization_name_description") + ).toBeInTheDocument(); + expect(screen.getByText("environments.settings.general.delete_organization")).toBeInTheDocument(); + expect( + screen.getByText("environments.settings.general.delete_organization_description") + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/loading.tsx index d588451b7344..12f2f9f7d941 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/loading.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/loading.tsx @@ -1,9 +1,9 @@ import { LoadingCard } from "@/app/(app)/components/LoadingCard"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getTranslate } from "@/tolgee/server"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; const Loading = async () => { const t = await getTranslate(); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.test.tsx index 7b097c666b1a..b843308c0eab 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.test.tsx @@ -1,17 +1,20 @@ -import { - getIsMultiOrgEnabled, - getIsOrganizationAIReady, - getWhiteLabelPermission, -} from "@/modules/ee/license-check/lib/utils"; +import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; +import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { getUser } from "@/lib/user/service"; +import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils"; +import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import { getTranslate } from "@/tolgee/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getUser } from "@formbricks/lib/user/service"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { TUser } from "@formbricks/types/user"; +import { DeleteOrganization } from "./components/DeleteOrganization"; +import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm"; import Page from "./page"; -vi.mock("@formbricks/lib/constants", () => ({ +vi.mock("@/lib/constants", () => ({ IS_FORMBRICKS_CLOUD: false, IS_PRODUCTION: false, FB_LOGO_URL: "https://example.com/mock-logo.png", @@ -33,12 +36,6 @@ vi.mock("@formbricks/lib/constants", () => ({ WEBAPP_URL: "mock-webapp-url", SMTP_HOST: "mock-smtp-host", SMTP_PORT: "mock-smtp-port", - AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name", - AI_AZURE_LLM_API_KEY: "mock-ai", - AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id", - AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name", - AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key", - AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id", })); vi.mock("next-auth", () => ({ @@ -49,7 +46,7 @@ vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn(), })); -vi.mock("@formbricks/lib/user/service", () => ({ +vi.mock("@/lib/user/service", () => ({ getUser: vi.fn(), })); @@ -59,11 +56,37 @@ vi.mock("@/modules/environments/lib/utils", () => ({ vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsMultiOrgEnabled: vi.fn(), - getIsOrganizationAIReady: vi.fn(), getWhiteLabelPermission: vi.fn(), })); +vi.mock( + "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar", + () => ({ + OrganizationSettingsNavbar: vi.fn(() =>
OrganizationSettingsNavbar
), + }) +); + +vi.mock("./components/EditOrganizationNameForm", () => ({ + EditOrganizationNameForm: vi.fn(() =>
EditOrganizationNameForm
), +})); + +vi.mock("@/modules/ee/whitelabel/email-customization/components/email-customization-settings", () => ({ + EmailCustomizationSettings: vi.fn(() =>
EmailCustomizationSettings
), +})); + +vi.mock("./components/DeleteOrganization", () => ({ + DeleteOrganization: vi.fn(() =>
DeleteOrganization
), +})); + +vi.mock("@/modules/ui/components/id-badge", () => ({ + IdBadge: vi.fn(() =>
IdBadge
), +})); + describe("Page", () => { + afterEach(() => { + cleanup(); + }); + let mockEnvironmentAuth = { session: { user: { id: "test-user-id" } }, currentUserMembership: { role: "owner" }, @@ -74,41 +97,178 @@ describe("Page", () => { const mockUser = { id: "test-user-id" } as TUser; const mockTranslate = vi.fn((key) => key); + const mockParams = { environmentId: "env-123" }; beforeEach(() => { + vi.resetAllMocks(); vi.mocked(getTranslate).mockResolvedValue(mockTranslate); vi.mocked(getUser).mockResolvedValue(mockUser); vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth); vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); - vi.mocked(getIsOrganizationAIReady).mockResolvedValue(true); vi.mocked(getWhiteLabelPermission).mockResolvedValue(true); }); - it("renders the page with organization settings", async () => { + test("renders the page with organization settings for owner", async () => { const props = { - params: Promise.resolve({ environmentId: "env-123" }), + params: Promise.resolve(mockParams), }; - const result = await Page(props); + const PageComponent = await Page(props); + render(PageComponent); - expect(result).toBeTruthy(); + expect(screen.getByText("environments.settings.general.organization_settings")).toBeInTheDocument(); + expect(OrganizationSettingsNavbar).toHaveBeenCalledWith( + { + environmentId: mockParams.environmentId, + isFormbricksCloud: IS_FORMBRICKS_CLOUD, + membershipRole: "owner", + activeId: "general", + }, + undefined + ); + expect(screen.getByText("environments.settings.general.organization_name")).toBeInTheDocument(); + expect(EditOrganizationNameForm).toHaveBeenCalledWith( + { + organization: mockEnvironmentAuth.organization, + environmentId: mockParams.environmentId, + membershipRole: "owner", + }, + undefined + ); + expect(EmailCustomizationSettings).toHaveBeenCalledWith( + { + organization: mockEnvironmentAuth.organization, + hasWhiteLabelPermission: true, + environmentId: mockParams.environmentId, + isReadOnly: false, + isFormbricksCloud: IS_FORMBRICKS_CLOUD, + fbLogoUrl: FB_LOGO_URL, + user: mockUser, + }, + undefined + ); + expect(screen.getByText("environments.settings.general.delete_organization")).toBeInTheDocument(); + expect(DeleteOrganization).toHaveBeenCalledWith( + { + organization: mockEnvironmentAuth.organization, + isDeleteDisabled: false, + isUserOwner: true, + }, + undefined + ); + expect(IdBadge).toHaveBeenCalledWith( + { + id: mockEnvironmentAuth.organization.id, + label: "common.organization_id", + variant: "column", + }, + undefined + ); }); - it("renders if session user id empty", async () => { - mockEnvironmentAuth.session.user.id = ""; + test("renders correctly when user is manager", async () => { + const managerAuth = { + ...mockEnvironmentAuth, + currentUserMembership: { role: "manager" }, + isOwner: false, + isManager: true, + } as unknown as TEnvironmentAuth; + vi.mocked(getEnvironmentAuth).mockResolvedValue(managerAuth); - vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth); + const props = { + params: Promise.resolve(mockParams), + }; + const PageComponent = await Page(props); + render(PageComponent); + expect(EmailCustomizationSettings).toHaveBeenCalledWith( + expect.objectContaining({ + isReadOnly: false, // owner or manager can edit + }), + undefined + ); + expect(DeleteOrganization).toHaveBeenCalledWith( + expect.objectContaining({ + isDeleteDisabled: true, // only owner can delete + isUserOwner: false, + }), + undefined + ); + }); + + test("renders correctly when multi-org is disabled", async () => { + vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); const props = { - params: Promise.resolve({ environmentId: "env-123" }), + params: Promise.resolve(mockParams), }; + const PageComponent = await Page(props); + render(PageComponent); + + expect(screen.queryByText("environments.settings.general.delete_organization")).not.toBeInTheDocument(); + expect(DeleteOrganization).not.toHaveBeenCalled(); + // isDeleteDisabled should be true because multiOrg is disabled, even for owner + expect(EmailCustomizationSettings).toHaveBeenCalledWith( + expect.objectContaining({ + isReadOnly: false, + }), + undefined + ); + }); + + test("renders correctly when user is not owner or manager (e.g., admin)", async () => { + const adminAuth = { + ...mockEnvironmentAuth, + currentUserMembership: { role: "admin" }, + isOwner: false, + isManager: false, + } as unknown as TEnvironmentAuth; + vi.mocked(getEnvironmentAuth).mockResolvedValue(adminAuth); - const result = await Page(props); + const props = { + params: Promise.resolve(mockParams), + }; + const PageComponent = await Page(props); + render(PageComponent); + + expect(EmailCustomizationSettings).toHaveBeenCalledWith( + expect.objectContaining({ + isReadOnly: true, + }), + undefined + ); + expect(DeleteOrganization).toHaveBeenCalledWith( + expect.objectContaining({ + isDeleteDisabled: true, + isUserOwner: false, + }), + undefined + ); + }); + + test("renders if session user id empty, user is null", async () => { + const noUserSessionAuth = { + ...mockEnvironmentAuth, + session: { ...mockEnvironmentAuth.session, user: { ...mockEnvironmentAuth.session.user, id: "" } }, + }; + vi.mocked(getEnvironmentAuth).mockResolvedValue(noUserSessionAuth); + vi.mocked(getUser).mockResolvedValue(null); + + const props = { + params: Promise.resolve(mockParams), + }; - expect(result).toBeTruthy(); + const PageComponent = await Page(props); + render(PageComponent); + expect(screen.getByText("environments.settings.general.organization_settings")).toBeInTheDocument(); + expect(EmailCustomizationSettings).toHaveBeenCalledWith( + expect.objectContaining({ + user: null, + }), + undefined + ); }); - it("handles getEnvironmentAuth error", async () => { + test("handles getEnvironmentAuth error", async () => { vi.mocked(getEnvironmentAuth).mockRejectedValue(new Error("Authentication error")); const props = { diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx index dfb66fd1f605..bff6e3f0dfae 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx @@ -1,18 +1,13 @@ import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; -import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle"; -import { - getIsMultiOrgEnabled, - getIsOrganizationAIReady, - getWhiteLabelPermission, -} from "@/modules/ee/license-check/lib/utils"; +import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { getUser } from "@/lib/user/service"; +import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils"; import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; -import { SettingsId } from "@/modules/ui/components/settings-id"; import { getTranslate } from "@/tolgee/server"; -import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; -import { getUser } from "@formbricks/lib/user/service"; import { SettingsCard } from "../../components/SettingsCard"; import { DeleteOrganization } from "./components/DeleteOrganization"; import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm"; @@ -35,8 +30,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => { const isOwnerOrManager = isManager || isOwner; - const isOrganizationAIReady = await getIsOrganizationAIReady(organization.billing.plan); - return ( @@ -56,17 +49,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => { membershipRole={currentUserMembership?.role} /> - {isOrganizationAIReady && ( - - - - )} }) => { )} - + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/layout.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/layout.test.tsx new file mode 100644 index 000000000000..6c45e9fe58c6 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/layout.test.tsx @@ -0,0 +1,98 @@ +import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import { getProjectByEnvironmentId } from "@/lib/project/service"; +import { cleanup, render, screen } from "@testing-library/react"; +import { Session, getServerSession } from "next-auth"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TProject } from "@formbricks/types/project"; +import OrganizationSettingsLayout from "./layout"; + +// Mock dependencies +vi.mock("@/lib/organization/service"); +vi.mock("@/lib/project/service"); +vi.mock("next-auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getServerSession: vi.fn(), + }; +}); +vi.mock("@/modules/auth/lib/authOptions", () => ({ + authOptions: {}, // Mock authOptions if it's directly used or causes issues +})); + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", +})); + +const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId); +const mockGetProjectByEnvironmentId = vi.mocked(getProjectByEnvironmentId); +const mockGetServerSession = vi.mocked(getServerSession); + +const mockOrganization = { id: "org_test_id" } as unknown as TOrganization; +const mockProject = { id: "project_test_id" } as unknown as TProject; +const mockSession = { user: { id: "user_test_id" } } as unknown as Session; + +const t = (key: string) => key; +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => t, +})); + +const mockProps = { + params: { environmentId: "env_test_id" }, + children:
Child Content for Organization Settings
, +}; + +describe("OrganizationSettingsLayout", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + vi.resetAllMocks(); + + mockGetOrganizationByEnvironmentId.mockResolvedValue(mockOrganization); + mockGetProjectByEnvironmentId.mockResolvedValue(mockProject); + mockGetServerSession.mockResolvedValue(mockSession); + }); + + test("should render children when all data is fetched successfully", async () => { + render(await OrganizationSettingsLayout(mockProps)); + expect(screen.getByText("Child Content for Organization Settings")).toBeInTheDocument(); + }); + + test("should throw error if organization is not found", async () => { + mockGetOrganizationByEnvironmentId.mockResolvedValue(null); + await expect(OrganizationSettingsLayout(mockProps)).rejects.toThrowError("common.organization_not_found"); + }); + + test("should throw error if project is not found", async () => { + mockGetProjectByEnvironmentId.mockResolvedValue(null); + await expect(OrganizationSettingsLayout(mockProps)).rejects.toThrowError("common.project_not_found"); + }); + + test("should throw error if session is not found", async () => { + mockGetServerSession.mockResolvedValue(null); + await expect(OrganizationSettingsLayout(mockProps)).rejects.toThrowError("common.session_not_found"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/layout.tsx index 857892f436d9..da17518960f2 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/layout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/layout.tsx @@ -1,8 +1,8 @@ +import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import { getProjectByEnvironmentId } from "@/lib/project/service"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { getTranslate } from "@/tolgee/server"; import { getServerSession } from "next-auth"; -import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; const Layout = async (props) => { const params = await props.params; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/page.test.tsx new file mode 100644 index 000000000000..6c2f3211969b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/page.test.tsx @@ -0,0 +1,47 @@ +import { TeamsPage } from "@/modules/organization/settings/teams/page"; +import { describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", + FB_LOGO_URL: "mock-fb-logo-url", + SMTP_HOST: "mock-smtp-host", + SMTP_PORT: 587, + SMTP_USER: "mock-smtp-user", + SMTP_PASSWORD: "mock-smtp-password", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: 1, +})); + +vi.mock("@/lib/env", () => ({ + env: { + PUBLIC_URL: "https://public-domain.com", + }, +})); + +describe("TeamsPage re-export", () => { + test("should re-export TeamsPage component", () => { + expect(Page).toBe(TeamsPage); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsCard.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsCard.test.tsx new file mode 100644 index 000000000000..3bda6fef328d --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsCard.test.tsx @@ -0,0 +1,72 @@ +import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +vi.mock("@/modules/ui/components/badge", () => ({ + Badge: ({ text }) =>
{text}
, +})); + +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key) => key, // Mock t function to return the key + }), +})); + +describe("SettingsCard", () => { + afterEach(() => { + cleanup(); + }); + + const defaultProps = { + title: "Test Title", + description: "Test Description", + children:
Child Content
, + }; + + test("renders title, description, and children", () => { + render(); + expect(screen.getByText(defaultProps.title)).toBeInTheDocument(); + expect(screen.getByText(defaultProps.description)).toBeInTheDocument(); + expect(screen.getByTestId("child-content")).toBeInTheDocument(); + }); + + test("renders Beta badge when beta prop is true", () => { + render(); + const badgeElement = screen.getByTestId("mock-badge"); + expect(badgeElement).toBeInTheDocument(); + expect(badgeElement).toHaveTextContent("Beta"); + }); + + test("renders Soon badge when soon prop is true", () => { + render(); + const badgeElement = screen.getByTestId("mock-badge"); + expect(badgeElement).toBeInTheDocument(); + expect(badgeElement).toHaveTextContent("environments.settings.enterprise.coming_soon"); + }); + + test("does not render badges when beta and soon props are false", () => { + render(); + expect(screen.queryByTestId("mock-badge")).not.toBeInTheDocument(); + }); + + test("applies default padding when noPadding prop is false", () => { + render(); + const childrenContainer = screen.getByTestId("child-content").parentElement; + expect(childrenContainer).toHaveClass("px-4 pt-4"); + }); + + test("applies custom className to the root element", () => { + const customClass = "my-custom-class"; + render(); + const cardElement = screen.getByText(defaultProps.title).closest("div.relative"); + expect(cardElement).toHaveClass(customClass); + }); + + test("renders with default classes", () => { + render(); + const cardElement = screen.getByText(defaultProps.title).closest("div.relative"); + expect(cardElement).toHaveClass( + "relative my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 text-left shadow-sm" + ); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsCard.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsCard.tsx index 1ed3bd21bc9a..08852893690a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsCard.tsx @@ -1,8 +1,9 @@ "use client"; +import { cn } from "@/lib/cn"; import { Badge } from "@/modules/ui/components/badge"; +import { H3, Small } from "@/modules/ui/components/typography"; import { useTranslate } from "@tolgee/react"; -import { cn } from "@formbricks/lib/cn"; export const SettingsCard = ({ title, @@ -31,7 +32,7 @@ export const SettingsCard = ({ id={title}>
-

{title}

+

{title}

{beta && } {soon && ( @@ -39,7 +40,9 @@ export const SettingsCard = ({ )}
-

{description}

+ + {description} +
{children}
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsTitle.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsTitle.test.tsx new file mode 100644 index 000000000000..c050c2920faa --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsTitle.test.tsx @@ -0,0 +1,25 @@ +import { SettingsTitle } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsTitle"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; + +describe("SettingsTitle", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the title correctly", () => { + const titleText = "My Awesome Settings"; + render(); + const headingElement = screen.getByRole("heading", { name: titleText, level: 2 }); + expect(headingElement).toBeInTheDocument(); + expect(headingElement).toHaveTextContent(titleText); + expect(headingElement).toHaveClass("my-4 text-2xl font-medium leading-6 text-slate-800"); + }); + + test("renders with an empty title", () => { + render(); + const headingElement = screen.getByRole("heading", { level: 2 }); + expect(headingElement).toBeInTheDocument(); + expect(headingElement).toHaveTextContent(""); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/page.test.tsx new file mode 100644 index 000000000000..b2f786228a06 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/page.test.tsx @@ -0,0 +1,15 @@ +import { redirect } from "next/navigation"; +import { describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +describe("Settings Page", () => { + test("should redirect to profile settings page", async () => { + const params = { environmentId: "testEnvId" }; + await Page({ params }); + expect(redirect).toHaveBeenCalledWith(`/environments/${params.environmentId}/settings/profile`); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions.ts index 7c7b68503f3a..43b6aacdbaf1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions.ts @@ -1,12 +1,11 @@ "use server"; -import { generateInsightsForSurvey } from "@/app/api/(internal)/insights/lib/utils"; +import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { getResponseCountBySurveyId, getResponses } from "@formbricks/lib/response/service"; import { ZId } from "@formbricks/types/common"; import { ZResponseFilterCriteria } from "@formbricks/types/responses"; import { getSurveySummary } from "./summary/lib/surveySummary"; @@ -76,7 +75,6 @@ export const getSurveySummaryAction = authenticatedActionClient }, ], }); - return getSurveySummary(parsedInput.surveyId, parsedInput.filterCriteria); }); @@ -108,31 +106,3 @@ export const getResponseCountAction = authenticatedActionClient return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria); }); - -const ZGenerateInsightsForSurveyAction = z.object({ - surveyId: ZId, -}); - -export const generateInsightsForSurveyAction = authenticatedActionClient - .schema(ZGenerateInsightsForSurveyAction) - .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - access: [ - { - type: "organization", - schema: ZGenerateInsightsForSurveyAction, - data: parsedInput, - roles: ["owner", "manager"], - }, - { - type: "projectTeam", - projectId: await getProjectIdFromSurveyId(parsedInput.surveyId), - minPermission: "readWrite", - }, - ], - }); - - generateInsightsForSurvey(parsedInput.surveyId); - }); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys.test.tsx new file mode 100644 index 000000000000..ec298b7eb9b1 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys.test.tsx @@ -0,0 +1,37 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { Unplug } from "lucide-react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { EmptyAppSurveys } from "./EmptyInAppSurveys"; + +vi.mock("lucide-react", async () => { + const actual = await vi.importActual("lucide-react"); + return { + ...actual, + Unplug: vi.fn(() =>
), + }; +}); + +const mockEnvironment = { + id: "test-env-id", +} as unknown as TEnvironment; + +describe("EmptyAppSurveys", () => { + afterEach(() => { + cleanup(); + }); + + test("renders correctly with translated text and icon", () => { + render(); + + expect(screen.getByTestId("unplug-icon")).toBeInTheDocument(); + expect(Unplug).toHaveBeenCalled(); + + expect(screen.getByText("environments.surveys.summary.youre_not_plugged_in_yet")).toBeInTheDocument(); + expect( + screen.getByText( + "environments.surveys.summary.connect_your_website_or_app_with_formbricks_to_get_started" + ) + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.test.tsx new file mode 100644 index 000000000000..7682f17a50a4 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.test.tsx @@ -0,0 +1,227 @@ +import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import { + getResponseCountAction, + revalidateSurveyIdPath, +} 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 { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { useParams, usePathname, useSearchParams } from "next/navigation"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TLanguage } from "@formbricks/types/project"; +import { + TSurvey, + TSurveyLanguage, + TSurveyQuestion, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", + FB_LOGO_URL: "mock-fb-logo-url", + SMTP_HOST: "mock-smtp-host", + SMTP_PORT: 587, + SMTP_USER: "mock-smtp-user", + SMTP_PASSWORD: "mock-smtp-password", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: true, +})); + +vi.mock("@/lib/env", () => ({ + env: { + PUBLIC_URL: "https://public-domain.com", + }, +})); + +vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"); +vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"); +vi.mock("@/app/lib/surveys/surveys"); +vi.mock("@/modules/ui/components/secondary-navigation", () => ({ + SecondaryNavigation: vi.fn(() =>
), +})); +vi.mock("next/navigation", () => ({ + usePathname: vi.fn(), + useParams: vi.fn(), + useSearchParams: vi.fn(), +})); + +const mockUsePathname = vi.mocked(usePathname); +const mockUseParams = vi.mocked(useParams); +const mockUseSearchParams = vi.mocked(useSearchParams); +const mockUseResponseFilter = vi.mocked(useResponseFilter); +const mockGetResponseCountAction = vi.mocked(getResponseCountAction); +const mockRevalidateSurveyIdPath = vi.mocked(revalidateSurveyIdPath); +const mockGetFormattedFilters = vi.mocked(getFormattedFilters); +const MockSecondaryNavigation = vi.mocked(SecondaryNavigation); + +const mockSurveyLanguages: TSurveyLanguage[] = [ + { language: { code: "en-US" } as unknown as TLanguage, default: true, enabled: true }, +]; + +const mockSurvey = { + id: "surveyId123", + name: "Test Survey", + type: "app", + environmentId: "envId123", + status: "inProgress", + questions: [ + { + id: "question1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Question 1" }, + required: false, + logic: [], + isDraft: false, + imageUrl: "", + subheader: { default: "" }, + } as unknown as TSurveyQuestion, + ], + hiddenFields: { enabled: false, fieldIds: [] }, + displayOption: "displayOnce", + autoClose: null, + triggers: [], + createdAt: new Date(), + updatedAt: new Date(), + languages: mockSurveyLanguages, + variables: [], + singleUse: null, + styling: null, + surveyClosedMessage: null, + welcomeCard: { enabled: false, headline: { default: "" } } as unknown as TSurvey["welcomeCard"], + segment: null, + closeOnDate: null, + delay: 0, + autoComplete: null, + recontactDays: null, + runOnDate: null, + displayPercentage: null, + createdBy: null, +} as unknown as TSurvey; + +const defaultProps = { + environmentId: "testEnvId", + survey: mockSurvey, + activeId: "summary", +}; + +describe("SurveyAnalysisNavigation", () => { + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + test("calls revalidateSurveyIdPath on navigation item click", async () => { + mockUsePathname.mockReturnValue( + `/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary` + ); + mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id }); + mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any); + mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any); + mockGetFormattedFilters.mockReturnValue([] as any); + mockGetResponseCountAction.mockResolvedValue({ data: 5 }); + + render(); + await waitFor(() => expect(MockSecondaryNavigation).toHaveBeenCalled()); + + const lastCallArgs = MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0]; + + if (!lastCallArgs.navigation || lastCallArgs.navigation.length < 2) { + throw new Error("Navigation items not found"); + } + + act(() => { + (lastCallArgs.navigation[0] as any).onClick(); + }); + expect(mockRevalidateSurveyIdPath).toHaveBeenCalledWith( + defaultProps.environmentId, + defaultProps.survey.id + ); + vi.mocked(mockRevalidateSurveyIdPath).mockClear(); + + act(() => { + (lastCallArgs.navigation[1] as any).onClick(); + }); + expect(mockRevalidateSurveyIdPath).toHaveBeenCalledWith( + defaultProps.environmentId, + defaultProps.survey.id + ); + }); + + test("displays correct response count string in label for various scenarios", async () => { + mockUsePathname.mockReturnValue( + `/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses` + ); + mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id }); + mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any); + mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any); + mockGetFormattedFilters.mockReturnValue([] as any); + + // Scenario 1: total = 10, filtered = null (initial state) + render(); + expect(MockSecondaryNavigation.mock.calls[0][0].navigation[1].label).toBe("common.responses"); + cleanup(); + vi.resetAllMocks(); // Reset mocks for next case + + // Scenario 2: total = 15, filtered = 15 + mockUsePathname.mockReturnValue( + `/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses` + ); + mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id }); + mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any); + mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any); + mockGetFormattedFilters.mockReturnValue([] as any); + mockGetResponseCountAction.mockImplementation(async (args) => { + if (args && "filterCriteria" in args) return { data: 15, error: null, success: true }; + return { data: 15, error: null, success: true }; + }); + render(); + await waitFor(() => { + const lastCallArgs = + MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0]; + expect(lastCallArgs.navigation[1].label).toBe("common.responses"); + }); + cleanup(); + vi.resetAllMocks(); + + // Scenario 3: total = 10, filtered = 15 (filtered > total) + mockUsePathname.mockReturnValue( + `/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses` + ); + mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id }); + mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any); + mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any); + mockGetFormattedFilters.mockReturnValue([] as any); + mockGetResponseCountAction.mockImplementation(async (args) => { + if (args && "filterCriteria" in args) return { data: 15, error: null, success: true }; + return { data: 10, error: null, success: true }; + }); + render(); + await waitFor(() => { + const lastCallArgs = + MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0]; + expect(lastCallArgs.navigation[1].label).toBe("common.responses"); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.tsx index 89614bfb94d0..f821921e14eb 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.tsx @@ -1,105 +1,27 @@ "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 { 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 { useIntervalWhenFocused } from "@formbricks/lib/utils/hooks/useIntervalWhenFocused"; +import { 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(null); - const [totalResponseCount, setTotalResponseCount] = useState(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 url = `/environments/${environmentId}/surveys/${survey.id}`; const navigation = [ { @@ -114,7 +36,7 @@ export const SurveyAnalysisNavigation = ({ }, { id: "responses", - label: `${t("common.responses")} ${getResponseCountString()}`, + label: t("common.responses"), icon: , href: `${url}/responses?referer=true`, current: pathname?.includes("/responses"), diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/layout.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/layout.test.tsx new file mode 100644 index 000000000000..341bae06296c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/layout.test.tsx @@ -0,0 +1,123 @@ +import { getResponseCountBySurveyId } from "@/lib/response/service"; +import { getSurvey } from "@/lib/survey/service"; +import { authOptions } from "@/modules/auth/lib/authOptions"; +import { cleanup, render, screen } from "@testing-library/react"; +import { getServerSession } from "next-auth"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import SurveyLayout, { generateMetadata } from "./layout"; + +vi.mock("@/lib/response/service", () => ({ + getResponseCountBySurveyId: vi.fn(), +})); + +vi.mock("@/lib/survey/service", () => ({ + getSurvey: vi.fn(), +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("@/modules/auth/lib/authOptions", () => ({ + authOptions: {}, +})); + +const mockSurveyId = "survey_123"; +const mockEnvironmentId = "env_456"; +const mockSurveyName = "Test Survey"; +const mockResponseCount = 10; + +const mockSurvey = { + id: mockSurveyId, + name: mockSurveyName, + questions: [], + endings: [], + status: "inProgress", + type: "app", + environmentId: mockEnvironmentId, + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + variables: [], + triggers: [], + styling: null, + languages: [], + segment: null, + autoClose: null, + closeOnDate: null, + delay: 0, + displayLimit: null, + displayOption: "displayOnce", + isBackButtonHidden: false, + pin: null, + recontactDays: null, + runOnDate: null, + showLanguageSwitch: false, + singleUse: null, + surveyClosedMessage: null, + createdAt: new Date(), + updatedAt: new Date(), + autoComplete: null, + hiddenFields: { enabled: false, fieldIds: [] }, +} as unknown as TSurvey; + +describe("SurveyLayout", () => { + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + describe("generateMetadata", () => { + test("should return correct metadata when session and survey exist", async () => { + vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user_test_id" } }); + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + vi.mocked(getResponseCountBySurveyId).mockResolvedValue(mockResponseCount); + + const metadata = await generateMetadata({ + params: Promise.resolve({ surveyId: mockSurveyId, environmentId: mockEnvironmentId }), + }); + + expect(metadata).toEqual({ + title: `${mockResponseCount} Responses | ${mockSurveyName} Results`, + }); + expect(getServerSession).toHaveBeenCalledWith(authOptions); + expect(getSurvey).toHaveBeenCalledWith(mockSurveyId); + expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId); + }); + + test("should return correct metadata when survey is null", async () => { + vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user_test_id" } }); + vi.mocked(getSurvey).mockResolvedValue(null); + vi.mocked(getResponseCountBySurveyId).mockResolvedValue(mockResponseCount); + + const metadata = await generateMetadata({ + params: Promise.resolve({ surveyId: mockSurveyId, environmentId: mockEnvironmentId }), + }); + + expect(metadata).toEqual({ + title: `${mockResponseCount} Responses | undefined Results`, + }); + }); + + test("should return empty title when session does not exist", async () => { + vi.mocked(getServerSession).mockResolvedValue(null); + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + vi.mocked(getResponseCountBySurveyId).mockResolvedValue(mockResponseCount); + + const metadata = await generateMetadata({ + params: Promise.resolve({ surveyId: mockSurveyId, environmentId: mockEnvironmentId }), + }); + + expect(metadata).toEqual({ + title: "", + }); + }); + }); + + describe("SurveyLayout Component", () => { + test("should render children", async () => { + const childText = "Test Child Component"; + render(await SurveyLayout({ children:
{childText}
})); + expect(screen.getByText(childText)).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/layout.tsx index fe5477082e9c..1eb4de6d19b4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/layout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/layout.tsx @@ -1,8 +1,8 @@ +import { getResponseCountBySurveyId } from "@/lib/response/service"; +import { getSurvey } from "@/lib/survey/service"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { Metadata } from "next"; import { getServerSession } from "next-auth"; -import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; -import { getSurvey } from "@formbricks/lib/survey/service"; type Props = { params: Promise<{ surveyId: string; environmentId: string }>; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.test.tsx new file mode 100644 index 000000000000..cfc1575471fb --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.test.tsx @@ -0,0 +1,254 @@ +import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal"; +import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TTag } from "@formbricks/types/tags"; +import { TUser, TUserLocale } from "@formbricks/types/user"; + +vi.mock("@/modules/analysis/components/SingleResponseCard", () => ({ + SingleResponseCard: vi.fn(() =>
SingleResponseCard
), +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: vi.fn(({ children, onClick, disabled, variant, className }) => ( + + )), +})); + +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: vi.fn(({ children, open, onOpenChange }) => + open ? ( +
+ {children} + +
+ ) : null + ), + DialogContent: vi.fn(({ children, hideCloseButton, width, className }) => ( +
+ {children} +
+ )), + DialogBody: vi.fn(({ children }) =>
{children}
), + DialogFooter: vi.fn(({ children }) =>
{children}
), +})); + +const mockResponses = [ + { + id: "response1", + createdAt: new Date(), + updatedAt: new Date(), + surveyId: "survey1", + finished: true, + data: {}, + meta: { + userAgent: { browser: "Chrome", os: "Mac OS", device: "Desktop" }, + url: "http://localhost:3000", + }, + tags: [], + } as unknown as TResponse, + { + id: "response2", + createdAt: new Date(), + updatedAt: new Date(), + surveyId: "survey1", + finished: true, + data: {}, + meta: { + userAgent: { browser: "Firefox", os: "Windows", device: "Desktop" }, + url: "http://localhost:3000/page2", + }, + tags: [], + } as unknown as TResponse, + { + id: "response3", + createdAt: new Date(), + updatedAt: new Date(), + surveyId: "survey1", + finished: false, + data: {}, + meta: { + userAgent: { browser: "Safari", os: "iOS", device: "Mobile" }, + url: "http://localhost:3000/page3", + }, + tags: [], + } as unknown as TResponse, +] as unknown as TResponse[]; + +const mockSurvey = { + id: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + type: "app", + environmentId: "env1", + status: "inProgress", + questions: [], + hiddenFields: { enabled: false, fieldIds: [] }, + displayOption: "displayOnce", + recontactDays: 0, + autoClose: null, + closeOnDate: null, + delay: 0, + autoComplete: null, + surveyClosedMessage: null, + singleUse: null, + triggers: [], + languages: [], + displayPercentage: null, + welcomeCard: { enabled: false, headline: { default: "Welcome!" } } as unknown as TSurvey["welcomeCard"], + styling: null, +} as unknown as TSurvey; + +const mockEnvironment = { + id: "env1", + createdAt: new Date(), + updatedAt: new Date(), + type: "development", + appSetupCompleted: false, +} as unknown as TEnvironment; + +const mockUser = { + id: "user1", + name: "Test User", + email: "test@example.com", + emailVerified: new Date(), + imageUrl: "", + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + role: "project_manager", + objective: "increase_conversion", + notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] }, +} as unknown as TUser; + +const mockEnvironmentTags: TTag[] = [ + { id: "tag1", createdAt: new Date(), updatedAt: new Date(), name: "Tag 1", environmentId: "env1" }, +]; + +const mockLocale: TUserLocale = "en-US"; + +const mockSetSelectedResponseId = vi.fn(); +const mockUpdateResponse = vi.fn(); +const mockDeleteResponses = vi.fn(); +const mockSetOpen = vi.fn(); + +const defaultProps = { + responses: mockResponses, + selectedResponseId: mockResponses[0].id, + setSelectedResponseId: mockSetSelectedResponseId, + survey: mockSurvey, + environment: mockEnvironment, + user: mockUser, + environmentTags: mockEnvironmentTags, + updateResponse: mockUpdateResponse, + deleteResponses: mockDeleteResponses, + isReadOnly: false, + open: true, + setOpen: mockSetOpen, + locale: mockLocale, +}; + +describe("ResponseCardModal", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should not render if selectedResponseId is null", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + }); + + test("should render the dialog when a response is selected", () => { + render(); + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("single-response-card")).toBeInTheDocument(); + }); + + test("should call setSelectedResponseId with the next response id when next button is clicked", async () => { + render(); + const buttons = screen.getAllByTestId("mock-button"); + const nextButton = buttons.find((button) => button.querySelector("svg.lucide-chevron-right")); + if (nextButton) await userEvent.click(nextButton); + expect(mockSetSelectedResponseId).toHaveBeenCalledWith(mockResponses[1].id); + }); + + test("should call setSelectedResponseId with the previous response id when back button is clicked", async () => { + render(); + const buttons = screen.getAllByTestId("mock-button"); + const backButton = buttons.find((button) => button.querySelector("svg.lucide-chevron-left")); + if (backButton) await userEvent.click(backButton); + expect(mockSetSelectedResponseId).toHaveBeenCalledWith(mockResponses[0].id); + }); + + test("should disable back button if current response is the first one", () => { + render(); + const buttons = screen.getAllByTestId("mock-button"); + const backButton = buttons.find((button) => button.querySelector("svg.lucide-chevron-left")); + expect(backButton).toBeDisabled(); + }); + + test("should disable next button if current response is the last one", () => { + render( + + ); + const buttons = screen.getAllByTestId("mock-button"); + const nextButton = buttons.find((button) => button.querySelector("svg.lucide-chevron-right")); + expect(nextButton).toBeDisabled(); + }); + + test("useEffect should set open to true and currentIndex when selectedResponseId is provided", () => { + render(); + expect(mockSetOpen).toHaveBeenCalledWith(true); + // Current index is internal state, but we can check if the correct response is displayed + // by checking the props passed to SingleResponseCard + expect(vi.mocked(SingleResponseCard).mock.calls[0][0].response).toEqual(mockResponses[1]); + }); + + test("useEffect should set open to false when selectedResponseId is null after being open", () => { + const { rerender } = render( + + ); + expect(mockSetOpen).toHaveBeenCalledWith(true); + rerender(); + expect(mockSetOpen).toHaveBeenCalledWith(false); + }); + + test("should render ChevronLeft and ChevronRight icons", () => { + render(); + expect(document.querySelector(".lucide-chevron-left")).toBeInTheDocument(); + expect(document.querySelector(".lucide-chevron-right")).toBeInTheDocument(); + }); +}); + +// Mock Lucide icons for easier querying +vi.mock("lucide-react", async () => { + const actual = await vi.importActual("lucide-react"); + return { + ...actual, + ChevronLeft: vi.fn((props) => ), + ChevronRight: vi.fn((props) => ), + XIcon: vi.fn((props) => ), + }; +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx index 1ac85cdc851e..9b5dcddd4602 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx @@ -1,7 +1,7 @@ import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard"; import { Button } from "@/modules/ui/components/button"; -import { Modal } from "@/modules/ui/components/modal"; -import { ChevronLeft, ChevronRight, XIcon } from "lucide-react"; +import { Dialog, DialogBody, DialogContent, DialogFooter } from "@/modules/ui/components/dialog"; +import { ChevronLeft, ChevronRight } from "lucide-react"; import { useEffect, useState } from "react"; import { TEnvironment } from "@formbricks/types/environment"; import { TResponse } from "@formbricks/types/responses"; @@ -64,47 +64,24 @@ export const ResponseCardModal = ({ } }; - const handleClose = () => { - setSelectedResponseId(null); + const handleClose = (open: boolean) => { + setOpen(open); + if (!open) { + setSelectedResponseId(null); + } }; // If no response is selected or currentIndex is null, do not render the modal if (selectedResponseId === null || currentIndex === null) return null; return ( - -
-
-
- - - -
+ + + -
-
-
+ + + + + + + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.test.tsx new file mode 100644 index 000000000000..e30a49e7ae75 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.test.tsx @@ -0,0 +1,385 @@ +import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TResponse, TResponseDataValue } from "@formbricks/types/responses"; +import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { TTag } from "@formbricks/types/tags"; +import { TUser, TUserLocale } from "@formbricks/types/user"; +import { + ResponseDataView, + extractResponseData, + formatAddressData, + formatContactInfoData, + mapResponsesToTableData, +} from "./ResponseDataView"; + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable", + () => ({ + ResponseTable: vi.fn(() =>
ResponseTable
), + }) +); + +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: vi.fn((key) => { + if (key === "environments.surveys.responses.completed") return "Completed"; + if (key === "environments.surveys.responses.not_completed") return "Not Completed"; + return key; + }), + }), +})); + +const mockSurvey = { + id: "survey1", + name: "Test Survey", + type: "app", + status: "inProgress", + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Question 1" }, + required: true, + } as unknown as TSurveyQuestion, + { + id: "q2", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Question 2" }, + required: false, + choices: [{ id: "c1", label: { default: "Choice 1" } }], + }, + { + id: "matrix1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Matrix Question" }, + required: false, + rows: [{ id: "row1", label: "Row 1" }], + columns: [{ id: "col1", label: "Col 1" }], + } as unknown as TSurveyQuestion, + { + id: "address1", + type: TSurveyQuestionTypeEnum.Address, + headline: { default: "Address Question" }, + required: false, + } as unknown as TSurveyQuestion, + { + id: "contactInfo1", + type: TSurveyQuestionTypeEnum.ContactInfo, + headline: { default: "Contact Info Question" }, + required: false, + } as unknown as TSurveyQuestion, + ], + hiddenFields: { enabled: true, fieldIds: ["hidden1"] }, + variables: [{ id: "var1", name: "Variable 1", type: "text", value: "default" }], + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + autoClose: null, + closeOnDate: null, + delay: 0, + displayOption: "displayOnce", + recontactDays: null, + welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], + autoComplete: null, + singleUse: null, + styling: null, + surveyClosedMessage: null, + triggers: [], + languages: [], + displayPercentage: null, +} as unknown as TSurvey; + +const mockResponses: TResponse[] = [ + { + id: "response1", + createdAt: new Date(), + updatedAt: new Date(), + surveyId: "survey1", + finished: true, + data: { + q1: "Answer 1", + q2: "Choice 1", + matrix1: { row1: "Col 1" }, + address1: ["123 Main St", "Apt 4B", "Anytown", "CA", "90210", "USA"] as TResponseDataValue, + contactInfo1: [ + "John", + "Doe", + "john.doe@example.com", + "555-1234", + "Formbricks Inc.", + ] as TResponseDataValue, + hidden1: "Hidden Value 1", + verifiedEmail: "test@example.com", + }, + meta: { userAgent: { browser: "test-agent" }, url: "http://localhost" }, + singleUseId: null, + ttc: {}, + tags: [{ id: "tag1", name: "Tag1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() }], + variables: { var1: "Response Var Value" }, + language: "en", + contact: null, + contactAttributes: null, + }, + { + id: "response2", + createdAt: new Date(), + updatedAt: new Date(), + surveyId: "survey1", + finished: false, + data: { q1: "Answer 2" }, + meta: { userAgent: { browser: "test-agent-2" }, url: "http://localhost" }, + singleUseId: null, + ttc: {}, + tags: [], + variables: {}, + language: "de", + contact: null, + contactAttributes: null, + }, +]; + +const mockUser = { + id: "user1", + name: "Test User", + email: "test@example.com", + emailVerified: new Date(), + imageUrl: "", + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + role: "project_manager", + objective: "other", +} as unknown as TUser; + +const mockEnvironment = { + id: "env1", + createdAt: new Date(), + updatedAt: new Date(), + type: "production", +} as unknown as TEnvironment; + +const mockEnvironmentTags: TTag[] = [ + { id: "tag1", name: "Tag1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() }, + { id: "tag2", name: "Tag2", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() }, +]; + +const mockLocale: TUserLocale = "en-US"; + +const defaultProps = { + survey: mockSurvey, + responses: mockResponses, + user: mockUser, + environment: mockEnvironment, + environmentTags: mockEnvironmentTags, + isReadOnly: false, + fetchNextPage: vi.fn(), + hasMore: true, + deleteResponses: vi.fn(), + updateResponse: vi.fn(), + isFetchingFirstPage: false, + locale: mockLocale, +}; + +describe("ResponseDataView", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("renders ResponseTable with correct props", () => { + render(); + expect(screen.getByTestId("response-table")).toBeInTheDocument(); + + const responseTableMock = vi.mocked(ResponseTable); + expect(responseTableMock).toHaveBeenCalledTimes(1); + + const expectedData = [ + { + responseData: { + q1: "Answer 1", + q2: "Choice 1", + row1: "Col 1", // from matrix question + addressLine1: "123 Main St", + addressLine2: "Apt 4B", + city: "Anytown", + state: "CA", + zip: "90210", + country: "USA", + firstName: "John", + lastName: "Doe", + email: "john.doe@example.com", + phone: "555-1234", + company: "Formbricks Inc.", + hidden1: "Hidden Value 1", + }, + createdAt: mockResponses[0].createdAt, + status: "Completed", + responseId: "response1", + tags: mockResponses[0].tags, + variables: { var1: "Response Var Value" }, + verifiedEmail: "test@example.com", + language: "en", + person: null, + contactAttributes: null, + meta: { + url: "http://localhost", + userAgent: { + browser: "test-agent", + }, + }, + }, + { + responseData: { + q1: "Answer 2", + }, + createdAt: mockResponses[1].createdAt, + status: "Not Completed", + responseId: "response2", + tags: [], + variables: {}, + verifiedEmail: "", + language: "de", + person: null, + contactAttributes: null, + meta: { + url: "http://localhost", + userAgent: { + browser: "test-agent-2", + }, + }, + }, + ]; + + expect(responseTableMock.mock.calls[0][0].data).toEqual(expectedData); + expect(responseTableMock.mock.calls[0][0].survey).toEqual(mockSurvey); + expect(responseTableMock.mock.calls[0][0].responses).toEqual(mockResponses); + expect(responseTableMock.mock.calls[0][0].user).toEqual(mockUser); + expect(responseTableMock.mock.calls[0][0].environmentTags).toEqual(mockEnvironmentTags); + expect(responseTableMock.mock.calls[0][0].isReadOnly).toBe(false); + expect(responseTableMock.mock.calls[0][0].environment).toEqual(mockEnvironment); + expect(responseTableMock.mock.calls[0][0].fetchNextPage).toBe(defaultProps.fetchNextPage); + expect(responseTableMock.mock.calls[0][0].hasMore).toBe(true); + expect(responseTableMock.mock.calls[0][0].deleteResponses).toBe(defaultProps.deleteResponses); + expect(responseTableMock.mock.calls[0][0].updateResponse).toBe(defaultProps.updateResponse); + expect(responseTableMock.mock.calls[0][0].isFetchingFirstPage).toBe(false); + expect(responseTableMock.mock.calls[0][0].locale).toBe(mockLocale); + }); + + test("formatAddressData correctly formats data", () => { + const addressData: TResponseDataValue = ["1 Main St", "Apt 1", "CityA", "StateA", "10001", "CountryA"]; + const formatted = formatAddressData(addressData); + expect(formatted).toEqual({ + addressLine1: "1 Main St", + addressLine2: "Apt 1", + city: "CityA", + state: "StateA", + zip: "10001", + country: "CountryA", + }); + }); + + test("formatAddressData handles undefined values", () => { + const addressData: TResponseDataValue = ["1 Main St", "", "CityA", "", "10001", ""]; // Changed undefined to empty string as per function logic + const formatted = formatAddressData(addressData); + expect(formatted).toEqual({ + addressLine1: "1 Main St", + addressLine2: "", + city: "CityA", + state: "", + zip: "10001", + country: "", + }); + }); + + test("formatAddressData returns empty object for non-array input", () => { + const formatted = formatAddressData("not an array"); + expect(formatted).toEqual({}); + }); + + test("formatContactInfoData correctly formats data", () => { + const contactData: TResponseDataValue = ["Jane", "Doe", "jane@mail.com", "123-456", "Org B"]; + const formatted = formatContactInfoData(contactData); + expect(formatted).toEqual({ + firstName: "Jane", + lastName: "Doe", + email: "jane@mail.com", + phone: "123-456", + company: "Org B", + }); + }); + + test("formatContactInfoData handles undefined values", () => { + const contactData: TResponseDataValue = ["Jane", "", "jane@mail.com", "", "Org B"]; // Changed undefined to empty string + const formatted = formatContactInfoData(contactData); + expect(formatted).toEqual({ + firstName: "Jane", + lastName: "", + email: "jane@mail.com", + phone: "", + company: "Org B", + }); + }); + + test("formatContactInfoData returns empty object for non-array input", () => { + const formatted = formatContactInfoData({}); + expect(formatted).toEqual({}); + }); + + test("extractResponseData correctly extracts and formats data", () => { + const response = mockResponses[0]; + const survey = mockSurvey; + const extracted = extractResponseData(response, survey); + expect(extracted).toEqual({ + q1: "Answer 1", + q2: "Choice 1", + row1: "Col 1", // from matrix question + addressLine1: "123 Main St", + addressLine2: "Apt 4B", + city: "Anytown", + state: "CA", + zip: "90210", + country: "USA", + firstName: "John", + lastName: "Doe", + email: "john.doe@example.com", + phone: "555-1234", + company: "Formbricks Inc.", + hidden1: "Hidden Value 1", + }); + }); + + test("extractResponseData handles missing optional data", () => { + const response: TResponse = { + ...mockResponses[1], + data: { q1: "Answer 2" }, + }; + const survey = mockSurvey; + const extracted = extractResponseData(response, survey); + expect(extracted).toEqual({ + q1: "Answer 2", + // address and contactInfo will add empty strings if the keys exist but values are not arrays + // but here, the keys 'address1' and 'contactInfo1' are not in response.data + // hidden1 is also not in response.data + }); + }); + + test("mapResponsesToTableData correctly maps responses", () => { + const tMock = vi.fn((key) => (key === "environments.surveys.responses.completed" ? "Done" : "Pending")); + const tableData = mapResponsesToTableData(mockResponses, mockSurvey, tMock); + expect(tableData.length).toBe(2); + expect(tableData[0].status).toBe("Done"); + expect(tableData[1].status).toBe("Pending"); + expect(tableData[0].responseData.q1).toBe("Answer 1"); + expect(tableData[0].responseData.hidden1).toBe("Hidden Value 1"); + expect(tableData[0].variables.var1).toBe("Response Var Value"); + expect(tableData[1].responseData.q1).toBe("Answer 2"); + expect(tableData[0].verifiedEmail).toBe("test@example.com"); + expect(tableData[1].verifiedEmail).toBe(""); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.tsx index b102bbb87db9..f149673cce92 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView.tsx @@ -24,7 +24,8 @@ interface ResponseDataViewProps { locale: TUserLocale; } -const formatAddressData = (responseValue: TResponseDataValue): Record => { +// Export for testing +export const formatAddressData = (responseValue: TResponseDataValue): Record => { const addressKeys = ["addressLine1", "addressLine2", "city", "state", "zip", "country"]; return Array.isArray(responseValue) ? responseValue.reduce((acc, curr, index) => { @@ -34,7 +35,8 @@ const formatAddressData = (responseValue: TResponseDataValue): Record => { +// Export for testing +export const formatContactInfoData = (responseValue: TResponseDataValue): Record => { const addressKeys = ["firstName", "lastName", "email", "phone", "company"]; return Array.isArray(responseValue) ? responseValue.reduce((acc, curr, index) => { @@ -44,7 +46,8 @@ const formatContactInfoData = (responseValue: TResponseDataValue): Record => { +// Export for testing +export const extractResponseData = (response: TResponse, survey: TSurvey): Record => { let responseData: Record = {}; survey.questions.forEach((question) => { @@ -73,7 +76,8 @@ const extractResponseData = (response: TResponse, survey: TSurvey): Record { return Object.assign(acc, { [curr.id]: response.variables[curr.id] }); @@ -97,6 +100,7 @@ const mapResponsesToTableData = ( language: response.language, person: response.contact, contactAttributes: response.contactAttributes, + meta: response.meta, })); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.test.tsx new file mode 100644 index 000000000000..862d559235f7 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.test.tsx @@ -0,0 +1,335 @@ +import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView"; +import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TTag } from "@formbricks/types/tags"; +import { TUser, TUserLocale } from "@formbricks/types/user"; + +vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({ + useResponseFilter: vi.fn(), +})); + +vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions", () => ({ + getResponseCountAction: vi.fn(), + getResponsesAction: vi.fn(), +})); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView", + () => ({ + ResponseDataView: vi.fn(() =>
ResponseDataView
), + }) +); + +vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter", () => ({ + CustomFilter: vi.fn(() =>
CustomFilter
), +})); + +vi.mock("@/app/lib/surveys/surveys", () => ({ + getFormattedFilters: vi.fn(), +})); + +vi.mock("@/lib/utils/recall", () => ({ + replaceHeadlineRecall: vi.fn((survey) => survey), +})); + +vi.mock("next/navigation", () => ({ + useParams: vi.fn(), + useSearchParams: vi.fn(), + useRouter: vi.fn(), + usePathname: vi.fn(), +})); + +const mockUseResponseFilter = vi.mocked( + (await import("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext")) + .useResponseFilter +); +const mockGetResponsesAction = vi.mocked( + (await import("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions")) + .getResponsesAction +); +const mockGetResponseCountAction = vi.mocked( + (await import("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions")) + .getResponseCountAction +); +const mockUseParams = vi.mocked((await import("next/navigation")).useParams); +const mockUseSearchParams = vi.mocked((await import("next/navigation")).useSearchParams); +const mockGetFormattedFilters = vi.mocked((await import("@/app/lib/surveys/surveys")).getFormattedFilters); + +const mockSurvey = { + id: "survey1", + name: "Test Survey", + questions: [], + thankYouCard: { enabled: true, headline: "Thank You!" }, + hiddenFields: { enabled: true, fieldIds: [] }, + displayOption: "displayOnce", + recontactDays: 0, + autoClose: null, + triggers: [], + type: "web", + status: "inProgress", + languages: [], + styling: null, +} as unknown as TSurvey; + +const mockEnvironment = { id: "env1", name: "Test Environment" } as unknown as TEnvironment; +const mockUser = { id: "user1", name: "Test User" } as TUser; +const mockTags: TTag[] = [{ id: "tag1", name: "Tag 1", environmentId: "env1" } as TTag]; +const mockLocale: TUserLocale = "en-US"; + +const defaultProps = { + environment: mockEnvironment, + survey: mockSurvey, + surveyId: "survey1", + webAppUrl: "http://localhost:3000", + user: mockUser, + environmentTags: mockTags, + responsesPerPage: 10, + locale: mockLocale, + isReadOnly: false, +}; + +const mockResponseFilterState = { + selectedFilter: "all", + dateRange: { from: undefined, to: undefined }, + resetState: vi.fn(), +} as any; + +const mockResponses: TResponse[] = [ + { + id: "response1", + createdAt: new Date(), + updatedAt: new Date(), + surveyId: "survey1", + finished: true, + data: {}, + meta: { userAgent: {} }, + tags: [], + } as unknown as TResponse, + { + id: "response2", + createdAt: new Date(), + updatedAt: new Date(), + surveyId: "survey1", + finished: true, + data: {}, + meta: { userAgent: {} }, + tags: [], + } as unknown as TResponse, +]; + +describe("ResponsePage", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + beforeEach(() => { + mockUseParams.mockReturnValue({ environmentId: "env1", surveyId: "survey1" }); + mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any); + mockUseResponseFilter.mockReturnValue(mockResponseFilterState); + mockGetResponsesAction.mockResolvedValue({ data: mockResponses }); + mockGetResponseCountAction.mockResolvedValue({ data: 20 }); + mockGetFormattedFilters.mockReturnValue({}); + }); + + test("renders correctly with default props", async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId("custom-filter")).toBeInTheDocument(); + expect(screen.getByTestId("response-data-view")).toBeInTheDocument(); + }); + expect(mockGetResponsesAction).toHaveBeenCalled(); + }); + + test("fetches next page of responses", async () => { + const { rerender } = render(); + await waitFor(() => { + expect(mockGetResponsesAction).toHaveBeenCalledTimes(1); + }); + + // Simulate calling fetchNextPage (e.g., via ResponseDataView prop) + // For this test, we'll directly manipulate state to simulate the effect + // In a real scenario, this would be triggered by user interaction with ResponseDataView + const responseDataViewProps = vi.mocked(ResponseDataView).mock.calls[0][0]; + + await act(async () => { + await responseDataViewProps.fetchNextPage(); + }); + + rerender(); // Rerender to reflect state changes + + await waitFor(() => { + expect(mockGetResponsesAction).toHaveBeenCalledTimes(2); // Initial fetch + next page + expect(mockGetResponsesAction).toHaveBeenLastCalledWith( + expect.objectContaining({ + offset: defaultProps.responsesPerPage, // page 2 + }) + ); + }); + }); + + test("deletes responses and updates count", async () => { + render(); + await waitFor(() => { + expect(mockGetResponsesAction).toHaveBeenCalledTimes(1); + }); + + const responseDataViewProps = vi.mocked( + ( + await import( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView" + ) + ).ResponseDataView + ).mock.calls[0][0]; + + act(() => { + responseDataViewProps.deleteResponses(["response1"]); + }); + + // Check if ResponseDataView is re-rendered with updated responses + // This requires checking the props passed to ResponseDataView after deletion + // For simplicity, we assume the state update triggers a re-render and ResponseDataView receives new props + await waitFor(async () => { + const latestCallArgs = vi + .mocked( + ( + await import( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView" + ) + ).ResponseDataView + ) + .mock.calls.pop(); + if (latestCallArgs) { + expect(latestCallArgs[0].responses).toHaveLength(mockResponses.length - 1); + } + }); + }); + + test("updates a response", async () => { + render(); + await waitFor(() => { + expect(mockGetResponsesAction).toHaveBeenCalledTimes(1); + }); + + const responseDataViewProps = vi.mocked( + ( + await import( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView" + ) + ).ResponseDataView + ).mock.calls[0][0]; + + const updatedResponseData = { ...mockResponses[0], finished: false }; + act(() => { + responseDataViewProps.updateResponse("response1", updatedResponseData); + }); + + await waitFor(async () => { + const latestCallArgs = vi + .mocked( + ( + await import( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView" + ) + ).ResponseDataView + ) + .mock.calls.pop(); + if (latestCallArgs) { + const updatedResponseInView = latestCallArgs[0].responses.find((r) => r.id === "response1"); + expect(updatedResponseInView?.finished).toBe(false); + } + }); + }); + + test("resets pagination and responses when filters change", async () => { + const { rerender } = render(); + await waitFor(() => { + expect(mockGetResponsesAction).toHaveBeenCalledTimes(1); + }); + + // Simulate filter change + const newFilterState = { ...mockResponseFilterState, selectedFilter: "completed" }; + mockUseResponseFilter.mockReturnValue(newFilterState); + mockGetFormattedFilters.mockReturnValue({ someNewFilter: "value" } as any); // Simulate new formatted filters + + rerender(); + + await waitFor(() => { + // Should fetch responses again due to filter change + expect(mockGetResponsesAction).toHaveBeenCalledTimes(2); + // Check if it fetches with offset 0 (first page) + expect(mockGetResponsesAction).toHaveBeenLastCalledWith( + expect.objectContaining({ + offset: 0, + filterCriteria: { someNewFilter: "value" }, + }) + ); + }); + }); + + test("calls resetState when referer search param is not present", () => { + mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any); + render(); + expect(mockResponseFilterState.resetState).toHaveBeenCalled(); + }); + + test("does not call resetState when referer search param is present", () => { + mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue("someReferer") } as any); + render(); + expect(mockResponseFilterState.resetState).not.toHaveBeenCalled(); + }); + + test("handles empty responses from API", async () => { + mockGetResponsesAction.mockResolvedValue({ data: [] }); + mockGetResponseCountAction.mockResolvedValue({ data: 0 }); + render(); + await waitFor(async () => { + const latestCallArgs = vi + .mocked( + ( + await import( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView" + ) + ).ResponseDataView + ) + .mock.calls.pop(); + if (latestCallArgs) { + expect(latestCallArgs[0].responses).toEqual([]); + expect(latestCallArgs[0].hasMore).toBe(false); + } + }); + }); + + test("handles API errors gracefully for getResponsesAction", async () => { + mockGetResponsesAction.mockResolvedValue({ data: null as any }); + render(); + await waitFor(async () => { + const latestCallArgs = vi + .mocked( + ( + await import( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView" + ) + ).ResponseDataView + ) + .mock.calls.pop(); + if (latestCallArgs) { + expect(latestCallArgs[0].responses).toEqual([]); // Should default to empty array + expect(latestCallArgs[0].isFetchingFirstPage).toBe(false); + } + }); + }); + + test("handles API errors gracefully for getResponseCountAction", async () => { + mockGetResponseCountAction.mockResolvedValue({ data: null as any }); + render(); + // No direct visual change, but ensure no crash and component renders + await waitFor(() => { + expect(screen.getByTestId("response-data-view")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx index 16d2f3a4b16a..d2bee9dbc8f2 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx @@ -1,21 +1,13 @@ "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 { useParams, useSearchParams } from "next/navigation"; +import { replaceHeadlineRecall } from "@/lib/utils/recall"; +import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall"; import { TEnvironment } from "@formbricks/types/environment"; import { TResponse } from "@formbricks/types/responses"; import { TSurvey } from "@formbricks/types/surveys/types"; @@ -26,7 +18,6 @@ interface ResponsePageProps { environment: TEnvironment; survey: TSurvey; surveyId: string; - webAppUrl: string; user?: TUser; environmentTags: TTag[]; responsesPerPage: number; @@ -38,18 +29,12 @@ export const ResponsePage = ({ environment, survey, surveyId, - webAppUrl, user, environmentTags, responsesPerPage, locale, isReadOnly, }: ResponsePageProps) => { - const params = useParams(); - const sharingKey = params.sharingKey as string; - const isSharingPage = !!sharingKey; - - const [responseCount, setResponseCount] = useState(null); const [responses, setResponses] = useState([]); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); @@ -70,36 +55,23 @@ export const ResponsePage = ({ let newResponses: TResponse[] = []; - if (isSharingPage) { - const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({ - sharingKey: sharingKey, - limit: responsesPerPage, - offset: (newPage - 1) * responsesPerPage, - filterCriteria: filters, - }); - newResponses = getResponsesActionResponse?.data || []; - } else { - const getResponsesActionResponse = await getResponsesAction({ - surveyId, - limit: responsesPerPage, - offset: (newPage - 1) * responsesPerPage, - filterCriteria: filters, - }); - newResponses = getResponsesActionResponse?.data || []; - } + const getResponsesActionResponse = await getResponsesAction({ + surveyId, + limit: responsesPerPage, + offset: (newPage - 1) * responsesPerPage, + filterCriteria: filters, + }); + newResponses = getResponsesActionResponse?.data || []; if (newResponses.length === 0 || newResponses.length < responsesPerPage) { setHasMore(false); } setResponses([...responses, ...newResponses]); setPage(newPage); - }, [filters, isSharingPage, page, responses, responsesPerPage, sharingKey, surveyId]); + }, [filters, page, responses, responsesPerPage, surveyId]); 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,54 +90,20 @@ 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 { setFetchingFirstPage(true); let responses: TResponse[] = []; - if (isSharingPage) { - const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({ - sharingKey, - limit: responsesPerPage, - offset: 0, - filterCriteria: filters, - }); - - responses = getResponsesActionResponse?.data || []; - } else { - const getResponsesActionResponse = await getResponsesAction({ - surveyId, - limit: responsesPerPage, - offset: 0, - filterCriteria: filters, - }); - - responses = getResponsesActionResponse?.data || []; - } + const getResponsesActionResponse = await getResponsesAction({ + surveyId, + limit: responsesPerPage, + offset: 0, + filterCriteria: filters, + }); + + responses = getResponsesActionResponse?.data || []; if (responses.length < responsesPerPage) { setHasMore(false); @@ -176,7 +114,7 @@ export const ResponsePage = ({ } }; fetchInitialResponses(); - }, [surveyId, filters, responsesPerPage, sharingKey, isSharingPage]); + }, [surveyId, filters, responsesPerPage]); useEffect(() => { setPage(1); @@ -188,7 +126,6 @@ export const ResponsePage = ({ <>
- {!isReadOnly && !isSharingPage && }
({ + default: { + error: vi.fn(), + success: vi.fn(), + dismiss: vi.fn(), + }, +})); + +// Mock components +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick, ...props }: any) => ( + + ), +})); + +// Mock DndContext/SortableContext +vi.mock("@dnd-kit/core", () => ({ + DndContext: ({ children }: any) =>
{children}
, + useSensor: vi.fn(), + useSensors: vi.fn(() => "sensors"), + closestCenter: vi.fn(), + MouseSensor: vi.fn(), + TouchSensor: vi.fn(), + KeyboardSensor: vi.fn(), +})); + +vi.mock("@dnd-kit/modifiers", () => ({ + restrictToHorizontalAxis: "restrictToHorizontalAxis", +})); + +vi.mock("@dnd-kit/sortable", () => ({ + SortableContext: ({ children }: any) => <>{children}, + horizontalListSortingStrategy: "horizontalListSortingStrategy", + arrayMove: vi.fn((arr, oldIndex, newIndex) => { + const result = [...arr]; + const [removed] = result.splice(oldIndex, 1); + result.splice(newIndex, 0, removed); + return result; + }), +})); + +// Mock AutoAnimate +vi.mock("@formkit/auto-animate/react", () => ({ + useAutoAnimate: () => [vi.fn()], +})); + +// Mock UI components +vi.mock("@/modules/ui/components/data-table", () => ({ + DataTableHeader: ({ header }: any) => {header.id}, + DataTableSettingsModal: ({ open, setOpen }: any) => + open ? ( +
+ Settings Modal +
+ ) : null, + DataTableToolbar: ({ + table, + deleteRowsAction, + downloadRowsAction, + setIsTableSettingsModalOpen, + setIsExpanded, + isExpanded, + }: any) => ( +
+ + + + + +
+ ), +})); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal", + () => ({ + ResponseCardModal: ({ open, setOpen }: any) => + open ? ( +
+ Response Modal +
+ ) : null, + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell", + () => ({ + ResponseTableCell: ({ cell, row, setSelectedResponseId }: any) => ( + setSelectedResponseId(row.id)}> + Cell Content + + ), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns", + () => ({ + generateResponseTableColumns: vi.fn(() => [ + { id: "select", accessorKey: "select", header: "Select" }, + { id: "createdAt", accessorKey: "createdAt", header: "Created At" }, + { id: "person", accessorKey: "person", header: "Person" }, + { id: "status", accessorKey: "status", header: "Status" }, + ]), + }) +); + +vi.mock("@/modules/ui/components/table", () => ({ + Table: ({ children, ...props }: any) => {children}
, + TableBody: ({ children, ...props }: any) => {children}, + TableCell: ({ children, ...props }: any) => {children}, + TableHeader: ({ children, ...props }: any) => {children}, + TableRow: ({ children, ...props }: any) => {children}, +})); + +vi.mock("@/modules/ui/components/skeleton", () => ({ + Skeleton: ({ children }: any) =>
{children}
, +})); + +// Mock the actions +vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({ + getResponsesDownloadUrlAction: vi.fn(), +})); + +vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({ + deleteResponseAction: vi.fn(), +})); + +// Mock helper functions +vi.mock("@/lib/utils/helper", () => ({ + getFormattedErrorMessage: vi.fn(), +})); + +// Mock localStorage +const mockLocalStorage = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key) => store[key] || null), + setItem: vi.fn((key, value) => { + store[key] = String(value); + }), + clear: vi.fn(() => { + store = {}; + }), + removeItem: vi.fn((key) => { + delete store[key]; + }), + }; +})(); +Object.defineProperty(window, "localStorage", { value: mockLocalStorage }); + +// Mock Tolgee +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +// Define mock data for tests +const mockProps = { + data: [ + { responseId: "resp1", createdAt: new Date().toISOString(), status: "completed", person: "Person 1" }, + { responseId: "resp2", createdAt: new Date().toISOString(), status: "completed", person: "Person 2" }, + ] as any[], + survey: { + id: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + name: "name", + type: "link", + environmentId: "env-1", + createdBy: null, + status: "draft", + } as TSurvey, + responses: [ + { id: "resp1", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() }, + { id: "resp2", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() }, + ] as TResponse[], + environment: { id: "env1" } as TEnvironment, + environmentTags: [] as TTag[], + isReadOnly: false, + fetchNextPage: vi.fn(), + hasMore: false, + deleteResponses: vi.fn(), + updateResponse: vi.fn(), + isFetchingFirstPage: false, + locale: "en" as TUserLocale, +}; + +// Setup a container for React Testing Library before each test +beforeEach(() => { + const container = document.createElement("div"); + container.id = "test-container"; + document.body.appendChild(container); + + // Reset all toast mocks before each test + vi.mocked(toast.error).mockClear(); + vi.mocked(toast.success).mockClear(); + + // Create a mock anchor element for download tests + const mockAnchor = { + href: "", + click: vi.fn(), + style: {}, + }; + + // Update how we mock the document methods to avoid infinite recursion + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tagName) => { + if (tagName === "a") return mockAnchor as any; + return originalCreateElement(tagName); + }); + + vi.spyOn(document.body, "appendChild").mockReturnValue(null as any); + vi.spyOn(document.body, "removeChild").mockReturnValue(null as any); +}); + +// Cleanup after each test +afterEach(() => { + const container = document.getElementById("test-container"); + if (container) { + document.body.removeChild(container); + } + cleanup(); + vi.restoreAllMocks(); // Restore mocks after each test +}); + +describe("ResponseTable", () => { + afterEach(() => { + cleanup(); // Keep cleanup within describe as per instructions + }); + + test("renders the table with data", () => { + const container = document.getElementById("test-container"); + render(, { container: container! }); + expect(screen.getByRole("table")).toBeInTheDocument(); + expect(screen.getByTestId("table-toolbar")).toBeInTheDocument(); + }); + + test("renders no results message when data is empty", () => { + const container = document.getElementById("test-container"); + render(, { container: container! }); + expect(screen.getByText("common.no_results")).toBeInTheDocument(); + }); + + test("renders load more button when hasMore is true", () => { + const container = document.getElementById("test-container"); + render(, { container: container! }); + expect(screen.getByText("common.load_more")).toBeInTheDocument(); + }); + + test("calls fetchNextPage when load more button is clicked", async () => { + const container = document.getElementById("test-container"); + render(, { container: container! }); + const loadMoreButton = screen.getByText("common.load_more"); + await userEvent.click(loadMoreButton); + expect(mockProps.fetchNextPage).toHaveBeenCalledTimes(1); + }); + + test("opens settings modal when toolbar button is clicked", async () => { + const container = document.getElementById("test-container"); + render(, { container: container! }); + const openSettingsButton = screen.getByTestId("open-settings"); + await userEvent.click(openSettingsButton); + expect(screen.getByTestId("settings-modal")).toBeInTheDocument(); + }); + + test("toggles expanded state when toolbar button is clicked", async () => { + const container = document.getElementById("test-container"); + render(, { container: container! }); + const toggleExpandButton = screen.getByTestId("toggle-expand"); + + // Initially might be null, first click should set it to true + await userEvent.click(toggleExpandButton); + expect(mockLocalStorage.setItem).toHaveBeenCalledWith("survey1-rowExpand", expect.any(String)); + }); + + test("calls downloadSelectedRows with csv format when toolbar button is clicked", async () => { + vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({ + data: "https://download.url/file.csv", + }); + + const container = document.getElementById("test-container"); + render(, { container: container! }); + const downloadCsvButton = screen.getByTestId("download-csv"); + await userEvent.click(downloadCsvButton); + + expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({ + surveyId: "survey1", + format: "csv", + filterCriteria: { responseIds: [] }, + }); + + // Check if link was created and clicked + expect(document.createElement).toHaveBeenCalledWith("a"); + const mockLink = document.createElement("a"); + expect(mockLink.href).toBe("https://download.url/file.csv"); + expect(document.body.appendChild).toHaveBeenCalled(); + expect(mockLink.click).toHaveBeenCalled(); + expect(document.body.removeChild).toHaveBeenCalled(); + }); + + test("calls downloadSelectedRows with xlsx format when toolbar button is clicked", async () => { + vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({ + data: "https://download.url/file.xlsx", + }); + + const container = document.getElementById("test-container"); + render(, { container: container! }); + const downloadXlsxButton = screen.getByTestId("download-xlsx"); + await userEvent.click(downloadXlsxButton); + + expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({ + surveyId: "survey1", + format: "xlsx", + filterCriteria: { responseIds: [] }, + }); + + // Check if link was created and clicked + expect(document.createElement).toHaveBeenCalledWith("a"); + const mockLink = document.createElement("a"); + expect(mockLink.href).toBe("https://download.url/file.xlsx"); + expect(document.body.appendChild).toHaveBeenCalled(); + expect(mockLink.click).toHaveBeenCalled(); + expect(document.body.removeChild).toHaveBeenCalled(); + }); + + // Test response modal + test("opens and closes response modal when a cell is clicked", async () => { + const container = document.getElementById("test-container"); + render(, { container: container! }); + const cell = screen.getByTestId("cell-resp1_select-resp1"); + await userEvent.click(cell); + expect(screen.getByTestId("response-modal")).toBeInTheDocument(); + // Close the modal + const closeButton = screen.getByText("Close"); + await userEvent.click(closeButton); + + // Modal should be closed now + expect(screen.queryByTestId("response-modal")).not.toBeInTheDocument(); + }); + + test("shows error toast when download action returns error", async () => { + const errorMsg = "Download failed"; + vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({ + data: undefined, + serverError: errorMsg, + }); + vi.mocked(getFormattedErrorMessage).mockReturnValueOnce(errorMsg); + + // Reset document.createElement spy to fix the last test + vi.mocked(document.createElement).mockClear(); + + const container = document.getElementById("test-container"); + render(, { container: container! }); + const downloadCsvButton = screen.getByTestId("download-csv"); + await userEvent.click(downloadCsvButton); + + await waitFor(() => { + expect(getResponsesDownloadUrlAction).toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses"); + }); + }); + + test("shows default error toast when download action returns no data", async () => { + vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({ + data: undefined, + }); + vi.mocked(getFormattedErrorMessage).mockReturnValueOnce(""); + + const container = document.getElementById("test-container"); + render(, { container: container! }); + const downloadCsvButton = screen.getByTestId("download-csv"); + await userEvent.click(downloadCsvButton); + + await waitFor(() => { + expect(getResponsesDownloadUrlAction).toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses"); + }); + }); + + test("shows error toast when download action throws exception", async () => { + vi.mocked(getResponsesDownloadUrlAction).mockRejectedValueOnce(new Error("Network error")); + + const container = document.getElementById("test-container"); + render(, { container: container! }); + const downloadCsvButton = screen.getByTestId("download-csv"); + await userEvent.click(downloadCsvButton); + + await waitFor(() => { + expect(getResponsesDownloadUrlAction).toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses"); + }); + }); + + test("does not create download link when download action fails", async () => { + // Clear any previous calls to document.createElement + vi.mocked(document.createElement).mockClear(); + + vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({ + data: undefined, + serverError: "Download failed", + }); + + // Create a fresh spy for createElement for this test only + const createElementSpy = vi.spyOn(document, "createElement"); + + const container = document.getElementById("test-container"); + render(, { container: container! }); + const downloadCsvButton = screen.getByTestId("download-csv"); + await userEvent.click(downloadCsvButton); + + await waitFor(() => { + expect(getResponsesDownloadUrlAction).toHaveBeenCalled(); + // Check specifically for "a" element creation, not any element + expect(createElementSpy).not.toHaveBeenCalledWith("a"); + }); + }); + + test("loads saved settings from localStorage on mount", () => { + const columnOrder = ["status", "person", "createdAt", "select"]; + const columnVisibility = { status: false }; + const isExpanded = true; + + mockLocalStorage.getItem.mockImplementation((key) => { + if (key === "survey1-columnOrder") return JSON.stringify(columnOrder); + if (key === "survey1-columnVisibility") return JSON.stringify(columnVisibility); + if (key === "survey1-rowExpand") return JSON.stringify(isExpanded); + return null; + }); + + const container = document.getElementById("test-container"); + render(, { container: container! }); + + // Verify localStorage calls + expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnOrder"); + expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnVisibility"); + expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-rowExpand"); + + // The mock for generateResponseTableColumns returns this order: + // ["select", "createdAt", "person", "status"] + // Only visible columns should be rendered, in this order + const expectedHeaders = ["select", "createdAt", "person"]; + const headers = screen.getAllByTestId(/^header-/); + expect(headers).toHaveLength(expectedHeaders.length); + expectedHeaders.forEach((columnId, index) => { + expect(headers[index]).toHaveAttribute("data-testid", `header-${columnId}`); + }); + + // Verify column visibility is applied + const statusHeader = screen.queryByTestId("header-status"); + expect(statusHeader).not.toBeInTheDocument(); + + // Verify row expansion is applied + const toggleExpandButton = screen.getByTestId("toggle-expand"); + expect(toggleExpandButton).toHaveAttribute("aria-pressed", "true"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx index c2bc2963cff2..d90196639594 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx @@ -3,6 +3,7 @@ import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal"; import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell"; import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns"; +import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions"; import { Button } from "@/modules/ui/components/button"; import { @@ -25,15 +26,16 @@ import { import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"; import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable"; import { useAutoAnimate } from "@formkit/auto-animate/react"; +import * as Sentry from "@sentry/nextjs"; import { VisibilityState, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { useTranslate } from "@tolgee/react"; import { useEffect, useMemo, useState } from "react"; +import toast from "react-hot-toast"; import { TEnvironment } from "@formbricks/types/environment"; import { TResponse, TResponseTableData } from "@formbricks/types/responses"; import { TSurvey } from "@formbricks/types/surveys/types"; import { TTag } from "@formbricks/types/tags"; -import { TUser } from "@formbricks/types/user"; -import { TUserLocale } from "@formbricks/types/user"; +import { TUser, TUserLocale } from "@formbricks/types/user"; interface ResponseTableProps { data: TResponseTableData[]; @@ -180,6 +182,32 @@ export const ResponseTable = ({ await deleteResponseAction({ responseId }); }; + // Handle downloading selected responses + const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => { + try { + const downloadResponse = await getResponsesDownloadUrlAction({ + surveyId: survey.id, + format: format, + filterCriteria: { responseIds }, + }); + + if (downloadResponse?.data) { + const link = document.createElement("a"); + link.href = downloadResponse.data; + link.download = ""; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } else { + toast.error(t("environments.surveys.responses.error_downloading_responses")); + } + } catch (error) { + Sentry.captureException(error); + toast.error(t("environments.surveys.responses.error_downloading_responses")); + } + }; + return (
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.test.tsx new file mode 100644 index 000000000000..77ce5f41ca81 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.test.tsx @@ -0,0 +1,165 @@ +import type { Cell, Row } from "@tanstack/react-table"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { TResponse, TResponseTableData } from "@formbricks/types/responses"; +import { ResponseTableCell } from "./ResponseTableCell"; + +const makeCell = ( + id: string, + size = 100, + first = false, + last = false, + content = "CellContent" +): Cell => + ({ + column: { + id, + getSize: () => size, + getIsFirstColumn: () => first, + getIsLastColumn: () => last, + getStart: () => 0, + columnDef: { cell: () => content }, + }, + id, + getContext: () => ({}), + }) as unknown as Cell; + +const makeRow = (id: string, selected = false): Row => + ({ id, getIsSelected: () => selected }) as unknown as Row; + +describe("ResponseTableCell", () => { + afterEach(() => { + cleanup(); + }); + + test("renders cell content", () => { + const cell = makeCell("col1"); + const row = makeRow("r1"); + render( + + ); + expect(screen.getByText("CellContent")).toBeDefined(); + }); + + test("calls setSelectedResponseId on cell click when not select column", async () => { + const cell = makeCell("col1"); + const row = makeRow("r1"); + const setSel = vi.fn(); + render( + + ); + await userEvent.click(screen.getByText("CellContent")); + expect(setSel).toHaveBeenCalledWith("r1"); + }); + + test("does not call setSelectedResponseId on select column click", async () => { + const cell = makeCell("select"); + const row = makeRow("r1"); + const setSel = vi.fn(); + render( + + ); + await userEvent.click(screen.getByText("CellContent")); + expect(setSel).not.toHaveBeenCalled(); + }); + + test("renders maximize icon for createdAt column and handles click", async () => { + const cell = makeCell("createdAt", 120, false, false); + const row = makeRow("r2"); + const setSel = vi.fn(); + render( + + ); + const btn = screen.getByRole("button", { name: /expand response/i }); + expect(btn).toBeDefined(); + await userEvent.click(btn); + expect(setSel).toHaveBeenCalledWith("r2"); + }); + + test("does not apply selected style when row.getIsSelected() is false", () => { + const cell = makeCell("col1"); + const row = makeRow("r1", false); + const { container } = render( + + ); + expect(container.firstChild).not.toHaveClass("bg-slate-100"); + }); + + test("applies selected style when row.getIsSelected() is true", () => { + const cell = makeCell("col1"); + const row = makeRow("r1", true); + const { container } = render( + + ); + expect(container.firstChild).toHaveClass("bg-slate-100"); + }); + + test("renders collapsed height class when isExpanded is false", () => { + const cell = makeCell("col1"); + const row = makeRow("r1"); + const { container } = render( + + ); + const inner = container.querySelector("div > div"); + expect(inner).toHaveClass("h-10"); + }); + + test("renders expanded height class when isExpanded is true", () => { + const cell = makeCell("col1"); + const row = makeRow("r1"); + const { container } = render( + + ); + const inner = container.querySelector("div > div"); + expect(inner).toHaveClass("h-full"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx index bc7a15c7840b..75e5e90e9649 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx @@ -1,8 +1,8 @@ +import { cn } from "@/lib/cn"; import { getCommonPinningStyles } from "@/modules/ui/components/data-table/lib/utils"; import { TableCell } from "@/modules/ui/components/table"; import { Cell, Row, flexRender } from "@tanstack/react-table"; import { Maximize2Icon } from "lucide-react"; -import { cn } from "@formbricks/lib/cn"; import { TResponse, TResponseTableData } from "@formbricks/types/responses"; interface ResponseTableCellProps { @@ -35,11 +35,13 @@ export const ResponseTableCell = ({ // Conditional rendering of maximize icon const renderMaximizeIcon = cell.column.id === "createdAt" && ( -
-
+ ); return ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.test.tsx new file mode 100644 index 000000000000..3ee61afc4f89 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.test.tsx @@ -0,0 +1,780 @@ +import { extractChoiceIdsFromResponse } from "@/lib/response/utils"; +import { processResponseData } from "@/lib/responses"; +import { getContactIdentifier } from "@/lib/utils/contact"; +import { getFormattedDateTimeString } from "@/lib/utils/datetime"; +import { getSelectionColumn } from "@/modules/ui/components/data-table"; +import { cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TResponseTableData } from "@formbricks/types/responses"; +import { + TSurvey, + TSurveyQuestion, + TSurveyQuestionTypeEnum, + TSurveyVariable, +} from "@formbricks/types/surveys/types"; +import { TTag } from "@formbricks/types/tags"; +import { generateResponseTableColumns } from "./ResponseTableColumns"; + +// Mock TFnType +const t = vi.fn((key: string, params?: any) => { + if (params) { + let message = key; + for (const p in params) { + message = message.replace(`{{${p}}}`, params[p]); + } + return message; + } + return key; +}); + +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: vi.fn((localizedString, locale) => localizedString[locale] || localizedString.default), +})); + +vi.mock("@/lib/responses", () => ({ + processResponseData: vi.fn((data) => (Array.isArray(data) ? data.join(", ") : String(data))), +})); + +vi.mock("@/lib/utils/contact", () => ({ + getContactIdentifier: vi.fn((person) => person?.attributes?.email || person?.id || "Anonymous"), +})); + +vi.mock("@/lib/utils/datetime", () => ({ + getFormattedDateTimeString: vi.fn((date) => new Date(date).toISOString()), +})); + +vi.mock("@/lib/utils/recall", () => ({ + recallToHeadline: vi.fn((headline) => headline), +})); + +vi.mock("@/modules/analysis/components/SingleResponseCard/components/RenderResponse", () => ({ + RenderResponse: vi.fn(({ responseData, isExpanded }) => ( +
+ RenderResponse: {JSON.stringify(responseData)} (Expanded: {String(isExpanded)}) +
+ )), +})); + +vi.mock("@/modules/survey/lib/questions", () => ({ + getQuestionIconMap: vi.fn(() => ({ + [TSurveyQuestionTypeEnum.OpenText]: OT, + [TSurveyQuestionTypeEnum.MultipleChoiceSingle]: MCS, + [TSurveyQuestionTypeEnum.MultipleChoiceMulti]: MCM, + [TSurveyQuestionTypeEnum.Matrix]: MX, + [TSurveyQuestionTypeEnum.Address]: AD, + [TSurveyQuestionTypeEnum.ContactInfo]: CI, + })), + VARIABLES_ICON_MAP: { + text: VarT, + number: VarN, + }, +})); + +vi.mock("@/modules/ui/components/data-table", () => ({ + getSelectionColumn: vi.fn(() => ({ + id: "select", + header: "Select", + cell: "SelectCell", + })), +})); + +vi.mock("@/modules/ui/components/response-badges", () => ({ + ResponseBadges: vi.fn(({ items, isExpanded }) => ( +
+ Badges: {items.join(", ")} (Expanded: {String(isExpanded)}) +
+ )), +})); + +vi.mock("@/modules/ui/components/tooltip", () => ({ + Tooltip: ({ children }) =>
{children}
, + TooltipContent: ({ children }) =>
{children}
, + TooltipProvider: ({ children }) =>
{children}
, + TooltipTrigger: ({ children }) =>
{children}
, +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }) => {children}, +})); + +vi.mock("lucide-react", () => ({ + CircleHelpIcon: () => Help, + EyeOffIcon: () => EyeOff, + MailIcon: () => Mail, + TagIcon: () => Tag, + MousePointerClickIcon: () => MousePointerClick, + AirplayIcon: () => Airplay, + ArrowUpFromDotIcon: () => ArrowUpFromDot, + FlagIcon: () => Flag, + GlobeIcon: () => Globe, + SmartphoneIcon: () => Smartphone, +})); + +// Mock new dependencies +vi.mock("@/lib/response/utils", () => ({ + extractChoiceIdsFromResponse: vi.fn((responseValue) => { + // Mock implementation that returns choice IDs based on response value + if (Array.isArray(responseValue)) { + return responseValue.map((_, index) => `choice-${index + 1}`); + } else if (typeof responseValue === "string") { + return [`choice-single`]; + } + return []; + }), +})); + +vi.mock("@/modules/ui/components/id-badge", () => ({ + IdBadge: vi.fn(({ id }) =>
{id}
), +})); + +vi.mock("@/modules/ui/lib/utils", () => ({ + cn: vi.fn((...classes) => classes.filter(Boolean).join(" ")), +})); + +const mockSurvey = { + id: "survey1", + name: "Test Survey", + type: "app", + status: "inProgress", + questions: [ + { + id: "q1open", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Open Text Question" }, + required: true, + } as unknown as TSurveyQuestion, + { + id: "q2matrix", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Matrix Question" }, + rows: [{ default: "Row1" }, { default: "Row2" }], + columns: [{ default: "Col1" }, { default: "Col2" }], + required: false, + } as unknown as TSurveyQuestion, + { + id: "q3address", + type: TSurveyQuestionTypeEnum.Address, + headline: { default: "Address Question" }, + required: false, + } as unknown as TSurveyQuestion, + { + id: "q4contact", + type: TSurveyQuestionTypeEnum.ContactInfo, + headline: { default: "Contact Info Question" }, + required: false, + } as unknown as TSurveyQuestion, + { + id: "q5single", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Single Choice Question" }, + required: false, + choices: [ + { id: "choice-1", label: { default: "Option 1" } }, + { id: "choice-2", label: { default: "Option 2" } }, + { id: "choice-3", label: { default: "Option 3" } }, + ], + } as unknown as TSurveyQuestion, + { + id: "q6multi", + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + headline: { default: "Multi Choice Question" }, + required: false, + choices: [ + { id: "choice-a", label: { default: "Choice A" } }, + { id: "choice-b", label: { default: "Choice B" } }, + { id: "choice-c", label: { default: "Choice C" } }, + ], + } as unknown as TSurveyQuestion, + ], + variables: [ + { id: "var1", name: "User Segment", type: "text" } as TSurveyVariable, + { id: "var2", name: "Total Spend", type: "number" } as TSurveyVariable, + ], + hiddenFields: { enabled: true, fieldIds: ["hf1", "hf2"] }, + endings: [], + triggers: [], + recontactDays: null, + displayOption: "displayOnce", + autoClose: null, + delay: 0, + autoComplete: null, + isVerifyEmailEnabled: false, + styling: null, + languages: [], + segment: null, + projectOverwrites: null, + singleUse: null, + pin: null, + surveyClosedMessage: null, + welcomeCard: { + enabled: false, + } as TSurvey["welcomeCard"], +} as unknown as TSurvey; + +const mockResponseData = { + contactAttributes: { country: "USA" }, + responseData: { + q1open: "Open text answer", + Row1: "Col1", // For matrix q2matrix + Row2: "Col2", + addressLine1: "123 Main St", + city: "Anytown", + firstName: "John", + email: "john.doe@example.com", + hf1: "Hidden Field 1 Value", + q5single: "Option 1", // Single choice response + q6multi: ["Choice A", "Choice C"], // Multi choice response + }, + variables: { + var1: "Segment A", + var2: 100, + }, + status: "completed", + tags: [{ id: "tag1", name: "Important" } as unknown as TTag], + language: "default", +} as unknown as TResponseTableData; + +describe("generateResponseTableColumns", () => { + beforeEach(() => { + vi.clearAllMocks(); + t.mockImplementation((key: string) => key); // Reset t mock for each test + }); + + afterEach(() => { + cleanup(); + }); + + test("should include selection column when not read-only", () => { + const columns = generateResponseTableColumns(mockSurvey, false, false, t as any); + expect(columns[0].id).toBe("select"); + expect(vi.mocked(getSelectionColumn)).toHaveBeenCalledTimes(1); + }); + + test("should not include selection column when read-only", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + expect(columns[0].id).not.toBe("select"); + expect(vi.mocked(getSelectionColumn)).not.toHaveBeenCalled(); + }); + + test("should include Verified Email column when survey.isVerifyEmailEnabled is true", () => { + const surveyWithVerifiedEmail = { ...mockSurvey, isVerifyEmailEnabled: true }; + const columns = generateResponseTableColumns(surveyWithVerifiedEmail, false, true, t as any); + expect(columns.some((col) => (col as any).accessorKey === "verifiedEmail")).toBe(true); + }); + + test("should not include Verified Email column when survey.isVerifyEmailEnabled is false", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + expect(columns.some((col) => (col as any).accessorKey === "verifiedEmail")).toBe(false); + }); + + test("should generate columns for variables", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const var1Col = columns.find((col) => (col as any).accessorKey === "var1"); + expect(var1Col).toBeDefined(); + const var1Cell = (var1Col?.cell as any)?.({ row: { original: mockResponseData } } as any); + expect(var1Cell.props.children).toBe("Segment A"); + + const var2Col = columns.find((col) => (col as any).accessorKey === "var2"); + expect(var2Col).toBeDefined(); + const var2Cell = (var2Col?.cell as any)?.({ row: { original: mockResponseData } } as any); + expect(var2Cell.props.children).toBe(100); + }); + + test("should generate columns for hidden fields if fieldIds exist", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1"); + expect(hf1Col).toBeDefined(); + const hf1Cell = (hf1Col?.cell as any)?.({ row: { original: mockResponseData } } as any); + expect(hf1Cell.props.children).toBe("Hidden Field 1 Value"); + }); + + test("should not generate columns for hidden fields if fieldIds is undefined", () => { + const surveyWithoutHiddenFieldIds = { ...mockSurvey, hiddenFields: { enabled: true } }; + const columns = generateResponseTableColumns(surveyWithoutHiddenFieldIds, false, true, t as any); + const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1"); + expect(hf1Col).toBeUndefined(); + }); +}); + +describe("ResponseTableColumns", () => { + afterEach(() => { + cleanup(); + }); + + test("includes verifiedEmailColumn when isVerifyEmailEnabled is true", () => { + // Arrange + const mockSurvey = { + questions: [], + variables: [], + hiddenFields: { fieldIds: [] }, + isVerifyEmailEnabled: true, + } as unknown as TSurvey; + + const mockT = vi.fn((key) => key); + const isExpanded = false; + const isReadOnly = false; + + // Act + const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT); + + // Assert + const verifiedEmailColumn: any = columns.find((col: any) => col.accessorKey === "verifiedEmail"); + expect(verifiedEmailColumn).toBeDefined(); + expect(verifiedEmailColumn?.accessorKey).toBe("verifiedEmail"); + + // Call the header function to trigger the t function call with "common.verified_email" + if (verifiedEmailColumn && typeof verifiedEmailColumn.header === "function") { + verifiedEmailColumn.header(); + expect(mockT).toHaveBeenCalledWith("common.verified_email"); + } + }); + + test("excludes verifiedEmailColumn when isVerifyEmailEnabled is false", () => { + // Arrange + const mockSurvey = { + questions: [], + variables: [], + hiddenFields: { fieldIds: [] }, + isVerifyEmailEnabled: false, + } as unknown as TSurvey; + + const mockT = vi.fn((key) => key); + const isExpanded = false; + const isReadOnly = false; + + // Act + const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT); + + // Assert + const verifiedEmailColumn = columns.find((col: any) => col.accessorKey === "verifiedEmail"); + expect(verifiedEmailColumn).toBeUndefined(); + }); +}); + +describe("ResponseTableColumns - Column Implementations", () => { + afterEach(() => { + cleanup(); + }); + + test("dateColumn renders with formatted date", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const dateColumn: any = columns.find((col) => (col as any).accessorKey === "createdAt"); + expect(dateColumn).toBeDefined(); + + // Call the header function to test it returns the expected value + expect(dateColumn?.header?.()).toBe("common.date"); + + // Mock a response with a date to test the cell function + const mockRow = { + original: { createdAt: "2023-01-01T12:00:00Z" }, + } as any; + + // Call the cell function and check the formatted date + dateColumn?.cell?.({ row: mockRow } as any); + expect(vi.mocked(getFormattedDateTimeString)).toHaveBeenCalledWith(new Date("2023-01-01T12:00:00Z")); + }); + + test("personColumn renders anonymous when person is null", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId"); + expect(personColumn).toBeDefined(); + + // Test header content + const headerResult = personColumn?.header?.(); + expect(headerResult).toBeDefined(); + + // Mock a response with no person + const mockRow = { + original: { person: null }, + } as any; + + // Mock the t function for this specific call + t.mockReturnValueOnce("Anonymous User"); + + // Call the cell function and check it returns "Anonymous" + const cellResult = personColumn?.cell?.({ row: mockRow } as any); + expect(t).toHaveBeenCalledWith("common.anonymous"); + expect(cellResult?.props?.children).toBe("Anonymous User"); + }); + + test("personColumn renders person identifier when person exists", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId"); + expect(personColumn).toBeDefined(); + + // Mock a response with a person + const mockRow = { + original: { + person: { id: "123", attributes: { email: "test@example.com" } }, + contactAttributes: { name: "John Doe" }, + }, + } as any; + + // Call the cell function + personColumn?.cell?.({ row: mockRow } as any); + expect(vi.mocked(getContactIdentifier)).toHaveBeenCalledWith( + mockRow.original.person, + mockRow.original.contactAttributes + ); + }); + + test("tagsColumn returns undefined when tags is not an array", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const tagsColumn: any = columns.find((col) => (col as any).accessorKey === "tags"); + expect(tagsColumn).toBeDefined(); + + // Mock a response with no tags + const mockRow = { + original: { tags: null }, + } as any; + + // Call the cell function + const cellResult = tagsColumn?.cell?.({ row: mockRow } as any); + expect(cellResult).toBeUndefined(); + }); + + test("variableColumns render variable values correctly", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + + // Find the variable column for var1 + const var1Column: any = columns.find((col) => (col as any).accessorKey === "var1"); + expect(var1Column).toBeDefined(); + + // Test the header + const headerResult = var1Column?.header?.(); + expect(headerResult).toBeDefined(); + + // Mock a response with a string variable + const mockRow = { + original: { variables: { var1: "Test Value" } }, + } as any; + + // Call the cell function + const cellResult = var1Column?.cell?.({ row: mockRow } as any); + expect(cellResult?.props.children).toBe("Test Value"); + + // Test with a number variable + const var2Column: any = columns.find((col) => (col as any).accessorKey === "var2"); + expect(var2Column).toBeDefined(); + + const mockRowNumber = { + original: { variables: { var2: 42 } }, + } as any; + + const cellResultNumber = var2Column?.cell?.({ row: mockRowNumber } as any); + expect(cellResultNumber?.props.children).toBe(42); + }); + + test("hiddenFieldColumns render when fieldIds exist", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + + // Find the hidden field column + const hfColumn: any = columns.find((col) => (col as any).accessorKey === "hf1"); + expect(hfColumn).toBeDefined(); + + // Test the header + const headerResult = hfColumn?.header?.(); + expect(headerResult).toBeDefined(); + + // Mock a response with a hidden field value + const mockRow = { + original: { responseData: { hf1: "Hidden Value" } }, + } as any; + + // Call the cell function + const cellResult = hfColumn?.cell?.({ row: mockRow } as any); + expect(cellResult?.props.children).toBe("Hidden Value"); + }); + + test("hiddenFieldColumns are empty when fieldIds don't exist", () => { + // Create a survey with no hidden field IDs + const surveyWithNoHiddenFields = { + ...mockSurvey, + hiddenFields: { enabled: true }, // no fieldIds + }; + + const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any); + + // Check that no hidden field columns were created + const hfColumn = columns.find((col) => (col as any).accessorKey === "hf1"); + expect(hfColumn).toBeUndefined(); + }); +}); + +describe("ResponseTableColumns - Multiple Choice Questions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + test("generates two columns for multipleChoiceSingle questions", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + + // Should have main response column + const mainColumn = columns.find((col) => (col as any).accessorKey === "q5single"); + expect(mainColumn).toBeDefined(); + + // Should have option IDs column + const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds"); + expect(optionIdsColumn).toBeDefined(); + }); + + test("generates two columns for multipleChoiceMulti questions", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + + // Should have main response column + const mainColumn = columns.find((col) => (col as any).accessorKey === "q6multi"); + expect(mainColumn).toBeDefined(); + + // Should have option IDs column + const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q6multioptionIds"); + expect(optionIdsColumn).toBeDefined(); + }); + + test("multipleChoiceSingle main column renders RenderResponse component", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single"); + + const mockRow = { + original: { + responseData: { q5single: "Option 1" }, + language: "default", + }, + }; + + const cellResult = mainColumn?.cell?.({ row: mockRow } as any); + // Check that RenderResponse component is returned + expect(cellResult).toBeDefined(); + }); + + test("multipleChoiceMulti main column renders RenderResponse component", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi"); + + const mockRow = { + original: { + responseData: { q6multi: ["Choice A", "Choice C"] }, + language: "default", + }, + }; + + const cellResult = mainColumn?.cell?.({ row: mockRow } as any); + // Check that RenderResponse component is returned + expect(cellResult).toBeDefined(); + }); +}); + +describe("ResponseTableColumns - Choice ID Columns", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + test("option IDs column calls extractChoiceIdsFromResponse for string response", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds"); + + const mockRow = { + original: { + responseData: { q5single: "Option 1" }, + language: "default", + }, + }; + + optionIdsColumn?.cell?.({ row: mockRow } as any); + + expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith( + "Option 1", + expect.objectContaining({ id: "q5single", type: "multipleChoiceSingle" }), + "default" + ); + }); + + test("option IDs column calls extractChoiceIdsFromResponse for array response", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds"); + + const mockRow = { + original: { + responseData: { q6multi: ["Choice A", "Choice C"] }, + language: "default", + }, + }; + + optionIdsColumn?.cell?.({ row: mockRow } as any); + + expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith( + ["Choice A", "Choice C"], + expect.objectContaining({ id: "q6multi", type: "multipleChoiceMulti" }), + "default" + ); + }); + + test("option IDs column renders IdBadge components for choice IDs", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds"); + + const mockRow = { + original: { + responseData: { q6multi: ["Choice A", "Choice C"] }, + language: "default", + }, + }; + + // Mock extractChoiceIdsFromResponse to return specific choice IDs + vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce(["choice-1", "choice-3"]); + + const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any); + + // Should render something for choice IDs + expect(cellResult).toBeDefined(); + // Verify that extractChoiceIdsFromResponse was called + expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalled(); + }); + + test("option IDs column returns null for non-string/array response values", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds"); + + const mockRow = { + original: { + responseData: { q5single: 123 }, // Invalid type + language: "default", + }, + }; + + const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any); + + expect(cellResult).toBeNull(); + expect(vi.mocked(extractChoiceIdsFromResponse)).not.toHaveBeenCalled(); + }); + + test("option IDs column returns null when no choice IDs found", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds"); + + const mockRow = { + original: { + responseData: { q5single: "Non-existent option" }, + language: "default", + }, + }; + + // Mock extractChoiceIdsFromResponse to return empty array + vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce([]); + + const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any); + + expect(cellResult).toBeNull(); + }); + + test("option IDs column handles missing language gracefully", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds"); + + const mockRow = { + original: { + responseData: { q5single: "Option 1" }, + language: null, // No language + }, + }; + + optionIdsColumn?.cell?.({ row: mockRow } as any); + + expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith( + "Option 1", + expect.objectContaining({ id: "q5single" }), + undefined + ); + }); +}); + +describe("ResponseTableColumns - Helper Functions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + test("question headers are properly created for multiple choice questions", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single"); + const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds"); + + // Test main column header + const mainHeader = mainColumn?.header?.(); + expect(mainHeader).toBeDefined(); + expect(mainHeader?.props?.className).toContain("flex items-center justify-between"); + + // Test option IDs column header + const optionHeader = optionIdsColumn?.header?.(); + expect(optionHeader).toBeDefined(); + expect(optionHeader?.props?.className).toContain("flex items-center justify-between"); + }); + + test("question headers include proper icons for multiple choice questions", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + const singleChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q5single"); + const multiChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi"); + + // Headers should be functions that return JSX + expect(typeof singleChoiceColumn?.header).toBe("function"); + expect(typeof multiChoiceColumn?.header).toBe("function"); + + // Call headers to ensure they don't throw + expect(() => singleChoiceColumn?.header?.()).not.toThrow(); + expect(() => multiChoiceColumn?.header?.()).not.toThrow(); + }); +}); + +describe("ResponseTableColumns - Integration Tests", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + test("multiple choice questions work end-to-end with real data", () => { + const columns = generateResponseTableColumns(mockSurvey, false, true, t as any); + + // Find all multiple choice related columns + const singleMainCol = columns.find((col) => (col as any).accessorKey === "q5single"); + const singleIdsCol = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds"); + const multiMainCol = columns.find((col) => (col as any).accessorKey === "q6multi"); + const multiIdsCol = columns.find((col) => (col as any).accessorKey === "q6multioptionIds"); + + expect(singleMainCol).toBeDefined(); + expect(singleIdsCol).toBeDefined(); + expect(multiMainCol).toBeDefined(); + expect(multiIdsCol).toBeDefined(); + + // Test with actual mock response data + const mockRow = { original: mockResponseData }; + + // Test single choice main column + const singleMainResult = (singleMainCol?.cell as any)?.({ row: mockRow }); + expect(singleMainResult).toBeDefined(); + + // Test multi choice main column + const multiMainResult = (multiMainCol?.cell as any)?.({ row: mockRow }); + expect(multiMainResult).toBeDefined(); + + // Test that choice ID columns exist and can be called + const singleIdsResult = (singleIdsCol?.cell as any)?.({ row: mockRow }); + const multiIdsResult = (multiIdsCol?.cell as any)?.({ row: mockRow }); + + // Should not error when calling the cell functions + expect(() => singleIdsResult).not.toThrow(); + expect(() => multiIdsResult).not.toThrow(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx index c1eb5af132b4..016f4aa46aaf 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx @@ -1,58 +1,31 @@ "use client"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { extractChoiceIdsFromResponse } from "@/lib/response/utils"; +import { getContactIdentifier } from "@/lib/utils/contact"; +import { getFormattedDateTimeString } from "@/lib/utils/datetime"; +import { recallToHeadline } from "@/lib/utils/recall"; import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse"; import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions"; import { getSelectionColumn } from "@/modules/ui/components/data-table"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import { ResponseBadges } from "@/modules/ui/components/response-badges"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; +import { cn } from "@/modules/ui/lib/utils"; import { ColumnDef } from "@tanstack/react-table"; import { TFnType } from "@tolgee/react"; import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react"; import Link from "next/link"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { processResponseData } from "@formbricks/lib/responses"; -import { getContactIdentifier } from "@formbricks/lib/utils/contact"; -import { getFormattedDateTimeString } from "@formbricks/lib/utils/datetime"; -import { recallToHeadline } from "@formbricks/lib/utils/recall"; import { TResponseTableData } from "@formbricks/types/responses"; import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types"; - -const getAddressFieldLabel = (field: string, t: TFnType) => { - switch (field) { - case "addressLine1": - return t("environments.surveys.responses.address_line_1"); - case "addressLine2": - return t("environments.surveys.responses.address_line_2"); - case "city": - return t("environments.surveys.responses.city"); - case "state": - return t("environments.surveys.responses.state_region"); - case "zip": - return t("environments.surveys.responses.zip_post_code"); - case "country": - return t("environments.surveys.responses.country"); - - default: - break; - } -}; - -const getContactInfoFieldLabel = (field: string, t: TFnType) => { - switch (field) { - case "firstName": - return t("environments.surveys.responses.first_name"); - case "lastName": - return t("environments.surveys.responses.last_name"); - case "email": - return t("environments.surveys.responses.email"); - case "phone": - return t("environments.surveys.responses.phone"); - case "company": - return t("environments.surveys.responses.company"); - default: - break; - } -}; +import { + COLUMNS_ICON_MAP, + METADATA_FIELDS, + getAddressFieldLabel, + getContactInfoFieldLabel, + getMetadataFieldLabel, + getMetadataValue, +} from "../lib/utils"; const getQuestionColumnsData = ( question: TSurveyQuestion, @@ -61,6 +34,42 @@ const getQuestionColumnsData = ( t: TFnType ): ColumnDef[] => { const QUESTIONS_ICON_MAP = getQuestionIconMap(t); + + // Helper function to create consistent column headers + const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => { + const title = suffix ? `${headline} - ${suffix}` : headline; + const QuestionHeader = () => ( +
+
+ {QUESTIONS_ICON_MAP[questionType]} + {title} +
+
+ ); + QuestionHeader.displayName = "QuestionHeader"; + return QuestionHeader; + }; + + // Helper function to get localized question headline + const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => { + return getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default"); + }; + + // Helper function to render choice ID badges + const renderChoiceIdBadges = (choiceIds: string[], isExpanded: boolean) => { + if (choiceIds.length === 0) return null; + + const containerClasses = cn("flex gap-x-1 w-full", isExpanded && "flex-wrap gap-y-1"); + + return ( +
+ {choiceIds.map((choiceId, index) => ( + + ))} +
+ ); + }; + switch (question.type) { case "matrix": return question.rows.map((matrixRow) => { @@ -71,7 +80,11 @@ const getQuestionColumnsData = (
{QUESTIONS_ICON_MAP["matrix"]} - {getLocalizedValue(matrixRow, "default")} + + {getLocalizedValue(question.headline, "default") + + " - " + + getLocalizedValue(matrixRow, "default")} +
); @@ -133,6 +146,50 @@ const getQuestionColumnsData = ( }; }); + case "multipleChoiceMulti": + case "multipleChoiceSingle": + case "ranking": + case "pictureSelection": { + const questionHeadline = getQuestionHeadline(question, survey); + return [ + { + accessorKey: question.id, + header: createQuestionHeader(question.type, questionHeadline), + cell: ({ row }) => { + const responseValue = row.original.responseData[question.id]; + const language = row.original.language; + return ( + + ); + }, + }, + { + accessorKey: question.id + "optionIds", + header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")), + cell: ({ row }) => { + const responseValue = row.original.responseData[question.id]; + // Type guard to ensure responseValue is the correct type + if (typeof responseValue === "string" || Array.isArray(responseValue)) { + const choiceIds = extractChoiceIdsFromResponse( + responseValue, + question, + row.original.language || undefined + ); + return renderChoiceIdBadges(choiceIds, isExpanded); + } + return null; + }, + }, + ]; + } + default: return [ { @@ -160,6 +217,7 @@ const getQuestionColumnsData = ( responseData={responseValue} language={language} isExpanded={isExpanded} + showId={false} /> ); }, @@ -168,6 +226,33 @@ const getQuestionColumnsData = ( } }; +const getMetadataColumnsData = (t: TFnType): ColumnDef[] => { + const metadataColumns: ColumnDef[] = []; + + METADATA_FIELDS.forEach((label) => { + const IconComponent = COLUMNS_ICON_MAP[label]; + + metadataColumns.push({ + accessorKey: label, + header: () => ( +
+ {IconComponent && } + {getMetadataFieldLabel(label, t)} +
+ ), + cell: ({ row }) => { + const value = getMetadataValue(row.original.meta, label); + if (value) { + return
{value}
; + } + return null; + }, + }); + }); + + return metadataColumns; +}; + export const generateResponseTableColumns = ( survey: TSurvey, isExpanded: boolean, @@ -226,7 +311,7 @@ export const generateResponseTableColumns = ( header: t("common.status"), cell: ({ row }) => { const status = row.original.status; - return ; + return ; }, }; @@ -239,27 +324,16 @@ export const generateResponseTableColumns = ( const tagsArray = tags.map((tag) => tag.name); return ( ({ value: tag }))} isExpanded={isExpanded} icon={} + showId={false} /> ); } }, }; - const notesColumn: ColumnDef = { - accessorKey: "notes", - header: t("common.notes"), - cell: ({ row }) => { - const notes = row.original.notes; - if (Array.isArray(notes)) { - const notesArray = notes.map((note) => note.text); - return processResponseData(notesArray); - } - }, - }; - const variableColumns: ColumnDef[] = survey.variables.map((variable) => { return { accessorKey: variable.id, @@ -300,6 +374,8 @@ export const generateResponseTableColumns = ( }) : []; + const metadataColumns = getMetadataColumnsData(t); + const verifiedEmailColumn: ColumnDef = { accessorKey: "verifiedEmail", header: () => ( @@ -313,7 +389,6 @@ export const generateResponseTableColumns = ( }; // Combine the selection column with the dynamic question columns - const baseColumns = [ personColumn, dateColumn, @@ -322,8 +397,8 @@ export const generateResponseTableColumns = ( ...questionColumns, ...variableColumns, ...hiddenFieldColumns, + ...metadataColumns, tagsColumn, - notesColumn, ]; return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns]; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/lib/utils.test.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/lib/utils.test.ts new file mode 100644 index 000000000000..400631f65ee2 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/lib/utils.test.ts @@ -0,0 +1,204 @@ +import "@testing-library/jest-dom/vitest"; +import { + AirplayIcon, + ArrowUpFromDotIcon, + FlagIcon, + GlobeIcon, + MousePointerClickIcon, + SmartphoneIcon, +} from "lucide-react"; +import { describe, expect, test, vi } from "vitest"; +import { + COLUMNS_ICON_MAP, + getAddressFieldLabel, + getContactInfoFieldLabel, + getMetadataFieldLabel, + getMetadataValue, +} from "./utils"; + +describe("utils", () => { + const mockT = vi.fn((key: string) => { + const translations: Record = { + "environments.surveys.responses.address_line_1": "Address Line 1", + "environments.surveys.responses.address_line_2": "Address Line 2", + "environments.surveys.responses.city": "City", + "environments.surveys.responses.state_region": "State/Region", + "environments.surveys.responses.zip_post_code": "ZIP/Post Code", + "environments.surveys.responses.country": "Country", + "environments.surveys.responses.first_name": "First Name", + "environments.surveys.responses.last_name": "Last Name", + "environments.surveys.responses.email": "Email", + "environments.surveys.responses.phone": "Phone", + "environments.surveys.responses.company": "Company", + "common.action": "Action", + "environments.surveys.responses.os": "OS", + "environments.surveys.responses.device": "Device", + "environments.surveys.responses.browser": "Browser", + "common.url": "URL", + "environments.surveys.responses.source": "Source", + }; + return translations[key] || key; + }); + + describe("getAddressFieldLabel", () => { + test("returns correct label for addressLine1", () => { + const result = getAddressFieldLabel("addressLine1", mockT); + expect(result).toBe("Address Line 1"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.address_line_1"); + }); + + test("returns correct label for addressLine2", () => { + const result = getAddressFieldLabel("addressLine2", mockT); + expect(result).toBe("Address Line 2"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.address_line_2"); + }); + + test("returns correct label for city", () => { + const result = getAddressFieldLabel("city", mockT); + expect(result).toBe("City"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.city"); + }); + + test("returns correct label for state", () => { + const result = getAddressFieldLabel("state", mockT); + expect(result).toBe("State/Region"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.state_region"); + }); + + test("returns correct label for zip", () => { + const result = getAddressFieldLabel("zip", mockT); + expect(result).toBe("ZIP/Post Code"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.zip_post_code"); + }); + + test("returns correct label for country", () => { + const result = getAddressFieldLabel("country", mockT); + expect(result).toBe("Country"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.country"); + }); + + test("returns undefined for unknown field", () => { + const result = getAddressFieldLabel("unknown", mockT); + expect(result).toBeUndefined(); + expect(mockT).not.toHaveBeenCalled(); + }); + }); + + describe("getContactInfoFieldLabel", () => { + test("returns correct label for firstName", () => { + const result = getContactInfoFieldLabel("firstName", mockT); + expect(result).toBe("First Name"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.first_name"); + }); + + test("returns correct label for lastName", () => { + const result = getContactInfoFieldLabel("lastName", mockT); + expect(result).toBe("Last Name"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.last_name"); + }); + + test("returns correct label for email", () => { + const result = getContactInfoFieldLabel("email", mockT); + expect(result).toBe("Email"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.email"); + }); + + test("returns correct label for phone", () => { + const result = getContactInfoFieldLabel("phone", mockT); + expect(result).toBe("Phone"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.phone"); + }); + + test("returns correct label for company", () => { + const result = getContactInfoFieldLabel("company", mockT); + expect(result).toBe("Company"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.company"); + }); + + test("returns undefined for unknown field", () => { + const result = getContactInfoFieldLabel("unknown", mockT); + expect(result).toBeUndefined(); + expect(mockT).not.toHaveBeenCalled(); + }); + }); + + describe("getMetadataFieldLabel", () => { + test("returns correct label for action", () => { + const result = getMetadataFieldLabel("action", mockT); + expect(result).toBe("Action"); + expect(mockT).toHaveBeenCalledWith("common.action"); + }); + + test("returns correct label for country", () => { + const result = getMetadataFieldLabel("country", mockT); + expect(result).toBe("Country"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.country"); + }); + + test("returns correct label for os", () => { + const result = getMetadataFieldLabel("os", mockT); + expect(result).toBe("OS"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.os"); + }); + + test("returns correct label for device", () => { + const result = getMetadataFieldLabel("device", mockT); + expect(result).toBe("Device"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.device"); + }); + + test("returns correct label for browser", () => { + const result = getMetadataFieldLabel("browser", mockT); + expect(result).toBe("Browser"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.browser"); + }); + + test("returns correct label for url", () => { + const result = getMetadataFieldLabel("url", mockT); + expect(result).toBe("URL"); + expect(mockT).toHaveBeenCalledWith("common.url"); + }); + + test("returns correct label for source", () => { + const result = getMetadataFieldLabel("source", mockT); + expect(result).toBe("Source"); + expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.source"); + }); + + test("returns capitalized label for unknown field", () => { + const result = getMetadataFieldLabel("customField", mockT); + expect(result).toBe("Customfield"); + expect(mockT).not.toHaveBeenCalled(); + }); + + test("returns capitalized label for field with underscores", () => { + const result = getMetadataFieldLabel("custom_field", mockT); + expect(result).toBe("Custom_field"); + expect(mockT).not.toHaveBeenCalled(); + }); + }); + + describe("COLUMNS_ICON_MAP", () => { + test("contains correct icon mappings", () => { + expect(COLUMNS_ICON_MAP.action).toBe(MousePointerClickIcon); + expect(COLUMNS_ICON_MAP.country).toBe(FlagIcon); + expect(COLUMNS_ICON_MAP.browser).toBe(GlobeIcon); + expect(COLUMNS_ICON_MAP.os).toBe(AirplayIcon); + expect(COLUMNS_ICON_MAP.device).toBe(SmartphoneIcon); + expect(COLUMNS_ICON_MAP.source).toBe(ArrowUpFromDotIcon); + expect(COLUMNS_ICON_MAP.url).toBe(GlobeIcon); + }); + }); + + describe("getMetadataValue", () => { + test("returns correct value for action", () => { + const result = getMetadataValue({ action: "action_column" }, "action"); + expect(result).toBe("action_column"); + }); + + test("returns correct value for userAgent", () => { + const result = getMetadataValue({ userAgent: { browser: "browser_column" } }, "browser"); + expect(result).toBe("browser_column"); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/lib/utils.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/lib/utils.ts new file mode 100644 index 000000000000..2329c282b911 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/lib/utils.ts @@ -0,0 +1,88 @@ +import { TFnType } from "@tolgee/react"; +import { capitalize } from "lodash"; +import { + AirplayIcon, + ArrowUpFromDotIcon, + FlagIcon, + GlobeIcon, + MousePointerClickIcon, + SmartphoneIcon, +} from "lucide-react"; +import { TResponseMeta } from "@formbricks/types/responses"; + +export const getAddressFieldLabel = (field: string, t: TFnType) => { + switch (field) { + case "addressLine1": + return t("environments.surveys.responses.address_line_1"); + case "addressLine2": + return t("environments.surveys.responses.address_line_2"); + case "city": + return t("environments.surveys.responses.city"); + case "state": + return t("environments.surveys.responses.state_region"); + case "zip": + return t("environments.surveys.responses.zip_post_code"); + case "country": + return t("environments.surveys.responses.country"); + default: + break; + } +}; + +export const getContactInfoFieldLabel = (field: string, t: TFnType) => { + switch (field) { + case "firstName": + return t("environments.surveys.responses.first_name"); + case "lastName": + return t("environments.surveys.responses.last_name"); + case "email": + return t("environments.surveys.responses.email"); + case "phone": + return t("environments.surveys.responses.phone"); + case "company": + return t("environments.surveys.responses.company"); + default: + break; + } +}; + +export const getMetadataFieldLabel = (label: string, t: TFnType) => { + switch (label) { + case "action": + return t("common.action"); + case "country": + return t("environments.surveys.responses.country"); + case "os": + return t("environments.surveys.responses.os"); + case "device": + return t("environments.surveys.responses.device"); + case "browser": + return t("environments.surveys.responses.browser"); + case "url": + return t("common.url"); + case "source": + return t("environments.surveys.responses.source"); + default: + return capitalize(label); + } +}; + +export const COLUMNS_ICON_MAP = { + action: MousePointerClickIcon, + country: FlagIcon, + browser: GlobeIcon, + os: AirplayIcon, + device: SmartphoneIcon, + source: ArrowUpFromDotIcon, + url: GlobeIcon, +}; + +const userAgentFields = ["browser", "os", "device"]; +export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"]; + +export const getMetadataValue = (meta: TResponseMeta, label: string) => { + if (userAgentFields.includes(label)) { + return meta.userAgent?.[label]; + } + return meta[label]; +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.test.tsx new file mode 100644 index 000000000000..71aea93449f6 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.test.tsx @@ -0,0 +1,254 @@ +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"; +import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; +import { getDisplayCountBySurveyId } from "@/lib/display/service"; +import { getPublicDomain } from "@/lib/getPublicUrl"; +import { getResponseCountBySurveyId } from "@/lib/response/service"; +import { getSurvey } from "@/lib/survey/service"; +import { getTagsByEnvironmentId } from "@/lib/tag/service"; +import { getUser } from "@/lib/user/service"; +import { findMatchingLocale } from "@/lib/utils/locale"; +import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TTag } from "@formbricks/types/tags"; +import { TUser, TUserLocale } from "@formbricks/types/user"; + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation", + () => ({ + SurveyAnalysisNavigation: vi.fn(() =>
), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage", + () => ({ + ResponsePage: vi.fn(() =>
), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA", + () => ({ + SurveyAnalysisCTA: vi.fn(() =>
), + }) +); + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", + WEBAPP_URL: "http://localhost:3000", + RESPONSES_PER_PAGE: 10, + SESSION_MAX_AGE: 1000, +})); + +vi.mock("@/lib/getPublicUrl", () => ({ + getPublicDomain: vi.fn(), +})); + +vi.mock("@/lib/response/service", () => ({ + getResponseCountBySurveyId: vi.fn(), +})); + +vi.mock("@/lib/display/service", () => ({ + getDisplayCountBySurveyId: vi.fn(), +})); + +vi.mock("@/lib/survey/service", () => ({ + getSurvey: vi.fn(), +})); + +vi.mock("@/lib/tag/service", () => ({ + getTagsByEnvironmentId: vi.fn(), +})); + +vi.mock("@/lib/user/service", () => ({ + getUser: vi.fn(), +})); + +vi.mock("@/lib/utils/locale", () => ({ + findMatchingLocale: vi.fn(), +})); + +vi.mock("@/modules/environments/lib/utils", () => ({ + getEnvironmentAuth: vi.fn(), +})); + +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: vi.fn(({ children }) =>
{children}
), +})); + +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: vi.fn(({ pageTitle, children, cta }) => ( +
+

{pageTitle}

+ {cta} + {children} +
+ )), +})); + +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); + +vi.mock("next/navigation", () => ({ + useParams: () => ({ + environmentId: "test-env-id", + surveyId: "test-survey-id", + }), +})); + +const mockEnvironmentId = "test-env-id"; +const mockSurveyId = "test-survey-id"; +const mockUserId = "test-user-id"; + +const mockSurvey: TSurvey = { + id: mockSurveyId, + name: "Test Survey", + environmentId: mockEnvironmentId, + status: "inProgress", + type: "web", + questions: [], + thankYouCard: { enabled: false }, + endings: [], + languages: [], + triggers: [], + recontactDays: null, + displayOption: "displayOnce", + autoClose: null, + styling: null, +} as unknown as TSurvey; + +const mockUser = { + id: mockUserId, + name: "Test User", + email: "test@example.com", + role: "project_manager", + createdAt: new Date(), + updatedAt: new Date(), + locale: "en-US", +} as unknown as TUser; + +const mockEnvironment = { + id: mockEnvironmentId, + type: "production", + createdAt: new Date(), + updatedAt: new Date(), + appSetupCompleted: true, +} as unknown as TEnvironment; + +const mockTags: TTag[] = [{ id: "tag1", name: "Tag 1", environmentId: mockEnvironmentId } as unknown as TTag]; +const mockLocale: TUserLocale = "en-US"; +const mockPublicDomain = "http://customdomain.com"; + +const mockParams = { + environmentId: mockEnvironmentId, + surveyId: mockSurveyId, +}; + +describe("ResponsesPage", () => { + beforeEach(() => { + vi.mocked(getEnvironmentAuth).mockResolvedValue({ + session: { user: { id: mockUserId } } as any, + environment: mockEnvironment, + isReadOnly: false, + } as TEnvironmentAuth); + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags); + vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10); + vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(5); + vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale); + vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain); + }); + + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + test("renders correctly with all data", async () => { + const props = { params: mockParams }; + const jsx = await Page(props); + render({jsx}); + + await screen.findByTestId("page-content-wrapper"); + expect(screen.getByTestId("page-header")).toBeInTheDocument(); + expect(screen.getByTestId("page-title")).toHaveTextContent(mockSurvey.name); + expect(screen.getByTestId("survey-analysis-cta")).toBeInTheDocument(); + expect(screen.getByTestId("survey-analysis-navigation")).toBeInTheDocument(); + expect(screen.getByTestId("response-page")).toBeInTheDocument(); + + expect(vi.mocked(SurveyAnalysisCTA)).toHaveBeenCalledWith( + expect.objectContaining({ + environment: mockEnvironment, + survey: mockSurvey, + isReadOnly: false, + user: mockUser, + publicDomain: mockPublicDomain, + responseCount: 10, + displayCount: 5, + }), + undefined + ); + + expect(vi.mocked(SurveyAnalysisNavigation)).toHaveBeenCalledWith( + expect.objectContaining({ + environmentId: mockEnvironmentId, + survey: mockSurvey, + activeId: "responses", + }), + undefined + ); + + expect(vi.mocked(ResponsePage)).toHaveBeenCalledWith( + expect.objectContaining({ + environment: mockEnvironment, + survey: mockSurvey, + surveyId: mockSurveyId, + environmentTags: mockTags, + user: mockUser, + responsesPerPage: 10, + locale: mockLocale, + }), + undefined + ); + }); + + test("throws error if survey not found", async () => { + vi.mocked(getSurvey).mockResolvedValue(null); + const props = { params: mockParams }; + await expect(Page(props)).rejects.toThrow("common.survey_not_found"); + }); + + test("throws error if user not found", async () => { + vi.mocked(getUser).mockResolvedValue(null); + const props = { params: mockParams }; + await expect(Page(props)).rejects.toThrow("common.user_not_found"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx index 9932a21f6007..c204fa530adc 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx @@ -1,30 +1,26 @@ 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 { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; -import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils"; -import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils"; +import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants"; +import { getDisplayCountBySurveyId } from "@/lib/display/service"; +import { getPublicDomain } from "@/lib/getPublicUrl"; +import { getResponseCountBySurveyId } from "@/lib/response/service"; +import { getSurvey } from "@/lib/survey/service"; +import { getTagsByEnvironmentId } from "@/lib/tag/service"; +import { getUser } from "@/lib/user/service"; +import { findMatchingLocale } from "@/lib/utils/locale"; +import { getSegments } from "@/modules/ee/contacts/segments/lib/segments"; +import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getTranslate } from "@/tolgee/server"; -import { - MAX_RESPONSES_FOR_INSIGHT_GENERATION, - RESPONSES_PER_PAGE, - WEBAPP_URL, -} from "@formbricks/lib/constants"; -import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl"; -import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; -import { getSurvey } from "@formbricks/lib/survey/service"; -import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; -import { getUser } from "@formbricks/lib/user/service"; -import { findMatchingLocale } from "@formbricks/lib/utils/locale"; const Page = async (props) => { const params = await props.params; const t = await getTranslate(); - const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId); + const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId); const survey = await getSurvey(params.surveyId); @@ -40,15 +36,15 @@ const Page = async (props) => { const tags = await getTagsByEnvironmentId(params.environmentId); - const totalResponseCount = await getResponseCountBySurveyId(params.surveyId); + const isContactsEnabled = await getIsContactsEnabled(); + const segments = isContactsEnabled ? await getSegments(params.environmentId) : []; + + // Get response count for the CTA component + const responseCount = await getResponseCountBySurveyId(params.surveyId); + const displayCount = await getDisplayCountBySurveyId(params.surveyId); - const isAIEnabled = await getIsAIEnabled({ - isAIEnabled: organization.isAIEnabled, - billing: organization.billing, - }); - const shouldGenerateInsights = needsInsightsGeneration(survey); const locale = await findMatchingLocale(); - const surveyDomain = getSurveyDomain(); + const publicDomain = getPublicDomain(); return ( @@ -60,29 +56,20 @@ const Page = async (props) => { survey={survey} isReadOnly={isReadOnly} user={user} - surveyDomain={surveyDomain} + publicDomain={publicDomain} + responseCount={responseCount} + displayCount={displayCount} + segments={segments} + isContactsEnabled={isContactsEnabled} + isFormbricksCloud={IS_FORMBRICKS_CLOUD} /> }> - {isAIEnabled && shouldGenerateInsights && ( - - )} - - + ; + }) => { + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId: parsedInput.organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + minPermission: "readWrite", + projectId: parsedInput.projectId, + }, + ], + }); + + ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; + ctx.auditLoggingCtx.surveyId = parsedInput.surveyId; + ctx.auditLoggingCtx.oldObject = null; + + const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey( + parsedInput.surveyId + ); + + ctx.auditLoggingCtx.newObject = { + deletedResponsesCount: deletedResponsesCount, + deletedDisplaysCount: deletedDisplaysCount, + }; + + return { + success: true, + deletedResponsesCount: deletedResponsesCount, + deletedDisplaysCount: deletedDisplaysCount, + }; + } + ) +); + +const ZGetEmailHtmlAction = z.object({ + surveyId: ZId, +}); + +export const getEmailHtmlAction = authenticatedActionClient + .schema(ZGetEmailHtmlAction) .action(async ({ ctx, parsedInput }) => { await checkAuthorizationUpdated({ userId: ctx.user.id, @@ -80,28 +142,24 @@ export const generateResultShareUrlAction = authenticatedActionClient ], }); - const survey = await getSurvey(parsedInput.surveyId); - if (!survey) { - throw new ResourceNotFoundError("Survey", parsedInput.surveyId); - } - - const resultShareKey = customAlphabet( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", - 20 - )(); - - await updateSurvey({ ...survey, resultShareKey }); - - return resultShareKey; + return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale); }); -const ZGetResultShareUrlAction = z.object({ +const ZGeneratePersonalLinksAction = z.object({ surveyId: ZId, + segmentId: ZId, + environmentId: ZId, + expirationDays: z.number().optional(), }); -export const getResultShareUrlAction = authenticatedActionClient - .schema(ZGetResultShareUrlAction) +export const generatePersonalLinksAction = authenticatedActionClient + .schema(ZGeneratePersonalLinksAction) .action(async ({ ctx, parsedInput }) => { + const isContactsEnabled = await getIsContactsEnabled(); + if (!isContactsEnabled) { + throw new OperationNotAllowedError("Contacts are not enabled for this environment"); + } + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), @@ -118,20 +176,70 @@ export const getResultShareUrlAction = authenticatedActionClient ], }); - const survey = await getSurvey(parsedInput.surveyId); - if (!survey) { - throw new ResourceNotFoundError("Survey", parsedInput.surveyId); + // Get contacts and generate personal links + const contactsResult = await generatePersonalLinks( + parsedInput.surveyId, + parsedInput.segmentId, + parsedInput.expirationDays + ); + + if (!contactsResult || contactsResult.length === 0) { + throw new UnknownError("No contacts found for the selected segment"); } - return survey.resultShareKey; + // Prepare CSV data with the specified headers and order + const csvHeaders = [ + "Formbricks Contact ID", + "User ID", + "First Name", + "Last Name", + "Email", + "Personal Link", + ]; + + const csvData = contactsResult + .map((contact) => { + if (!contact) { + return null; + } + const attributes = contact.attributes ?? {}; + return { + "Formbricks Contact ID": contact.contactId, + "User ID": attributes.userId ?? "", + "First Name": attributes.firstName ?? "", + "Last Name": attributes.lastName ?? "", + Email: attributes.email ?? "", + "Personal Link": contact.surveyUrl, + }; + }) + .filter((contact) => contact !== null); + + // Convert to CSV using the file conversion utility + const csvContent = await convertToCsv(csvHeaders, csvData); + const fileName = `personal-links-${parsedInput.surveyId}-${Date.now()}.csv`; + + // Store file temporarily and return download URL + const fileBuffer = Buffer.from(csvContent); + await putFile(fileName, fileBuffer, "private", parsedInput.environmentId); + + const downloadUrl = `${WEBAPP_URL}/storage/${parsedInput.environmentId}/private/${fileName}`; + + return { + downloadUrl, + fileName, + count: csvData.length, + }; }); -const ZDeleteResultShareUrlAction = z.object({ +const ZUpdateSingleUseLinksAction = z.object({ surveyId: ZId, + environmentId: ZId, + isSingleUse: z.boolean(), + isSingleUseEncryption: z.boolean(), }); -export const deleteResultShareUrlAction = authenticatedActionClient - .schema(ZDeleteResultShareUrlAction) +export const updateSingleUseLinksAction = authenticatedActionClient + .schema(ZUpdateSingleUseLinksAction) .action(async ({ ctx, parsedInput }) => { await checkAuthorizationUpdated({ userId: ctx.user.id, @@ -143,8 +251,8 @@ export const deleteResultShareUrlAction = authenticatedActionClient }, { type: "projectTeam", - minPermission: "readWrite", projectId: await getProjectIdFromSurveyId(parsedInput.surveyId), + minPermission: "readWrite", }, ], }); @@ -154,31 +262,10 @@ export const deleteResultShareUrlAction = authenticatedActionClient throw new ResourceNotFoundError("Survey", parsedInput.surveyId); } - return await updateSurvey({ ...survey, resultShareKey: null }); - }); - -const ZGetEmailHtmlAction = z.object({ - surveyId: ZId, -}); - -export const getEmailHtmlAction = authenticatedActionClient - .schema(ZGetEmailHtmlAction) - .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "projectTeam", - minPermission: "readWrite", - projectId: await getProjectIdFromSurveyId(parsedInput.surveyId), - }, - ], + const updatedSurvey = await updateSurvey({ + ...survey, + singleUse: { enabled: parsedInput.isSingleUse, isEncrypted: parsedInput.isSingleUseEncryption }, }); - return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale); + return updatedSurvey; }); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/AddressSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/AddressSummary.test.tsx new file mode 100644 index 000000000000..211228957ccd --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/AddressSummary.test.tsx @@ -0,0 +1,154 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys/types"; +import { AddressSummary } from "./AddressSummary"; + +// Mock dependencies +vi.mock("@/lib/time", () => ({ + timeSince: () => "2 hours ago", +})); + +vi.mock("@/lib/utils/contact", () => ({ + getContactIdentifier: () => "contact@example.com", +})); + +vi.mock("@/modules/ui/components/avatars", () => ({ + PersonAvatar: ({ personId }: { personId: string }) =>
{personId}
, +})); + +vi.mock("@/modules/ui/components/array-response", () => ({ + ArrayResponse: ({ value }: { value: string[] }) => ( +
{value.join(", ")}
+ ), +})); + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: () =>
, +})); + +describe("AddressSummary", () => { + afterEach(() => { + cleanup(); + }); + + const environmentId = "env-123"; + const survey = {} as TSurvey; + const locale = "en-US"; + + test("renders table headers correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Address Question" }, + samples: [], + } as unknown as TSurveyQuestionSummaryAddress; + + render( + + ); + + expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); + expect(screen.getByText("common.user")).toBeInTheDocument(); + expect(screen.getByText("common.response")).toBeInTheDocument(); + expect(screen.getByText("common.time")).toBeInTheDocument(); + }); + + test("renders contact information correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Address Question" }, + samples: [ + { + id: "response1", + value: ["123 Main St", "Apt 4", "New York", "NY", "10001"], + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: { email: "user@example.com" }, + }, + ], + } as unknown as TSurveyQuestionSummaryAddress; + + render( + + ); + + expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1"); + expect(screen.getByText("contact@example.com")).toBeInTheDocument(); + expect(screen.getByTestId("array-response")).toHaveTextContent("123 Main St, Apt 4, New York, NY, 10001"); + expect(screen.getByText("2 hours ago")).toBeInTheDocument(); + + // Check link to contact + const contactLink = screen.getByText("contact@example.com").closest("a"); + expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/contact1`); + }); + + test("renders anonymous user when no contact is provided", () => { + const questionSummary = { + question: { id: "q1", headline: "Address Question" }, + samples: [ + { + id: "response2", + value: ["456 Oak St", "London", "UK"], + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryAddress; + + render( + + ); + + expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous"); + expect(screen.getByText("common.anonymous")).toBeInTheDocument(); + expect(screen.getByTestId("array-response")).toHaveTextContent("456 Oak St, London, UK"); + }); + + test("renders multiple responses correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Address Question" }, + samples: [ + { + id: "response1", + value: ["123 Main St", "New York"], + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + { + id: "response2", + value: ["456 Oak St", "London"], + updatedAt: new Date().toISOString(), + contact: { id: "contact2" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryAddress; + + render( + + ); + + expect(screen.getAllByTestId("person-avatar")).toHaveLength(2); + expect(screen.getAllByTestId("array-response")).toHaveLength(2); + expect(screen.getAllByText("2 hours ago")).toHaveLength(2); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/AddressSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/AddressSummary.tsx index 79a92779de1a..0e9b68515b48 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/AddressSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/AddressSummary.tsx @@ -1,11 +1,11 @@ "use client"; +import { timeSince } from "@/lib/time"; +import { getContactIdentifier } from "@/lib/utils/contact"; import { ArrayResponse } from "@/modules/ui/components/array-response"; import { PersonAvatar } from "@/modules/ui/components/avatars"; import { useTranslate } from "@tolgee/react"; import Link from "next/link"; -import { timeSince } from "@formbricks/lib/time"; -import { getContactIdentifier } from "@formbricks/lib/utils/contact"; import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary.test.tsx new file mode 100644 index 000000000000..aa92690d7671 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary.test.tsx @@ -0,0 +1,89 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummaryCta } from "@formbricks/types/surveys/types"; +import { CTASummary } from "./CTASummary"; + +vi.mock("@/modules/ui/components/progress-bar", () => ({ + ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => ( +
{`${progress}-${barColor}`}
+ ), +})); + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: ({ + additionalInfo, + }: { + showResponses: boolean; + additionalInfo: React.ReactNode; + }) =>
{additionalInfo}
, +})); + +vi.mock("lucide-react", () => ({ + InboxIcon: () =>
, +})); + +vi.mock("../lib/utils", () => ({ + convertFloatToNDecimal: (value: number) => value.toFixed(2), +})); + +describe("CTASummary", () => { + afterEach(() => { + cleanup(); + }); + + const survey = {} as TSurvey; + + test("renders with all metrics and required question", () => { + const questionSummary = { + question: { id: "q1", headline: "CTA Question", required: true }, + impressionCount: 100, + clickCount: 25, + skipCount: 10, + ctr: { count: 25, percentage: 25 }, + } as unknown as TSurveyQuestionSummaryCta; + + render(); + + expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); + expect(screen.getByText("100 common.impressions")).toBeInTheDocument(); + // Use getAllByText instead of getByText for multiple matching elements + expect(screen.getAllByText("25 common.clicks")).toHaveLength(2); + expect(screen.queryByText("10 common.skips")).not.toBeInTheDocument(); // Should not show skips for required questions + + // Check CTR section + expect(screen.getByText("CTR")).toBeInTheDocument(); + expect(screen.getByText("25.00%")).toBeInTheDocument(); + + // Check progress bar + expect(screen.getByTestId("progress-bar")).toHaveTextContent("0.25-bg-brand-dark"); + }); + + test("renders skip count for non-required questions", () => { + const questionSummary = { + question: { id: "q1", headline: "CTA Question", required: false }, + impressionCount: 100, + clickCount: 20, + skipCount: 30, + ctr: { count: 20, percentage: 20 }, + } as unknown as TSurveyQuestionSummaryCta; + + render(); + + expect(screen.getByText("30 common.skips")).toBeInTheDocument(); + }); + + test("renders singular form for count = 1", () => { + const questionSummary = { + question: { id: "q1", headline: "CTA Question", required: true }, + impressionCount: 10, + clickCount: 1, + skipCount: 0, + ctr: { count: 1, percentage: 10 }, + } as unknown as TSurveyQuestionSummaryCta; + + render(); + + // Use getAllByText instead of getByText for multiple matching elements + expect(screen.getAllByText("1 common.click")).toHaveLength(1); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.test.tsx new file mode 100644 index 000000000000..f914246fc1b6 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.test.tsx @@ -0,0 +1,69 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummaryCal } from "@formbricks/types/surveys/types"; +import { CalSummary } from "./CalSummary"; + +vi.mock("@/modules/ui/components/progress-bar", () => ({ + ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => ( +
{`${progress}-${barColor}`}
+ ), +})); + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: () =>
, +})); + +vi.mock("../lib/utils", () => ({ + convertFloatToNDecimal: (value: number) => value.toFixed(2), +})); + +describe("CalSummary", () => { + afterEach(() => { + cleanup(); + }); + + const environmentId = "env-123"; + const survey = {} as TSurvey; + + test("renders the correct components and data", () => { + const questionSummary = { + question: { id: "q1", headline: "Calendar Question" }, + booked: { count: 5, percentage: 75 }, + skipped: { count: 1, percentage: 25 }, + } as unknown as TSurveyQuestionSummaryCal; + + render(); + + expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); + + // Check if booked section is displayed + expect(screen.getByText("common.booked")).toBeInTheDocument(); + expect(screen.getByText("75.00%")).toBeInTheDocument(); + expect(screen.getByText("5 common.responses")).toBeInTheDocument(); + + // Check if skipped section is displayed + expect(screen.getByText("common.dismissed")).toBeInTheDocument(); + expect(screen.getByText("25.00%")).toBeInTheDocument(); + expect(screen.getByText("1 common.response")).toBeInTheDocument(); + + // Check progress bars + const progressBars = screen.getAllByTestId("progress-bar"); + expect(progressBars).toHaveLength(2); + expect(progressBars[0]).toHaveTextContent("0.75-bg-brand-dark"); + expect(progressBars[1]).toHaveTextContent("0.25-bg-brand-dark"); + }); + + test("renders singular and plural response counts correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Calendar Question" }, + booked: { count: 1, percentage: 50 }, + skipped: { count: 1, percentage: 50 }, + } as unknown as TSurveyQuestionSummaryCal; + + render(); + + // Use getAllByText directly since we know there are multiple matching elements + const responseElements = screen.getAllByText("1 common.response"); + expect(responseElements).toHaveLength(2); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.test.tsx new file mode 100644 index 000000000000..f97f35b5e4bb --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.test.tsx @@ -0,0 +1,80 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + TSurvey, + TSurveyConsentQuestion, + TSurveyQuestionSummaryConsent, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; +import { ConsentSummary } from "./ConsentSummary"; + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader", + () => ({ + QuestionSummaryHeader: () =>
QuestionSummaryHeader
, + }) +); + +describe("ConsentSummary", () => { + afterEach(() => { + cleanup(); + }); + + const mockSetFilter = vi.fn(); + const questionSummary = { + question: { + id: "q1", + headline: { en: "Headline" }, + type: TSurveyQuestionTypeEnum.Consent, + } as unknown as TSurveyConsentQuestion, + accepted: { percentage: 60.5, count: 61 }, + dismissed: { percentage: 39.5, count: 40 }, + } as unknown as TSurveyQuestionSummaryConsent; + const survey = {} as TSurvey; + + test("renders accepted and dismissed with correct values", () => { + render(); + expect(screen.getByText("common.accepted")).toBeInTheDocument(); + expect(screen.getByText(/60\.5%/)).toBeInTheDocument(); + expect(screen.getByText(/61/)).toBeInTheDocument(); + expect(screen.getByText("common.dismissed")).toBeInTheDocument(); + expect(screen.getByText(/39\.5%/)).toBeInTheDocument(); + expect(screen.getByText(/40/)).toBeInTheDocument(); + }); + + test("calls setFilter with correct args on accepted click", async () => { + render(); + await userEvent.click(screen.getByText("common.accepted")); + expect(mockSetFilter).toHaveBeenCalledWith( + "q1", + { en: "Headline" }, + TSurveyQuestionTypeEnum.Consent, + "is", + "common.accepted" + ); + }); + + test("calls setFilter with correct args on dismissed click", async () => { + render(); + await userEvent.click(screen.getByText("common.dismissed")); + expect(mockSetFilter).toHaveBeenCalledWith( + "q1", + { en: "Headline" }, + TSurveyQuestionTypeEnum.Consent, + "is", + "common.dismissed" + ); + }); + + test("renders singular and plural response labels", () => { + const oneAndTwo = { + ...questionSummary, + accepted: { percentage: questionSummary.accepted.percentage, count: 1 }, + dismissed: { percentage: questionSummary.dismissed.percentage, count: 2 }, + }; + render(); + expect(screen.getByText(/1 common\.response/)).toBeInTheDocument(); + expect(screen.getByText(/2 common\.responses/)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx index 1234f0f90694..73f1a243a304 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx @@ -44,8 +44,8 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
{summaryItems.map((summaryItem) => { return ( -
setFilter( @@ -74,7 +74,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
-
+ ); })}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary.test.tsx new file mode 100644 index 000000000000..5ed1adfe414b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary.test.tsx @@ -0,0 +1,153 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types"; +import { ContactInfoSummary } from "./ContactInfoSummary"; + +vi.mock("@/lib/time", () => ({ + timeSince: () => "2 hours ago", +})); + +vi.mock("@/lib/utils/contact", () => ({ + getContactIdentifier: () => "contact@example.com", +})); + +vi.mock("@/modules/ui/components/avatars", () => ({ + PersonAvatar: ({ personId }: { personId: string }) =>
{personId}
, +})); + +vi.mock("@/modules/ui/components/array-response", () => ({ + ArrayResponse: ({ value }: { value: string[] }) => ( +
{value.join(", ")}
+ ), +})); + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: () =>
, +})); + +describe("ContactInfoSummary", () => { + afterEach(() => { + cleanup(); + }); + + const environmentId = "env-123"; + const survey = {} as TSurvey; + const locale = "en-US"; + + test("renders table headers correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Contact Info Question" }, + samples: [], + } as unknown as TSurveyQuestionSummaryContactInfo; + + render( + + ); + + expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); + expect(screen.getByText("common.user")).toBeInTheDocument(); + expect(screen.getByText("common.response")).toBeInTheDocument(); + expect(screen.getByText("common.time")).toBeInTheDocument(); + }); + + test("renders contact information correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Contact Info Question" }, + samples: [ + { + id: "response1", + value: ["John Doe", "john@example.com", "+1234567890"], + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: { email: "user@example.com" }, + }, + ], + } as unknown as TSurveyQuestionSummaryContactInfo; + + render( + + ); + + expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1"); + expect(screen.getByText("contact@example.com")).toBeInTheDocument(); + expect(screen.getByTestId("array-response")).toHaveTextContent("John Doe, john@example.com, +1234567890"); + expect(screen.getByText("2 hours ago")).toBeInTheDocument(); + + // Check link to contact + const contactLink = screen.getByText("contact@example.com").closest("a"); + expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/contact1`); + }); + + test("renders anonymous user when no contact is provided", () => { + const questionSummary = { + question: { id: "q1", headline: "Contact Info Question" }, + samples: [ + { + id: "response2", + value: ["Anonymous User", "anonymous@example.com"], + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryContactInfo; + + render( + + ); + + expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous"); + expect(screen.getByText("common.anonymous")).toBeInTheDocument(); + expect(screen.getByTestId("array-response")).toHaveTextContent("Anonymous User, anonymous@example.com"); + }); + + test("renders multiple responses correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Contact Info Question" }, + samples: [ + { + id: "response1", + value: ["John Doe", "john@example.com"], + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + { + id: "response2", + value: ["Jane Smith", "jane@example.com"], + updatedAt: new Date().toISOString(), + contact: { id: "contact2" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryContactInfo; + + render( + + ); + + expect(screen.getAllByTestId("person-avatar")).toHaveLength(2); + expect(screen.getAllByTestId("array-response")).toHaveLength(2); + expect(screen.getAllByText("2 hours ago")).toHaveLength(2); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary.tsx index d549e18df046..2aecef1db64e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary.tsx @@ -1,11 +1,11 @@ "use client"; +import { timeSince } from "@/lib/time"; +import { getContactIdentifier } from "@/lib/utils/contact"; import { ArrayResponse } from "@/modules/ui/components/array-response"; import { PersonAvatar } from "@/modules/ui/components/avatars"; import { useTranslate } from "@tolgee/react"; import Link from "next/link"; -import { timeSince } from "@formbricks/lib/time"; -import { getContactIdentifier } from "@formbricks/lib/utils/contact"; import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.test.tsx new file mode 100644 index 000000000000..904b846389a3 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.test.tsx @@ -0,0 +1,192 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummaryDate } from "@formbricks/types/surveys/types"; +import { DateQuestionSummary } from "./DateQuestionSummary"; + +vi.mock("@/lib/time", () => ({ + timeSince: () => "2 hours ago", +})); + +vi.mock("@/lib/utils/contact", () => ({ + getContactIdentifier: () => "contact@example.com", +})); + +vi.mock("@/lib/utils/datetime", () => ({ + formatDateWithOrdinal: (_: Date) => "January 1st, 2023", +})); + +vi.mock("@/modules/ui/components/avatars", () => ({ + PersonAvatar: ({ personId }: { personId: string }) =>
{personId}
, +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => ( + + ), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + + {children} + + ), +})); + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: () =>
, +})); + +describe("DateQuestionSummary", () => { + afterEach(() => { + cleanup(); + }); + + const environmentId = "env-123"; + const survey = {} as TSurvey; + const locale = "en-US"; + + test("renders table headers correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Date Question" }, + samples: [], + } as unknown as TSurveyQuestionSummaryDate; + + render( + + ); + + expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); + expect(screen.getByText("common.user")).toBeInTheDocument(); + expect(screen.getByText("common.response")).toBeInTheDocument(); + expect(screen.getByText("common.time")).toBeInTheDocument(); + }); + + test("renders date responses correctly", () => { + const questionSummary = { + question: { id: "q1", headline: "Date Question" }, + samples: [ + { + id: "response1", + value: "2023-01-01", + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryDate; + + render( + + ); + + expect(screen.getByText("January 1st, 2023")).toBeInTheDocument(); + expect(screen.getByText("contact@example.com")).toBeInTheDocument(); + expect(screen.getByText("2 hours ago")).toBeInTheDocument(); + }); + + test("renders invalid dates with special message", () => { + const questionSummary = { + question: { id: "q1", headline: "Date Question" }, + samples: [ + { + id: "response1", + value: "invalid-date", + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryDate; + + render( + + ); + + expect(screen.getByText("common.invalid_date(invalid-date)")).toBeInTheDocument(); + }); + + test("renders anonymous user when no contact is provided", () => { + const questionSummary = { + question: { id: "q1", headline: "Date Question" }, + samples: [ + { + id: "response1", + value: "2023-01-01", + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryDate; + + render( + + ); + + expect(screen.getByText("common.anonymous")).toBeInTheDocument(); + }); + + test("shows load more button when there are more responses and loads more on click", async () => { + const samples = Array.from({ length: 15 }, (_, i) => ({ + id: `response${i}`, + value: "2023-01-01", + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + })); + + const questionSummary = { + question: { id: "q1", headline: "Date Question" }, + samples, + } as unknown as TSurveyQuestionSummaryDate; + + render( + + ); + + // Initially 10 responses should be visible + expect(screen.getAllByText("January 1st, 2023")).toHaveLength(10); + + // "Load More" button should be visible + const loadMoreButton = screen.getByTestId("load-more-button"); + expect(loadMoreButton).toBeInTheDocument(); + + // Click "Load More" + await userEvent.click(loadMoreButton); + + // Now all 15 responses should be visible + expect(screen.getAllByText("January 1st, 2023")).toHaveLength(15); + + // "Load More" button should disappear + expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.tsx index 5f9b00bb37ed..a2fa7558a3d7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.tsx @@ -1,13 +1,13 @@ "use client"; +import { timeSince } from "@/lib/time"; +import { getContactIdentifier } from "@/lib/utils/contact"; +import { formatDateWithOrdinal } from "@/lib/utils/datetime"; import { PersonAvatar } from "@/modules/ui/components/avatars"; import { Button } from "@/modules/ui/components/button"; import { useTranslate } from "@tolgee/react"; import Link from "next/link"; import { useState } from "react"; -import { timeSince } from "@formbricks/lib/time"; -import { getContactIdentifier } from "@formbricks/lib/utils/contact"; -import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime"; import { TSurvey, TSurveyQuestionSummaryDate } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; @@ -80,7 +80,7 @@ export const DateQuestionSummary = ({
)}
-
+
{renderResponseValue(response.value)}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner.tsx deleted file mode 100644 index babc571aa977..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { generateInsightsForSurveyAction } from "@/modules/ee/insights/actions"; -import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; -import { Badge } from "@/modules/ui/components/badge"; -import { Button } from "@/modules/ui/components/button"; -import { TooltipRenderer } from "@/modules/ui/components/tooltip"; -import { useTranslate } from "@tolgee/react"; -import { SparklesIcon } from "lucide-react"; -import { useState } from "react"; -import toast from "react-hot-toast"; - -interface EnableInsightsBannerProps { - surveyId: string; - maxResponseCount: number; - surveyResponseCount: number; -} - -export const EnableInsightsBanner = ({ - surveyId, - surveyResponseCount, - maxResponseCount, -}: EnableInsightsBannerProps) => { - const { t } = useTranslate(); - const [isGeneratingInsights, setIsGeneratingInsights] = useState(false); - - const handleInsightGeneration = async () => { - toast.success("Generating insights for this survey. Please check back in a few minutes.", { - duration: 3000, - }); - setIsGeneratingInsights(true); - toast.success(t("environments.surveys.summary.enable_ai_insights_banner_success")); - generateInsightsForSurveyAction({ surveyId }); - }; - - if (isGeneratingInsights) { - return null; - } - - return ( - -
- -
-
- - {t("environments.surveys.summary.enable_ai_insights_banner_title")} - - - - {t("environments.surveys.summary.enable_ai_insights_banner_description")} - -
- maxResponseCount - ? t("environments.surveys.summary.enable_ai_insights_banner_tooltip") - : undefined - }> - - -
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.test.tsx new file mode 100644 index 000000000000..af062231aeb8 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.test.tsx @@ -0,0 +1,231 @@ +import { FileUploadSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + TSurvey, + TSurveyFileUploadQuestion, + TSurveyQuestionSummaryFileUpload, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; + +// Mock child components and hooks +vi.mock("@/modules/ui/components/avatars", () => ({ + PersonAvatar: vi.fn(() =>
PersonAvatarMock
), +})); + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: vi.fn(() =>
QuestionSummaryHeaderMock
), +})); + +// Mock utility functions +vi.mock("@/lib/storage/utils", () => ({ + getOriginalFileNameFromUrl: (url: string) => `original-${url.split("/").pop()}`, +})); + +vi.mock("@/lib/time", () => ({ + timeSince: () => "some time ago", +})); + +vi.mock("@/lib/utils/contact", () => ({ + getContactIdentifier: () => "contact@example.com", +})); + +const environmentId = "test-env-id"; +const survey = { id: "survey-1" } as TSurvey; +const locale = "en-US"; + +const createMockResponse = (id: string, value: string[], contactId: string | null = null) => ({ + id: `response-${id}`, + value, + updatedAt: new Date().toISOString(), + contact: contactId ? { id: contactId, name: `Contact ${contactId}` } : null, + contactAttributes: contactId ? { email: `contact${contactId}@example.com` } : {}, +}); + +const questionSummaryBase = { + question: { + id: "q1", + headline: { default: "Upload your file" }, + type: TSurveyQuestionTypeEnum.FileUpload, + } as unknown as TSurveyFileUploadQuestion, + responseCount: 0, + files: [], +} as unknown as TSurveyQuestionSummaryFileUpload; + +describe("FileUploadSummary", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the component with initial responses", () => { + const files = Array.from({ length: 5 }, (_, i) => + createMockResponse(i.toString(), [`https://example.com/file${i}.pdf`], `contact-${i}`) + ); + const questionSummary = { + ...questionSummaryBase, + files, + responseCount: files.length, + } as unknown as TSurveyQuestionSummaryFileUpload; + + render( + + ); + + expect(screen.getByText("QuestionSummaryHeaderMock")).toBeInTheDocument(); + expect(screen.getByText("common.user")).toBeInTheDocument(); + expect(screen.getByText("common.response")).toBeInTheDocument(); + expect(screen.getByText("common.time")).toBeInTheDocument(); + expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(5); + expect(screen.getAllByText("contact@example.com")).toHaveLength(5); + expect(screen.getByText("original-file0.pdf")).toBeInTheDocument(); + expect(screen.getByText("original-file4.pdf")).toBeInTheDocument(); + expect(screen.queryByText("common.load_more")).not.toBeInTheDocument(); + }); + + test("renders 'Skipped' when value is an empty array", () => { + const files = [createMockResponse("skipped", [], "contact-skipped")]; + const questionSummary = { + ...questionSummaryBase, + files, + responseCount: files.length, + } as unknown as TSurveyQuestionSummaryFileUpload; + + render( + + ); + + expect(screen.getByText("common.skipped")).toBeInTheDocument(); + expect(screen.queryByText(/original-/)).not.toBeInTheDocument(); // No file name should be rendered + }); + + test("renders 'Anonymous' when contact is null", () => { + const files = [createMockResponse("anon", ["https://example.com/anonfile.jpg"], null)]; + const questionSummary = { + ...questionSummaryBase, + files, + responseCount: files.length, + } as unknown as TSurveyQuestionSummaryFileUpload; + + render( + + ); + + expect(screen.getByText("common.anonymous")).toBeInTheDocument(); + expect(screen.getByText("original-anonfile.jpg")).toBeInTheDocument(); + }); + + test("shows 'Load More' button when there are more than 10 responses and loads more on click", async () => { + const files = Array.from({ length: 15 }, (_, i) => + createMockResponse(i.toString(), [`https://example.com/file${i}.txt`], `contact-${i}`) + ); + const questionSummary = { + ...questionSummaryBase, + files, + responseCount: files.length, + } as unknown as TSurveyQuestionSummaryFileUpload; + + render( + + ); + + // Initially 10 responses should be visible + expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(10); + expect(screen.getByText("original-file9.txt")).toBeInTheDocument(); + expect(screen.queryByText("original-file10.txt")).not.toBeInTheDocument(); + + // "Load More" button should be visible + const loadMoreButton = screen.getByText("common.load_more"); + expect(loadMoreButton).toBeInTheDocument(); + + // Click "Load More" + await userEvent.click(loadMoreButton); + + // Now all 15 responses should be visible + expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(15); + expect(screen.getByText("original-file14.txt")).toBeInTheDocument(); + + // "Load More" button should disappear + expect(screen.queryByText("common.load_more")).not.toBeInTheDocument(); + }); + + test("renders multiple files for a single response", () => { + const files = [ + createMockResponse( + "multi", + ["https://example.com/fileA.png", "https://example.com/fileB.docx"], + "contact-multi" + ), + ]; + const questionSummary = { + ...questionSummaryBase, + files, + responseCount: files.length, + } as unknown as TSurveyQuestionSummaryFileUpload; + + render( + + ); + + expect(screen.getByText("original-fileA.png")).toBeInTheDocument(); + expect(screen.getByText("original-fileB.docx")).toBeInTheDocument(); + // Check that download links exist + const links = screen.getAllByRole("link"); + // 1 contact link + 2 file links + expect(links.filter((link) => link.getAttribute("target") === "_blank")).toHaveLength(2); + expect( + links.find((link) => link.getAttribute("href") === "https://example.com/fileA.png") + ).toBeInTheDocument(); + expect( + links.find((link) => link.getAttribute("href") === "https://example.com/fileB.docx") + ).toBeInTheDocument(); + }); + + test("renders contact link correctly", () => { + const contactId = "contact-link-test"; + const files = [createMockResponse("link", ["https://example.com/link.pdf"], contactId)]; + const questionSummary = { + ...questionSummaryBase, + files, + responseCount: files.length, + } as unknown as TSurveyQuestionSummaryFileUpload; + + render( + + ); + + const contactLink = screen.getByText("contact@example.com").closest("a"); + expect(contactLink).toBeInTheDocument(); + expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/${contactId}`); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx index 1803cf84cebc..39cb0ed6ec5b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx @@ -1,14 +1,14 @@ "use client"; +import { getOriginalFileNameFromUrl } from "@/lib/storage/utils"; +import { timeSince } from "@/lib/time"; +import { getContactIdentifier } from "@/lib/utils/contact"; import { PersonAvatar } from "@/modules/ui/components/avatars"; import { Button } from "@/modules/ui/components/button"; import { useTranslate } from "@tolgee/react"; import { DownloadIcon, FileIcon } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; -import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils"; -import { timeSince } from "@formbricks/lib/time"; -import { getContactIdentifier } from "@formbricks/lib/utils/contact"; import { TSurvey, TSurveyQuestionSummaryFileUpload } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; @@ -74,12 +74,12 @@ export const FileUploadSummary = ({
{Array.isArray(response.value) && (response.value.length > 0 ? ( - response.value.map((fileUrl, index) => { + response.value.map((fileUrl) => { const fileName = getOriginalFileNameFromUrl(fileUrl); return (
- +
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.test.tsx new file mode 100644 index 000000000000..7924f943fba5 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.test.tsx @@ -0,0 +1,183 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TSurveyQuestionSummaryHiddenFields } from "@formbricks/types/surveys/types"; +import { HiddenFieldsSummary } from "./HiddenFieldsSummary"; + +// Mock dependencies +vi.mock("@/lib/time", () => ({ + timeSince: () => "2 hours ago", +})); + +vi.mock("@/lib/utils/contact", () => ({ + getContactIdentifier: () => "contact@example.com", +})); + +vi.mock("@/modules/ui/components/avatars", () => ({ + PersonAvatar: ({ personId }: { personId: string }) =>
{personId}
, +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => ( + + ), +})); + +// Mock lucide-react components +vi.mock("lucide-react", () => ({ + InboxIcon: () =>
, + MessageSquareTextIcon: () =>
, + Link: ({ children, href, className }: { children: React.ReactNode; href: string; className: string }) => ( + + {children} + + ), +})); + +// Mock Next.js Link +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + + {children} + + ), +})); + +describe("HiddenFieldsSummary", () => { + afterEach(() => { + cleanup(); + }); + + const environment = { id: "env-123" } as TEnvironment; + const locale = "en-US"; + + test("renders component with correct header and single response", () => { + const questionSummary = { + id: "hidden-field-1", + responseCount: 1, + samples: [ + { + id: "response1", + value: "Hidden value", + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryHiddenFields; + + render( + + ); + + expect(screen.getByText("hidden-field-1")).toBeInTheDocument(); + expect(screen.getByText("Hidden Field")).toBeInTheDocument(); + expect(screen.getByText("1 common.response")).toBeInTheDocument(); + + // Headers + expect(screen.getByText("common.user")).toBeInTheDocument(); + expect(screen.getByText("common.response")).toBeInTheDocument(); + expect(screen.getByText("common.time")).toBeInTheDocument(); + + // We can skip checking for PersonAvatar as it's inside hidden md:flex + expect(screen.getByText("contact@example.com")).toBeInTheDocument(); + expect(screen.getByText("Hidden value")).toBeInTheDocument(); + expect(screen.getByText("2 hours ago")).toBeInTheDocument(); + + // Check for link without checking for specific href + expect(screen.getByText("contact@example.com")).toBeInTheDocument(); + }); + + test("renders anonymous user when no contact is provided", () => { + const questionSummary = { + id: "hidden-field-1", + responseCount: 1, + samples: [ + { + id: "response1", + value: "Anonymous hidden value", + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryHiddenFields; + + render( + + ); + + // Instead of checking for avatar, just check for anonymous text + expect(screen.getByText("common.anonymous")).toBeInTheDocument(); + expect(screen.getByText("Anonymous hidden value")).toBeInTheDocument(); + }); + + test("renders plural response label when multiple responses", () => { + const questionSummary = { + id: "hidden-field-1", + responseCount: 2, + samples: [ + { + id: "response1", + value: "Hidden value 1", + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + { + id: "response2", + value: "Hidden value 2", + updatedAt: new Date().toISOString(), + contact: { id: "contact2" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryHiddenFields; + + render( + + ); + + expect(screen.getByText("2 common.responses")).toBeInTheDocument(); + expect(screen.getAllByText("contact@example.com")).toHaveLength(2); + }); + + test("shows load more button when there are more responses and loads more on click", async () => { + const samples = Array.from({ length: 15 }, (_, i) => ({ + id: `response${i}`, + value: `Hidden value ${i}`, + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + })); + + const questionSummary = { + id: "hidden-field-1", + responseCount: samples.length, + samples, + } as unknown as TSurveyQuestionSummaryHiddenFields; + + render( + + ); + + // Initially 10 responses should be visible + expect(screen.getAllByText(/Hidden value \d+/)).toHaveLength(10); + + // "Load More" button should be visible + const loadMoreButton = screen.getByTestId("load-more-button"); + expect(loadMoreButton).toBeInTheDocument(); + + // Click "Load More" + await userEvent.click(loadMoreButton); + + // Now all 15 responses should be visible + expect(screen.getAllByText(/Hidden value \d+/)).toHaveLength(15); + + // "Load More" button should disappear + expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.tsx index 357bd1bfdf57..e4210bde6393 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary.tsx @@ -1,12 +1,12 @@ "use client"; +import { timeSince } from "@/lib/time"; +import { getContactIdentifier } from "@/lib/utils/contact"; import { PersonAvatar } from "@/modules/ui/components/avatars"; import { Button } from "@/modules/ui/components/button"; import { useTranslate } from "@tolgee/react"; import { InboxIcon, Link, MessageSquareTextIcon } from "lucide-react"; import { useState } from "react"; -import { timeSince } from "@formbricks/lib/time"; -import { getContactIdentifier } from "@formbricks/lib/utils/contact"; import { TEnvironment } from "@formbricks/types/environment"; import { TSurveyQuestionSummaryHiddenFields } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.test.tsx new file mode 100644 index 000000000000..35e5c134a208 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.test.tsx @@ -0,0 +1,47 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { MatrixQuestionSummary } from "./MatrixQuestionSummary"; + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader", + () => ({ + QuestionSummaryHeader: () =>
QuestionSummaryHeader
, + }) +); + +describe("MatrixQuestionSummary", () => { + afterEach(() => { + cleanup(); + }); + + const survey = { id: "s1" } as any; + const questionSummary = { + question: { id: "q1", headline: "Q Head", type: "matrix" }, + data: [ + { + rowLabel: "Row1", + totalResponsesForRow: 10, + columnPercentages: [ + { column: "Yes", percentage: 50 }, + { column: "No", percentage: 50 }, + ], + }, + ], + } as any; + + test("renders headers and buttons, click triggers setFilter", async () => { + const setFilter = vi.fn(); + render(); + + // column headers + expect(screen.getByText("Yes")).toBeInTheDocument(); + expect(screen.getByText("No")).toBeInTheDocument(); + // row label + expect(screen.getByText("Row1")).toBeInTheDocument(); + // buttons + const btn = screen.getAllByRole("button", { name: /50/ }); + await userEvent.click(btn[0]); + expect(setFilter).toHaveBeenCalledWith("q1", "Q Head", "matrix", "Row1", "Yes"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.tsx index 59f19364be32..2b249875bad4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.tsx @@ -81,7 +81,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma percentage, questionSummary.data[rowIndex].totalResponsesForRow )}> -
@@ -94,7 +94,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma ) }> {percentage} -
+ ))} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.test.tsx new file mode 100644 index 000000000000..e15259a9c1e6 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.test.tsx @@ -0,0 +1,405 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { MultipleChoiceSummary } from "./MultipleChoiceSummary"; + +vi.mock("@/modules/ui/components/avatars", () => ({ + PersonAvatar: ({ personId }: any) =>
{personId}
, +})); +vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () =>
})); +vi.mock("@/modules/ui/components/id-badge", () => ({ + IdBadge: ({ id }: { id: string }) => ( +
+ ID: {id} +
+ ), +})); + +describe("MultipleChoiceSummary", () => { + afterEach(() => { + cleanup(); + }); + + const baseSurvey = { id: "s1" } as any; + const envId = "env"; + + test("renders header and choice button", async () => { + const setFilter = vi.fn(); + const q = { + question: { + id: "q", + headline: "H", + type: "multipleChoiceSingle", + choices: [{ id: "c", label: { default: "C" } }], + }, + choices: { C: { value: "C", count: 1, percentage: 100, others: [] } }, + type: "multipleChoiceSingle", + selectionCount: 0, + } as any; + render( + + ); + expect(screen.getByTestId("header")).toBeDefined(); + const btn = screen.getByText("1 - C"); + await userEvent.click(btn); + expect(setFilter).toHaveBeenCalledWith( + "q", + "H", + "multipleChoiceSingle", + "environments.surveys.summary.includes_either", + ["C"] + ); + }); + + test("renders others and load more for link", async () => { + const setFilter = vi.fn(); + const others = Array.from({ length: 12 }, (_, i) => ({ + value: `O${i}`, + contact: { id: `id${i}` }, + contactAttributes: {}, + })); + const q = { + question: { + id: "q2", + headline: "H2", + type: "multipleChoiceMulti", + choices: [{ id: "c2", label: { default: "X" } }], + }, + choices: { X: { value: "X", count: 0, percentage: 0, others } }, + type: "multipleChoiceMulti", + selectionCount: 5, + } as any; + render( + + ); + expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeDefined(); + expect(screen.getAllByText(/^O/)).toHaveLength(10); + await userEvent.click(screen.getByText("common.load_more")); + expect(screen.getAllByText(/^O/)).toHaveLength(12); + }); + + test("renders others with avatar for app", () => { + const setFilter = vi.fn(); + const others = [{ value: "Val", contact: { id: "uid" }, contactAttributes: {} }]; + const q = { + question: { + id: "q3", + headline: "H3", + type: "multipleChoiceMulti", + choices: [{ id: "c3", label: { default: "L" } }], + }, + choices: { L: { value: "L", count: 0, percentage: 0, others } }, + type: "multipleChoiceMulti", + selectionCount: 1, + } as any; + render( + + ); + expect(screen.getByTestId("avatar")).toBeDefined(); + expect(screen.getByText("Val")).toBeDefined(); + }); + + test("places choice without others before one with others", () => { + const setFilter = vi.fn(); + const choices = { + A: { value: "A", count: 0, percentage: 0, others: [] }, + B: { value: "B", count: 0, percentage: 0, others: [{ value: "x" }] }, + }; + render( + + ); + const btns = screen.getAllByRole("button"); + expect(btns[0]).toHaveTextContent("2 - A"); + expect(btns[1]).toHaveTextContent("1 - B"); + }); + + test("sorts by count when neither has others", () => { + const setFilter = vi.fn(); + const choices = { + X: { value: "X", count: 1, percentage: 50, others: [] }, + Y: { value: "Y", count: 2, percentage: 50, others: [] }, + }; + render( + + ); + const btns = screen.getAllByRole("button"); + expect(btns[0]).toHaveTextContent("2 - YID: other2 common.selections50%"); + expect(btns[1]).toHaveTextContent("1 - XID: other1 common.selection50%"); + }); + + test("places choice with others after one without when reversed inputs", () => { + const setFilter = vi.fn(); + const choices = { + C: { value: "C", count: 1, percentage: 0, others: [{ value: "z" }] }, + D: { value: "D", count: 1, percentage: 0, others: [] }, + }; + render( + + ); + const btns = screen.getAllByRole("button"); + expect(btns[0]).toHaveTextContent("2 - D"); + expect(btns[1]).toHaveTextContent("1 - C"); + }); + + test("multi type non-other uses includes_all", async () => { + const setFilter = vi.fn(); + const q = { + question: { + id: "q4", + headline: "H4", + type: "multipleChoiceMulti", + choices: [ + { id: "other", label: { default: "O" } }, + { id: "c4", label: { default: "C4" } }, + ], + }, + choices: { + O: { value: "O", count: 1, percentage: 10, others: [] }, + C4: { value: "C4", count: 2, percentage: 20, others: [] }, + }, + type: "multipleChoiceMulti", + selectionCount: 0, + } as any; + + render( + + ); + + const btn = screen.getByText("2 - C4"); + await userEvent.click(btn); + expect(setFilter).toHaveBeenCalledWith( + "q4", + "H4", + "multipleChoiceMulti", + "environments.surveys.summary.includes_all", + ["C4"] + ); + }); + + test("multi type other uses includes_either", async () => { + const setFilter = vi.fn(); + const q = { + question: { + id: "q5", + headline: "H5", + type: "multipleChoiceMulti", + choices: [ + { id: "other", label: { default: "O5" } }, + { id: "c5", label: { default: "C5" } }, + ], + }, + choices: { + O5: { value: "O5", count: 1, percentage: 10, others: [] }, + C5: { value: "C5", count: 0, percentage: 0, others: [] }, + }, + type: "multipleChoiceMulti", + selectionCount: 0, + } as any; + + render( + + ); + + const btn = screen.getByText("2 - O5"); + await userEvent.click(btn); + expect(setFilter).toHaveBeenCalledWith( + "q5", + "H5", + "multipleChoiceMulti", + "environments.surveys.summary.includes_either", + ["O5"] + ); + }); + + // New tests for IdBadge functionality + test("renders IdBadge when choice ID is found", () => { + const setFilter = vi.fn(); + const q = { + question: { + id: "q6", + headline: "H6", + type: "multipleChoiceSingle", + choices: [ + { id: "choice1", label: { default: "Option A" } }, + { id: "choice2", label: { default: "Option B" } }, + ], + }, + choices: { + "Option A": { value: "Option A", count: 5, percentage: 50, others: [] }, + "Option B": { value: "Option B", count: 5, percentage: 50, others: [] }, + }, + type: "multipleChoiceSingle", + selectionCount: 0, + } as any; + + render( + + ); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); + expect(idBadges[0]).toHaveAttribute("data-id", "choice1"); + expect(idBadges[1]).toHaveAttribute("data-id", "choice2"); + expect(idBadges[0]).toHaveTextContent("ID: choice1"); + expect(idBadges[1]).toHaveTextContent("ID: choice2"); + }); + + test("getChoiceIdByValue function correctly maps values to IDs", () => { + const setFilter = vi.fn(); + const q = { + question: { + id: "q8", + headline: "H8", + type: "multipleChoiceMulti", + choices: [ + { id: "id-apple", label: { default: "Apple" } }, + { id: "id-banana", label: { default: "Banana" } }, + { id: "id-cherry", label: { default: "Cherry" } }, + ], + }, + choices: { + Apple: { value: "Apple", count: 3, percentage: 30, others: [] }, + Banana: { value: "Banana", count: 4, percentage: 40, others: [] }, + Cherry: { value: "Cherry", count: 3, percentage: 30, others: [] }, + }, + type: "multipleChoiceMulti", + selectionCount: 0, + } as any; + + render( + + ); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(3); + + // Check that each badge has the correct ID + const expectedMappings = [ + { text: "Banana", id: "id-banana" }, // Highest count appears first + { text: "Apple", id: "id-apple" }, + { text: "Cherry", id: "id-cherry" }, + ]; + + expectedMappings.forEach(({ text, id }, index) => { + expect(screen.getByText(`${3 - index} - ${text}`)).toBeInTheDocument(); + expect(idBadges[index]).toHaveAttribute("data-id", id); + }); + }); + + test("handles choices with special characters in labels", () => { + const setFilter = vi.fn(); + const q = { + question: { + id: "q9", + headline: "H9", + type: "multipleChoiceSingle", + choices: [ + { id: "special-1", label: { default: "Option & Choice" } }, + { id: "special-2", label: { default: "Choice with 'quotes'" } }, + ], + }, + choices: { + "Option & Choice": { value: "Option & Choice", count: 2, percentage: 50, others: [] }, + "Choice with 'quotes'": { value: "Choice with 'quotes'", count: 2, percentage: 50, others: [] }, + }, + type: "multipleChoiceSingle", + selectionCount: 0, + } as any; + + render( + + ); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); + expect(idBadges[0]).toHaveAttribute("data-id", "special-1"); + expect(idBadges[1]).toHaveAttribute("data-id", "special-2"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx index 846a458b577a..129f03eb1657 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx @@ -1,13 +1,15 @@ "use client"; +import { getChoiceIdByValue } from "@/lib/response/utils"; +import { getContactIdentifier } from "@/lib/utils/contact"; import { PersonAvatar } from "@/modules/ui/components/avatars"; import { Button } from "@/modules/ui/components/button"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import { ProgressBar } from "@/modules/ui/components/progress-bar"; import { useTranslate } from "@tolgee/react"; import { InboxIcon } from "lucide-react"; import Link from "next/link"; -import { useState } from "react"; -import { getContactIdentifier } from "@formbricks/lib/utils/contact"; +import { Fragment, useState } from "react"; import { TI18nString, TSurvey, @@ -45,10 +47,15 @@ export const MultipleChoiceSummary = ({ const otherValue = questionSummary.question.choices.find((choice) => choice.id === "other")?.label.default; // sort by count and transform to array const results = Object.values(questionSummary.choices).sort((a, b) => { - if (a.others) return 1; // Always put a after b if a has 'others' - if (b.others) return -1; // Always put b after a if b has 'others' + const aHasOthers = (a.others?.length ?? 0) > 0; + const bHasOthers = (b.others?.length ?? 0) > 0; - return b.count - a.count; // Sort by count + // if one has “others” and the other doesn’t, push the one with others to the end + if (aHasOthers && !bHasOthers) return 1; + if (!aHasOthers && bHasOthers) return -1; + + // if they’re “tied” on having others, fall back to count + return b.count - a.count; }); const handleLoadMore = (e: React.MouseEvent) => { @@ -79,92 +86,95 @@ export const MultipleChoiceSummary = ({ } />
- {results.map((result, resultsIdx) => ( -
- setFilter( - questionSummary.question.id, - questionSummary.question.headline, - questionSummary.question.type, - questionSummary.type === "multipleChoiceSingle" || otherValue === result.value - ? t("environments.surveys.summary.includes_either") - : t("environments.surveys.summary.includes_all"), - [result.value] - ) - }> -
-
-

- {results.length - resultsIdx} - {result.value} -

-
-

- {convertFloatToNDecimal(result.percentage, 2)}% -

-
-
-

- {result.count} {result.count === 1 ? t("common.selection") : t("common.selections")} -

-
-
- -
- {result.others && result.others.length > 0 && ( -
e.stopPropagation()}> -
-
- {t("environments.surveys.summary.other_values_found")} + {results.map((result, resultsIdx) => { + const choiceId = getChoiceIdByValue(result.value, questionSummary.question); + return ( + + + {result.others && result.others.length > 0 && ( +
+
+
+ {t("environments.surveys.summary.other_values_found")} +
+
{surveyType === "app" && t("common.user")}
+
+ {result.others + .filter((otherValue) => otherValue.value !== "") + .slice(0, visibleOtherResponses) + .map((otherValue, idx) => ( +
+ {surveyType === "link" && ( +
{otherValue.value}
-
- {otherValue.contact.id && } - - {getContactIdentifier(otherValue.contact, otherValue.contactAttributes)} - -
- - )} + )} + {surveyType === "app" && otherValue.contact && ( + +
+ {otherValue.value} +
+
+ {otherValue.contact.id && } + + {getContactIdentifier(otherValue.contact, otherValue.contactAttributes)} + +
+ + )} +
+ ))} + {visibleOtherResponses < result.others.length && ( +
+
- ))} - {visibleOtherResponses < result.others.length && ( -
- -
- )} -
- )} -
- ))} + )} +
+ )} + + ); + })}
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.test.tsx new file mode 100644 index 000000000000..125c4e675447 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.test.tsx @@ -0,0 +1,60 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurveyQuestionSummaryNps } from "@formbricks/types/surveys/types"; +import { NPSSummary } from "./NPSSummary"; + +vi.mock("@/modules/ui/components/progress-bar", () => ({ + ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => ( +
{`${progress}-${barColor}`}
+ ), + HalfCircle: ({ value }: { value: number }) =>
{value}
, +})); +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: () =>
, +})); + +describe("NPSSummary", () => { + afterEach(() => { + cleanup(); + }); + + const baseQuestion = { id: "q1", headline: "Question?", type: "nps" as const }; + const summary = { + question: baseQuestion, + promoters: { count: 2, percentage: 50 }, + passives: { count: 1, percentage: 25 }, + detractors: { count: 1, percentage: 25 }, + dismissed: { count: 0, percentage: 0 }, + score: 25, + } as unknown as TSurveyQuestionSummaryNps; + const survey = {} as any; + + test("renders header, groups, ProgressBar and HalfCircle", () => { + render( {}} />); + expect(screen.getByTestId("question-summary-header")).toBeDefined(); + ["promoters", "passives", "detractors", "dismissed"].forEach((g) => + expect(screen.getByText(g)).toBeDefined() + ); + expect(screen.getAllByTestId("progress-bar")[0]).toBeDefined(); + expect(screen.getByTestId("half-circle")).toHaveTextContent("25"); + }); + + test.each([ + ["promoters", "environments.surveys.summary.includes_either", ["9", "10"]], + ["passives", "environments.surveys.summary.includes_either", ["7", "8"]], + ["detractors", "environments.surveys.summary.is_less_than", "7"], + ["dismissed", "common.skipped", undefined], + ])("clicking %s calls setFilter correctly", async (group, cmp, vals) => { + const setFilter = vi.fn(); + render(); + await userEvent.click(screen.getByText(group)); + expect(setFilter).toHaveBeenCalledWith( + baseQuestion.id, + baseQuestion.headline, + baseQuestion.type, + cmp, + vals + ); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx index dd01c999a420..fc119fef50ab 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx @@ -64,7 +64,10 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
{["promoters", "passives", "detractors", "dismissed"].map((group) => ( -
applyFilter(group)}> + ))}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.test.tsx new file mode 100644 index 000000000000..4f5866387cd4 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.test.tsx @@ -0,0 +1,174 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types"; +import { OpenTextSummary } from "./OpenTextSummary"; + +// Mock dependencies +vi.mock("@/lib/time", () => ({ + timeSince: () => "2 hours ago", +})); + +vi.mock("@/lib/utils/contact", () => ({ + getContactIdentifier: () => "contact@example.com", +})); + +vi.mock("@/modules/analysis/utils", () => ({ + renderHyperlinkedContent: (text: string) =>
{text}
, +})); + +vi.mock("@/modules/ui/components/avatars", () => ({ + PersonAvatar: ({ personId }: { personId: string }) =>
{personId}
, +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => ( + + ), +})); + +vi.mock("@/modules/ui/components/secondary-navigation", () => ({ + SecondaryNavigation: ({ activeId, navigation }: any) => ( +
+ {navigation.map((item: any) => ( + + ))} +
+ ), +})); + +vi.mock("@/modules/ui/components/table", () => ({ + Table: ({ children }: { children: React.ReactNode }) => {children}
, + TableHeader: ({ children }: { children: React.ReactNode }) => {children}, + TableBody: ({ children }: { children: React.ReactNode }) => {children}, + TableRow: ({ children }: { children: React.ReactNode }) => {children}, + TableHead: ({ children }: { children: React.ReactNode }) => {children}, + TableCell: ({ children, width }: { children: React.ReactNode; width?: number }) => ( + {children} + ), +})); + +vi.mock("@/modules/ee/insights/components/insights-view", () => ({ + InsightView: () =>
, +})); + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: ({ additionalInfo }: { additionalInfo?: React.ReactNode }) => ( +
{additionalInfo}
+ ), +})); + +describe("OpenTextSummary", () => { + afterEach(() => { + cleanup(); + }); + + const environmentId = "env-123"; + const survey = { id: "survey-1" } as TSurvey; + const locale = "en-US"; + + test("renders response mode by default when insights not enabled", () => { + const questionSummary = { + question: { id: "q1", headline: "Open Text Question" }, + samples: [ + { + id: "response1", + value: "Sample response text", + updatedAt: new Date().toISOString(), + contact: { id: "contact1" }, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryOpenText; + + render( + + ); + + expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); + expect(screen.getByTestId("table")).toBeInTheDocument(); + expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1"); + expect(screen.getByText("contact@example.com")).toBeInTheDocument(); + expect(screen.getByTestId("hyperlinked-content")).toHaveTextContent("Sample response text"); + expect(screen.getByText("2 hours ago")).toBeInTheDocument(); + + // No secondary navigation when insights not enabled + expect(screen.queryByTestId("secondary-navigation")).not.toBeInTheDocument(); + }); + + test("renders anonymous user when no contact is provided", () => { + const questionSummary = { + question: { id: "q1", headline: "Open Text Question" }, + samples: [ + { + id: "response1", + value: "Anonymous response", + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + }, + ], + } as unknown as TSurveyQuestionSummaryOpenText; + + render( + + ); + + expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous"); + expect(screen.getByText("common.anonymous")).toBeInTheDocument(); + }); + + test("shows load more button when there are more responses and loads more on click", async () => { + const samples = Array.from({ length: 15 }, (_, i) => ({ + id: `response${i}`, + value: `Response ${i}`, + updatedAt: new Date().toISOString(), + contact: null, + contactAttributes: {}, + })); + + const questionSummary = { + question: { id: "q1", headline: "Open Text Question" }, + samples, + } as unknown as TSurveyQuestionSummaryOpenText; + + render( + + ); + + // Initially 10 responses should be visible + expect(screen.getAllByTestId("hyperlinked-content")).toHaveLength(10); + + // "Load More" button should be visible + const loadMoreButton = screen.getByTestId("load-more-button"); + expect(loadMoreButton).toBeInTheDocument(); + + // Click "Load More" + await userEvent.click(loadMoreButton); + + // Now all 15 responses should be visible + expect(screen.getAllByTestId("hyperlinked-content")).toHaveLength(15); + + // "Load More" button should disappear + expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx index 3d97eea0ab58..6465a02ac583 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx @@ -1,16 +1,14 @@ "use client"; +import { timeSince } from "@/lib/time"; +import { getContactIdentifier } from "@/lib/utils/contact"; import { renderHyperlinkedContent } from "@/modules/analysis/utils"; -import { InsightView } from "@/modules/ee/insights/components/insights-view"; import { PersonAvatar } from "@/modules/ui/components/avatars"; import { Button } from "@/modules/ui/components/button"; -import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table"; import { useTranslate } from "@tolgee/react"; import Link from "next/link"; import { useState } from "react"; -import { timeSince } from "@formbricks/lib/time"; -import { getContactIdentifier } from "@formbricks/lib/utils/contact"; import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; @@ -19,25 +17,12 @@ interface OpenTextSummaryProps { questionSummary: TSurveyQuestionSummaryOpenText; environmentId: string; survey: TSurvey; - isAIEnabled: boolean; - documentsPerPage?: number; locale: TUserLocale; } -export const OpenTextSummary = ({ - questionSummary, - environmentId, - survey, - isAIEnabled, - documentsPerPage, - locale, -}: OpenTextSummaryProps) => { +export const OpenTextSummary = ({ questionSummary, environmentId, survey, locale }: OpenTextSummaryProps) => { const { t } = useTranslate(); - const isInsightsEnabled = isAIEnabled && questionSummary.insightsEnabled; const [visibleResponses, setVisibleResponses] = useState(10); - const [activeTab, setActiveTab] = useState<"insights" | "responses">( - isInsightsEnabled && questionSummary.insights.length ? "insights" : "responses" - ); const handleLoadMore = () => { // Increase the number of visible responses by 10, not exceeding the total number of responses @@ -46,104 +31,62 @@ export const OpenTextSummary = ({ ); }; - const tabNavigation = [ - { - id: "insights", - label: t("common.insights"), - onClick: () => setActiveTab("insights"), - }, - { - id: "responses", - label: t("common.responses"), - onClick: () => setActiveTab("responses"), - }, - ]; - return (
- -
- {t("environments.surveys.summary.insights_disabled")} -
-
- ) : undefined - } - /> - {isInsightsEnabled && ( -
- -
- )} +
- {activeTab === "insights" ? ( - - ) : activeTab === "responses" ? ( - <> - - - - {t("common.user")} - {t("common.response")} - {t("common.time")} - - - - {questionSummary.samples.slice(0, visibleResponses).map((response) => ( - - - {response.contact ? ( - -
- -
-

- {getContactIdentifier(response.contact, response.contactAttributes)} -

- - ) : ( -
-
- -
-

{t("common.anonymous")}

-
- )} -
- - {typeof response.value === "string" - ? renderHyperlinkedContent(response.value) - : response.value} - - - {timeSince(new Date(response.updatedAt).toISOString(), locale)} - -
- ))} -
-
- {visibleResponses < questionSummary.samples.length && ( -
- -
- )} - - ) : null} + + + + {t("common.user")} + {t("common.response")} + {t("common.time")} + + + + {questionSummary.samples.slice(0, visibleResponses).map((response) => ( + + + {response.contact ? ( + +
+ +
+

+ {getContactIdentifier(response.contact, response.contactAttributes)} +

+ + ) : ( +
+
+ +
+

{t("common.anonymous")}

+
+ )} +
+ + {typeof response.value === "string" + ? renderHyperlinkedContent(response.value) + : response.value} + + + {timeSince(new Date(response.updatedAt).toISOString(), locale)} + +
+ ))} +
+
+ {visibleResponses < questionSummary.samples.length && ( +
+ +
+ )}
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.test.tsx new file mode 100644 index 000000000000..2392285462e1 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.test.tsx @@ -0,0 +1,177 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + TSurvey, + TSurveyPictureSelectionQuestion, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; +import { PictureChoiceSummary } from "./PictureChoiceSummary"; + +vi.mock("@/modules/ui/components/progress-bar", () => ({ + ProgressBar: ({ progress }: { progress: number }) => ( +
+ ), +})); +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: ({ additionalInfo }: any) =>
{additionalInfo}
, +})); +vi.mock("@/modules/ui/components/id-badge", () => ({ + IdBadge: ({ id }: { id: string }) => ( +
+ ID: {id} +
+ ), +})); + +vi.mock("@/lib/response/utils", () => ({ + getChoiceIdByValue: (value: string, question: TSurveyPictureSelectionQuestion) => { + return question.choices?.find((choice) => choice.imageUrl === value)?.id ?? "other"; + }, +})); + +// mock next image +vi.mock("next/image", () => ({ + __esModule: true, + // eslint-disable-next-line @next/next/no-img-element + default: ({ src }: { src: string }) => , +})); + +const survey = {} as TSurvey; + +describe("PictureChoiceSummary", () => { + afterEach(() => { + cleanup(); + }); + + test("renders choices with formatted percentages and counts", () => { + const choices = [ + { id: "1", imageUrl: "img1.png", percentage: 33.3333, count: 1 }, + { id: "2", imageUrl: "img2.png", percentage: 66.6667, count: 2 }, + ]; + const questionSummary = { + choices, + question: { id: "q1", type: TSurveyQuestionTypeEnum.PictureSelection, headline: "H", allowMulti: true }, + selectionCount: 3, + } as any; + render( {}} />); + + expect(screen.getAllByRole("button")).toHaveLength(2); + expect(screen.getByText("33.33%")).toBeInTheDocument(); + expect(screen.getByText("1 common.selection")).toBeInTheDocument(); + expect(screen.getByText("2 common.selections")).toBeInTheDocument(); + expect(screen.getAllByTestId("progress-bar")).toHaveLength(2); + }); + + test("calls setFilter with correct args on click", async () => { + const choices = [{ id: "1", imageUrl: "img1.png", percentage: 25, count: 10 }]; + const questionSummary = { + choices, + question: { + id: "q1", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: "H1", + allowMulti: true, + }, + selectionCount: 10, + } as any; + const setFilter = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button")); + expect(setFilter).toHaveBeenCalledWith( + "q1", + "H1", + TSurveyQuestionTypeEnum.PictureSelection, + "environments.surveys.summary.includes_all", + ["environments.surveys.edit.picture_idx"] + ); + }); + + test("hides additionalInfo when allowMulti is false", () => { + const choices = [{ id: "1", imageUrl: "img1.png", percentage: 50, count: 5 }]; + const questionSummary = { + choices, + question: { + id: "q1", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: "H2", + allowMulti: false, + }, + selectionCount: 5, + } as any; + render( {}} />); + + expect(screen.getByTestId("header")).toBeEmptyDOMElement(); + }); + + // New tests for IdBadge functionality + test("renders IdBadge when choice ID is found via imageUrl", () => { + const choices = [ + { id: "choice1", imageUrl: "https://example.com/img1.png", percentage: 50, count: 5 }, + { id: "choice2", imageUrl: "https://example.com/img2.png", percentage: 50, count: 5 }, + ]; + const questionSummary = { + choices, + question: { + id: "q2", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: "Picture Question", + allowMulti: true, + choices: [ + { id: "pic-choice-1", imageUrl: "https://example.com/img1.png" }, + { id: "pic-choice-2", imageUrl: "https://example.com/img2.png" }, + ], + }, + selectionCount: 10, + } as any; + + render( {}} />); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); + expect(idBadges[0]).toHaveAttribute("data-id", "pic-choice-1"); + expect(idBadges[1]).toHaveAttribute("data-id", "pic-choice-2"); + expect(idBadges[0]).toHaveTextContent("ID: pic-choice-1"); + expect(idBadges[1]).toHaveTextContent("ID: pic-choice-2"); + }); + + test("getChoiceIdByValue function correctly maps imageUrl to choice ID", () => { + const choices = [ + { id: "choice1", imageUrl: "https://cdn.example.com/photo1.jpg", percentage: 33.33, count: 2 }, + { id: "choice2", imageUrl: "https://cdn.example.com/photo2.jpg", percentage: 33.33, count: 2 }, + { id: "choice3", imageUrl: "https://cdn.example.com/photo3.jpg", percentage: 33.33, count: 2 }, + ]; + const questionSummary = { + choices, + question: { + id: "q4", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: "Photo Selection", + allowMulti: true, + choices: [ + { id: "photo-a", imageUrl: "https://cdn.example.com/photo1.jpg" }, + { id: "photo-b", imageUrl: "https://cdn.example.com/photo2.jpg" }, + { id: "photo-c", imageUrl: "https://cdn.example.com/photo3.jpg" }, + ], + }, + selectionCount: 6, + } as any; + + render( {}} />); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(3); + expect(idBadges[0]).toHaveAttribute("data-id", "photo-a"); + expect(idBadges[1]).toHaveAttribute("data-id", "photo-b"); + expect(idBadges[2]).toHaveAttribute("data-id", "photo-c"); + + // Verify the images are also rendered correctly + const images = screen.getAllByRole("img"); + expect(images).toHaveLength(3); + expect(images[0]).toHaveAttribute("src", "https://cdn.example.com/photo1.jpg"); + expect(images[1]).toHaveAttribute("src", "https://cdn.example.com/photo2.jpg"); + expect(images[2]).toHaveAttribute("src", "https://cdn.example.com/photo3.jpg"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx index a942d1c2dd86..2e225714e496 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx @@ -1,5 +1,7 @@ "use client"; +import { getChoiceIdByValue } from "@/lib/response/utils"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import { ProgressBar } from "@/modules/ui/components/progress-bar"; import { useTranslate } from "@tolgee/react"; import { InboxIcon } from "lucide-react"; @@ -29,6 +31,7 @@ interface PictureChoiceSummaryProps { export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: PictureChoiceSummaryProps) => { const results = questionSummary.choices; const { t } = useTranslate(); + return (
- {results.map((result, index) => ( -
- setFilter( - questionSummary.question.id, - questionSummary.question.headline, - questionSummary.question.type, - t("environments.surveys.summary.includes_all"), - [`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`] - ) - }> -
-
-
- choice-image + {results.map((result, index) => { + const choiceId = getChoiceIdByValue(result.imageUrl, questionSummary.question); + return ( +
- ))} + + + ); + })}
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader.test.tsx new file mode 100644 index 000000000000..0a002be0ba4b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader.test.tsx @@ -0,0 +1,164 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummary, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; + +// Mock dependencies +vi.mock("@/lib/utils/recall", () => ({ + recallToHeadline: () => ({ default: "Recalled Headline" }), +})); + +vi.mock("@/modules/survey/editor/lib/utils", () => ({ + formatTextWithSlashes: (text: string) => {text}, +})); + +vi.mock("@/modules/survey/lib/questions", () => ({ + getQuestionTypes: () => [ + { + id: "openText", + label: "Open Text", + icon: () =>
Icon
, + }, + { + id: "multipleChoice", + label: "Multiple Choice", + icon: () =>
Icon
, + }, + ], +})); + +vi.mock("@/modules/ui/components/id-badge", () => ({ + IdBadge: ({ label, id }: { label: string; id: string }) => ( +
+ {label}: {id} +
+ ), +})); + +// Mock InboxIcon +vi.mock("lucide-react", () => ({ + InboxIcon: () =>
, +})); + +describe("QuestionSummaryHeader", () => { + afterEach(() => { + cleanup(); + }); + + const survey = {} as TSurvey; + + test("renders header with question headline and type", () => { + const questionSummary = { + question: { + id: "q1", + headline: { default: "Test Question" }, + type: "openText" as TSurveyQuestionTypeEnum, + required: true, + }, + responseCount: 42, + } as unknown as TSurveyQuestionSummary; + + render(); + + expect(screen.getByTestId("formatted-headline")).toHaveTextContent("Recalled Headline"); + + // Look for text content with a more specific approach + const questionTypeElement = screen.getByText((content) => { + return content.includes("Open Text") && !content.includes("common.question_id"); + }); + expect(questionTypeElement).toBeInTheDocument(); + + // Check for responses text specifically + expect( + screen.getByText((content) => { + return content.includes("42") && content.includes("common.responses"); + }) + ).toBeInTheDocument(); + + expect(screen.getByTestId("question-icon")).toBeInTheDocument(); + expect(screen.getByTestId("id-badge")).toHaveTextContent("common.question_id: q1"); + expect(screen.queryByText("environments.surveys.edit.optional")).not.toBeInTheDocument(); + }); + + test("shows 'optional' tag when question is not required", () => { + const questionSummary = { + question: { + id: "q2", + headline: { default: "Optional Question" }, + type: "multipleChoice" as TSurveyQuestionTypeEnum, + required: false, + }, + responseCount: 10, + } as unknown as TSurveyQuestionSummary; + + render(); + + expect(screen.getByText("environments.surveys.edit.optional")).toBeInTheDocument(); + }); + + test("hides response count when showResponses is false", () => { + const questionSummary = { + question: { + id: "q3", + headline: { default: "No Response Count Question" }, + type: "openText" as TSurveyQuestionTypeEnum, + required: true, + }, + responseCount: 15, + } as unknown as TSurveyQuestionSummary; + + render(); + + expect( + screen.queryByText((content) => content.includes("15") && content.includes("common.responses")) + ).not.toBeInTheDocument(); + }); + + test("shows unknown question type for unrecognized type", () => { + const questionSummary = { + question: { + id: "q4", + headline: { default: "Unknown Type Question" }, + type: "unknownType" as TSurveyQuestionTypeEnum, + required: true, + }, + responseCount: 5, + } as unknown as TSurveyQuestionSummary; + + render(); + + // Look for text in the question type element specifically + const unknownTypeElement = screen.getByText((content) => { + return ( + content.includes("environments.surveys.summary.unknown_question_type") && + !content.includes("common.question_id") + ); + }); + expect(unknownTypeElement).toBeInTheDocument(); + }); + + test("renders additional info when provided", () => { + const questionSummary = { + question: { + id: "q5", + headline: { default: "With Additional Info" }, + type: "openText" as TSurveyQuestionTypeEnum, + required: true, + }, + responseCount: 20, + } as unknown as TSurveyQuestionSummary; + + const additionalInfo =
Extra Information
; + + render( + + ); + + expect(screen.getByTestId("additional-info")).toBeInTheDocument(); + expect(screen.getByText("Extra Information")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader.tsx index 2b6adca6d351..0d384b8e8199 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader.tsx @@ -1,10 +1,12 @@ "use client"; +import { recallToHeadline } from "@/lib/utils/recall"; +import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils"; import { getQuestionTypes } from "@/modules/survey/lib/questions"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import { useTranslate } from "@tolgee/react"; import { InboxIcon } from "lucide-react"; import type { JSX } from "react"; -import { recallToHeadline } from "@formbricks/lib/utils/recall"; import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types"; interface HeadProps { @@ -22,31 +24,15 @@ export const QuestionSummaryHeader = ({ }: HeadProps) => { const { t } = useTranslate(); const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type); - // formats the text to highlight specific parts of the text with slashes - const formatTextWithSlashes = (text: string): (string | JSX.Element)[] => { - const regex = /\/(.*?)\\/g; - const parts = text.split(regex); - - return parts.map((part, index) => { - // Check if the part was inside slashes - if (index % 2 !== 0) { - return ( - - @{part} - - ); - } else { - return part; - } - }); - }; return (

{formatTextWithSlashes( - recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"] + recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"], + "@", + ["text-lg"] )}

@@ -69,6 +55,7 @@ export const QuestionSummaryHeader = ({
)}
+
); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.test.tsx new file mode 100644 index 000000000000..a93c27c15e9a --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.test.tsx @@ -0,0 +1,213 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types"; +import { RankingSummary } from "./RankingSummary"; + +// Mock dependencies +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: () =>
, +})); + +vi.mock("../lib/utils", () => ({ + convertFloatToNDecimal: (value: number) => value.toFixed(2), +})); + +vi.mock("@/modules/ui/components/id-badge", () => ({ + IdBadge: ({ id }: { id: string }) => ( +
+ ID: {id} +
+ ), +})); + +describe("RankingSummary", () => { + afterEach(() => { + cleanup(); + }); + + const survey = {} as TSurvey; + + test("renders ranking results in correct order", () => { + const questionSummary = { + question: { + id: "q1", + headline: "Rank the following", + choices: [ + { id: "choice1", label: { default: "Option A" } }, + { id: "choice2", label: { default: "Option B" } }, + { id: "choice3", label: { default: "Option C" } }, + ], + }, + choices: { + option1: { value: "Option A", avgRanking: 1.5, others: [] }, + option2: { value: "Option B", avgRanking: 2.3, others: [] }, + option3: { value: "Option C", avgRanking: 1.2, others: [] }, + }, + } as unknown as TSurveyQuestionSummaryRanking; + + render(); + + expect(screen.getByTestId("question-summary-header")).toBeInTheDocument(); + + // Check order: should be sorted by avgRanking (ascending) + const options = screen.getAllByText(/Option [A-C]/); + expect(options[0]).toHaveTextContent("Option C"); // 1.2 (lowest avgRanking first) + expect(options[1]).toHaveTextContent("Option A"); // 1.5 + expect(options[2]).toHaveTextContent("Option B"); // 2.3 + + // Check rankings are displayed + expect(screen.getByText("#1")).toBeInTheDocument(); + expect(screen.getByText("#2")).toBeInTheDocument(); + expect(screen.getByText("#3")).toBeInTheDocument(); + + // Check average values are displayed + expect(screen.getByText("#1.20")).toBeInTheDocument(); + expect(screen.getByText("#1.50")).toBeInTheDocument(); + expect(screen.getByText("#2.30")).toBeInTheDocument(); + }); + + test("doesn't show 'User' column for link survey type", () => { + const questionSummary = { + question: { + id: "q1", + headline: "Rank the following", + choices: [{ id: "choice1", label: { default: "Option A" } }], + }, + choices: { + option1: { + value: "Option A", + avgRanking: 1.0, + others: [{ value: "Other value", count: 1 }], + }, + }, + } as unknown as TSurveyQuestionSummaryRanking; + + render(); + + expect(screen.queryByText("common.user")).not.toBeInTheDocument(); + }); + + // New tests for IdBadge functionality + test("renders IdBadge when choice ID is found via label", () => { + const questionSummary = { + question: { + id: "q2", + headline: "Rank these options", + choices: [ + { id: "rank-choice-1", label: { default: "First Option" } }, + { id: "rank-choice-2", label: { default: "Second Option" } }, + { id: "rank-choice-3", label: { default: "Third Option" } }, + ], + }, + choices: { + option1: { value: "First Option", avgRanking: 1.5, others: [] }, + option2: { value: "Second Option", avgRanking: 2.1, others: [] }, + option3: { value: "Third Option", avgRanking: 2.8, others: [] }, + }, + } as unknown as TSurveyQuestionSummaryRanking; + + render(); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(3); + expect(idBadges[0]).toHaveAttribute("data-id", "rank-choice-1"); + expect(idBadges[1]).toHaveAttribute("data-id", "rank-choice-2"); + expect(idBadges[2]).toHaveAttribute("data-id", "rank-choice-3"); + expect(idBadges[0]).toHaveTextContent("ID: rank-choice-1"); + expect(idBadges[1]).toHaveTextContent("ID: rank-choice-2"); + expect(idBadges[2]).toHaveTextContent("ID: rank-choice-3"); + }); + + test("getChoiceIdByValue function correctly maps ranking values to choice IDs", () => { + const questionSummary = { + question: { + id: "q4", + headline: "Rate importance", + choices: [ + { id: "importance-high", label: { default: "Very Important" } }, + { id: "importance-medium", label: { default: "Somewhat Important" } }, + { id: "importance-low", label: { default: "Not Important" } }, + ], + }, + choices: { + option1: { value: "Very Important", avgRanking: 1.2, others: [] }, + option2: { value: "Somewhat Important", avgRanking: 2.0, others: [] }, + option3: { value: "Not Important", avgRanking: 2.8, others: [] }, + }, + } as unknown as TSurveyQuestionSummaryRanking; + + render(); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(3); + + // Should be ordered by avgRanking (ascending) + expect(screen.getByText("Very Important")).toBeInTheDocument(); // avgRanking: 1.2 + expect(screen.getByText("Somewhat Important")).toBeInTheDocument(); // avgRanking: 2.0 + expect(screen.getByText("Not Important")).toBeInTheDocument(); // avgRanking: 2.8 + + expect(idBadges[0]).toHaveAttribute("data-id", "importance-high"); + expect(idBadges[1]).toHaveAttribute("data-id", "importance-medium"); + expect(idBadges[2]).toHaveAttribute("data-id", "importance-low"); + }); + + test("handles mixed choices with and without matching IDs", () => { + const questionSummary = { + question: { + id: "q5", + headline: "Mixed options", + choices: [ + { id: "valid-choice-1", label: { default: "Valid Option" } }, + { id: "valid-choice-2", label: { default: "Another Valid Option" } }, + ], + }, + choices: { + option1: { value: "Valid Option", avgRanking: 1.5, others: [] }, + option2: { value: "Unknown Option", avgRanking: 2.0, others: [] }, + option3: { value: "Another Valid Option", avgRanking: 2.5, others: [] }, + }, + } as unknown as TSurveyQuestionSummaryRanking; + + render(); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(3); // Only 2 out of 3 should have badges + + // Check that all options are still displayed + expect(screen.getByText("Valid Option")).toBeInTheDocument(); + expect(screen.getByText("Unknown Option")).toBeInTheDocument(); + expect(screen.getByText("Another Valid Option")).toBeInTheDocument(); + + // Check that only the valid choices have badges + expect(idBadges[0]).toHaveAttribute("data-id", "valid-choice-1"); + expect(idBadges[1]).toHaveAttribute("data-id", "other"); + expect(idBadges[2]).toHaveAttribute("data-id", "valid-choice-2"); + }); + + test("handles special characters in choice labels", () => { + const questionSummary = { + question: { + id: "q6", + headline: "Special characters test", + choices: [ + { id: "special-1", label: { default: "Option with 'quotes'" } }, + { id: "special-2", label: { default: "Option & Ampersand" } }, + ], + }, + choices: { + option1: { value: "Option with 'quotes'", avgRanking: 1.0, others: [] }, + option2: { value: "Option & Ampersand", avgRanking: 2.0, others: [] }, + }, + } as unknown as TSurveyQuestionSummaryRanking; + + render(); + + const idBadges = screen.getAllByTestId("id-badge"); + expect(idBadges).toHaveLength(2); + expect(idBadges[0]).toHaveAttribute("data-id", "special-1"); + expect(idBadges[1]).toHaveAttribute("data-id", "special-2"); + + expect(screen.getByText("Option with 'quotes'")).toBeInTheDocument(); + expect(screen.getByText("Option & Ampersand")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.tsx index 7f70556ef9a7..95e0106eedc1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary.tsx @@ -1,15 +1,16 @@ +import { getChoiceIdByValue } from "@/lib/response/utils"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import { useTranslate } from "@tolgee/react"; -import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types"; +import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types"; import { convertFloatToNDecimal } from "../lib/utils"; import { QuestionSummaryHeader } from "./QuestionSummaryHeader"; interface RankingSummaryProps { questionSummary: TSurveyQuestionSummaryRanking; - surveyType: TSurveyType; survey: TSurvey; } -export const RankingSummary = ({ questionSummary, surveyType, survey }: RankingSummaryProps) => { +export const RankingSummary = ({ questionSummary, survey }: RankingSummaryProps) => { // sort by count and transform to array const { t } = useTranslate(); const results = Object.values(questionSummary.choices).sort((a, b) => { @@ -20,35 +21,30 @@ export const RankingSummary = ({ questionSummary, surveyType, survey }: RankingS
- {results.map((result, resultsIdx) => ( -
-
-
-
- #{resultsIdx + 1} -
{result.value}
- - - #{convertFloatToNDecimal(result.avgRanking, 2)} + {results.map((result, resultsIdx) => { + const choiceId = getChoiceIdByValue(result.value, questionSummary.question); + return ( +
+
+
+
+
+ #{resultsIdx + 1} +
{result.value}
+ {choiceId && } +
+ + + #{convertFloatToNDecimal(result.avgRanking, 2)} + + {t("environments.surveys.summary.average")} - {t("environments.surveys.summary.average")} - -
-
-
- - {result.others && result.others.length > 0 && ( -
-
-
- {t("environments.surveys.summary.other_values_found")}
-
{surveyType === "app" && t("common.user")}
- )} -
- ))} +
+ ); + })}
); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.test.tsx new file mode 100644 index 000000000000..da1e77641c73 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.test.tsx @@ -0,0 +1,87 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurveyQuestionSummaryRating } from "@formbricks/types/surveys/types"; +import { RatingSummary } from "./RatingSummary"; + +vi.mock("./QuestionSummaryHeader", () => ({ + QuestionSummaryHeader: ({ additionalInfo }: any) =>
{additionalInfo}
, +})); + +describe("RatingSummary", () => { + afterEach(() => { + cleanup(); + }); + + test("renders overall average and choices", () => { + const questionSummary = { + question: { + id: "q1", + scale: "star", + headline: "Headline", + type: "rating", + range: [1, 5], + isColorCodingEnabled: false, + }, + average: 3.1415, + choices: [ + { rating: 1, percentage: 50, count: 2 }, + { rating: 2, percentage: 50, count: 3 }, + ], + dismissed: { count: 0 }, + } as unknown as TSurveyQuestionSummaryRating; + const survey = {}; + const setFilter = vi.fn(); + render(); + expect(screen.getByText("environments.surveys.summary.overall: 3.14")).toBeDefined(); + expect(screen.getAllByRole("button")).toHaveLength(2); + }); + + test("clicking a choice calls setFilter with correct args", async () => { + const questionSummary = { + question: { + id: "q1", + scale: "number", + headline: "Headline", + type: "rating", + range: [1, 5], + isColorCodingEnabled: false, + }, + average: 2, + choices: [{ rating: 3, percentage: 100, count: 1 }], + dismissed: { count: 0 }, + } as unknown as TSurveyQuestionSummaryRating; + const survey = {}; + const setFilter = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button")); + expect(setFilter).toHaveBeenCalledWith( + "q1", + "Headline", + "rating", + "environments.surveys.summary.is_equal_to", + "3" + ); + }); + + test("renders dismissed section when dismissed count > 0", () => { + const questionSummary = { + question: { + id: "q1", + scale: "smiley", + headline: "Headline", + type: "rating", + range: [1, 5], + isColorCodingEnabled: false, + }, + average: 4, + choices: [], + dismissed: { count: 1 }, + } as unknown as TSurveyQuestionSummaryRating; + const survey = {}; + const setFilter = vi.fn(); + render(); + expect(screen.getByText("common.dismissed")).toBeDefined(); + expect(screen.getByText("1 common.response")).toBeDefined(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx index d2de76387de5..675c4f703f41 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx @@ -52,8 +52,8 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm />
{questionSummary.choices.map((result) => ( -
setFilter( @@ -85,7 +85,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm

-
+ ))}
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop.test.tsx new file mode 100644 index 000000000000..e3e7f8c3dcde --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop.test.tsx @@ -0,0 +1,67 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import ScrollToTop from "./ScrollToTop"; + +const containerId = "test-container"; + +describe("ScrollToTop", () => { + let mockContainer: HTMLElement; + + beforeEach(() => { + mockContainer = document.createElement("div"); + mockContainer.id = containerId; + mockContainer.scrollTop = 0; + mockContainer.scrollTo = vi.fn(); + mockContainer.addEventListener = vi.fn(); + mockContainer.removeEventListener = vi.fn(); + vi.spyOn(document, "getElementById").mockReturnValue(mockContainer); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + test("renders hidden initially", () => { + render(); + const button = screen.getByRole("button"); + expect(button).toHaveClass("opacity-0"); + }); + + test("calls scrollTo on button click", async () => { + render(); + const button = screen.getByRole("button"); + + // Make button visible + mockContainer.scrollTop = 301; + const scrollEvent = new Event("scroll"); + mockContainer.dispatchEvent(scrollEvent); + + await userEvent.click(button); + expect(mockContainer.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: "smooth" }); + }); + + test("does nothing if container is not found", () => { + vi.spyOn(document, "getElementById").mockReturnValue(null); + render(); + const button = screen.getByRole("button"); + expect(button).toHaveClass("opacity-0"); // Stays hidden + + // Try to simulate scroll (though no listener would be attached) + fireEvent.scroll(window, { target: { scrollY: 400 } }); + expect(button).toHaveClass("opacity-0"); + + // Try to click + userEvent.click(button); + // No error should occur, and scrollTo should not be called on a null element + }); + + test("removes event listener on unmount", () => { + const { unmount } = render(); + expect(mockContainer.addEventListener).toHaveBeenCalledWith("scroll", expect.any(Function)); + + unmount(); + expect(mockContainer.removeEventListener).toHaveBeenCalledWith("scroll", expect.any(Function)); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx deleted file mode 100644 index 7dcfb7bc1b67..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx +++ /dev/null @@ -1,172 +0,0 @@ -"use client"; - -import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink"; -import { Badge } from "@/modules/ui/components/badge"; -import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/modules/ui/components/dialog"; -import { useTranslate } from "@tolgee/react"; -import { - BellRing, - BlocksIcon, - Code2Icon, - LinkIcon, - MailIcon, - SmartphoneIcon, - UsersRound, -} from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; -import { TSurvey } from "@formbricks/types/surveys/types"; -import { TUser } from "@formbricks/types/user"; -import { EmbedView } from "./shareEmbedModal/EmbedView"; -import { PanelInfoView } from "./shareEmbedModal/PanelInfoView"; - -interface ShareEmbedSurveyProps { - survey: TSurvey; - surveyDomain: string; - open: boolean; - modalView: "start" | "embed" | "panel"; - setOpen: React.Dispatch>; - user: TUser; -} - -export const ShareEmbedSurvey = ({ - survey, - surveyDomain, - open, - modalView, - setOpen, - user, -}: ShareEmbedSurveyProps) => { - const router = useRouter(); - const environmentId = survey.environmentId; - const isSingleUseLinkSurvey = survey.singleUse?.enabled ?? false; - const { email } = user; - const { t } = useTranslate(); - const tabs = useMemo( - () => - [ - { id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon }, - { id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon }, - { - id: "link", - label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`, - icon: LinkIcon, - }, - { id: "app", label: t("environments.surveys.summary.embed_in_app"), icon: SmartphoneIcon }, - ].filter((tab) => !(survey.type === "link" && tab.id === "app")), - [t, isSingleUseLinkSurvey, survey.type] - ); - - const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id); - const [showView, setShowView] = useState<"start" | "embed" | "panel">("start"); - const [surveyUrl, setSurveyUrl] = useState(""); - - useEffect(() => { - if (survey.type !== "link") { - setActiveId(tabs[3].id); - } - }, [survey.type, tabs]); - - useEffect(() => { - if (open) { - setShowView(modalView); - } else { - setShowView("start"); - } - }, [open, modalView]); - - const handleOpenChange = (open: boolean) => { - setActiveId(survey.type === "link" ? tabs[0].id : tabs[3].id); - setOpen(open); - if (!open) { - setShowView("start"); - } - router.refresh(); - }; - - const handleInitialPageButton = () => { - setOpen(false); - }; - - return ( - - - - {showView === "start" ? ( -
-
- -

- {t("environments.surveys.summary.your_survey_is_public")} 🎉 -

-
- - -
-
-

{t("environments.surveys.summary.whats_next")}

-
- - - - {t("environments.surveys.summary.configure_alerts")} - - - - {t("environments.surveys.summary.setup_integrations")} - - -
-
-
- ) : showView === "embed" ? ( - - ) : showView === "panel" ? ( - - ) : null} -
-
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.tsx deleted file mode 100644 index bcba47a083b0..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults.tsx +++ /dev/null @@ -1,95 +0,0 @@ -"use client"; - -import { Button } from "@/modules/ui/components/button"; -import { Modal } from "@/modules/ui/components/modal"; -import { useTranslate } from "@tolgee/react"; -import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react"; -import { Clipboard } from "lucide-react"; -import Link from "next/link"; -import { toast } from "react-hot-toast"; - -interface ShareEmbedSurveyProps { - open: boolean; - setOpen: React.Dispatch>; - handlePublish: () => void; - handleUnpublish: () => void; - showPublishModal: boolean; - surveyUrl: string; -} -export const ShareSurveyResults = ({ - open, - setOpen, - handlePublish, - handleUnpublish, - showPublishModal, - surveyUrl, -}: ShareEmbedSurveyProps) => { - const { t } = useTranslate(); - return ( - - {showPublishModal && surveyUrl ? ( -
-
- -
-

- {t("environments.surveys.summary.survey_results_are_public")} -

-

- {t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")} -

-
-
-
- {surveyUrl} -
- -
-
- - -
-
-
- ) : ( -
-
- -
-

- {t("environments.surveys.summary.publish_to_web_warning")} -

-

- {t("environments.surveys.summary.publish_to_web_warning_description")} -

-
- -
-
- )} -
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.test.tsx new file mode 100644 index 000000000000..6baa205ea21f --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.test.tsx @@ -0,0 +1,184 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { useSearchParams } from "next/navigation"; +import toast from "react-hot-toast"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TLanguage } from "@formbricks/types/project"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { SuccessMessage } from "./SuccessMessage"; + +// Mock Confetti +vi.mock("@/modules/ui/components/confetti", () => ({ + Confetti: vi.fn(() =>
), +})); + +// Mock useSearchParams from next/navigation +vi.mock("next/navigation", () => ({ + useSearchParams: vi.fn(), + usePathname: vi.fn(() => "/"), // Default mock for usePathname if ever needed by underlying logic + useRouter: vi.fn(() => ({ push: vi.fn() })), // Default mock for useRouter +})); + +// Mock react-hot-toast +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + }, +})); + +const mockReplaceState = vi.fn(); + +describe("SuccessMessage", () => { + let mockUrlSearchParamsGet: ReturnType; + + const mockEnvironmentBase = { + id: "env1", + createdAt: new Date(), + updatedAt: new Date(), + type: "development", + appSetupCompleted: false, + } as unknown as TEnvironment; + + const mockSurveyBase = { + id: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + type: "app", + environmentId: "env1", + status: "draft", + questions: [], + displayOption: "displayOnce", + recontactDays: null, + autoClose: null, + delay: 0, + autoComplete: null, + runOnDate: null, + closeOnDate: null, + welcomeCard: { + enabled: false, + headline: { default: "" }, + html: { default: "" }, + } as unknown as TSurvey["welcomeCard"], + triggers: [], + languages: [ + { + default: true, + enabled: true, + language: { id: "lang1", code: "en", alias: null } as unknown as TLanguage, + }, + ], + segment: null, + singleUse: null, + styling: null, + surveyClosedMessage: null, + hiddenFields: { enabled: false, fieldIds: [] }, + variables: [], + displayPercentage: null, + } as unknown as TSurvey; + + beforeEach(() => { + vi.clearAllMocks(); // Clears mock calls, instances, contexts and results + mockUrlSearchParamsGet = vi.fn(); + vi.mocked(useSearchParams).mockReturnValue({ + get: mockUrlSearchParamsGet, + } as any); + + Object.defineProperty(window, "location", { + value: new URL("http://localhost/somepath"), + writable: true, + }); + + Object.defineProperty(window, "history", { + value: { + replaceState: mockReplaceState, + pushState: vi.fn(), + go: vi.fn(), + }, + writable: true, + }); + mockReplaceState.mockClear(); // Ensure replaceState mock is clean for each test + }); + + afterEach(() => { + cleanup(); + }); + + test("should show 'almost_there' toast and confetti for app survey with widget not setup when success param is present", async () => { + mockUrlSearchParamsGet.mockImplementation((param) => (param === "success" ? "true" : null)); + const environment: TEnvironment = { ...mockEnvironmentBase, appSetupCompleted: false }; + const survey: TSurvey = { ...mockSurveyBase, type: "app" }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId("confetti-mock")).toBeInTheDocument(); + }); + + expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.almost_there", { + id: "survey-publish-success-toast", + icon: "🤏", + duration: 5000, + position: "bottom-right", + }); + + expect(mockReplaceState).toHaveBeenCalledWith({}, "", "http://localhost/somepath"); + }); + + test("should show 'congrats' toast and confetti for app survey with widget setup when success param is present", async () => { + mockUrlSearchParamsGet.mockImplementation((param) => (param === "success" ? "true" : null)); + const environment: TEnvironment = { ...mockEnvironmentBase, appSetupCompleted: true }; + const survey: TSurvey = { ...mockSurveyBase, type: "app" }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId("confetti-mock")).toBeInTheDocument(); + }); + + expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.congrats", { + id: "survey-publish-success-toast", + icon: "🎉", + duration: 5000, + position: "bottom-right", + }); + expect(mockReplaceState).toHaveBeenCalledWith({}, "", "http://localhost/somepath"); + }); + + test("should show 'congrats' toast, confetti, and update URL for link survey when success param is present", async () => { + mockUrlSearchParamsGet.mockImplementation((param) => (param === "success" ? "true" : null)); + const environment: TEnvironment = { ...mockEnvironmentBase }; + const survey: TSurvey = { ...mockSurveyBase, type: "link" }; + + Object.defineProperty(window, "location", { + value: new URL("http://localhost/somepath?success=true"), // initial URL with success + writable: true, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("confetti-mock")).toBeInTheDocument(); + }); + + expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.congrats", { + id: "survey-publish-success-toast", + icon: "🎉", + duration: 5000, + position: "bottom-right", + }); + expect(mockReplaceState).toHaveBeenCalledWith({}, "", "http://localhost/somepath?share=true"); + }); + + test("should not show confetti or toast if success param is not present", () => { + mockUrlSearchParamsGet.mockImplementation((param) => null); + const environment: TEnvironment = { ...mockEnvironmentBase }; + const survey: TSurvey = { ...mockSurveyBase, type: "app" }; + + render(); + + expect(screen.queryByTestId("confetti-mock")).not.toBeInTheDocument(); + expect(toast.success).not.toHaveBeenCalled(); + expect(mockReplaceState).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.test.tsx new file mode 100644 index 000000000000..ec6704196060 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.test.tsx @@ -0,0 +1,127 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionTypeEnum, TSurveySummary } from "@formbricks/types/surveys/types"; +import { SummaryDropOffs } from "./SummaryDropOffs"; + +// Mock dependencies +vi.mock("@/lib/utils/recall", () => ({ + recallToHeadline: () => ({ default: "Recalled Question" }), +})); + +vi.mock("@/modules/survey/editor/lib/utils", () => ({ + formatTextWithSlashes: (text) => {text}, +})); + +vi.mock("@/modules/survey/lib/questions", () => ({ + getQuestionIcon: () => () =>
, +})); + +vi.mock("@/modules/ui/components/tooltip", () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, + Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("lucide-react", () => ({ + TimerIcon: () =>
, +})); + +describe("SummaryDropOffs", () => { + afterEach(() => { + cleanup(); + }); + + const mockSurvey = {} as TSurvey; + const mockDropOff: TSurveySummary["dropOff"] = [ + { + questionId: "q1", + headline: "First Question", + questionType: TSurveyQuestionTypeEnum.OpenText, + ttc: 15000, // 15 seconds + impressions: 100, + dropOffCount: 20, + dropOffPercentage: 20, + }, + { + questionId: "q2", + headline: "Second Question", + questionType: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + ttc: 30000, // 30 seconds + impressions: 80, + dropOffCount: 15, + dropOffPercentage: 18.75, + }, + { + questionId: "q3", + headline: "Third Question", + questionType: TSurveyQuestionTypeEnum.Rating, + ttc: 0, // No time data + impressions: 65, + dropOffCount: 10, + dropOffPercentage: 15.38, + }, + ]; + + test("renders header row with correct columns", () => { + render(); + + // Check header + expect(screen.getByText("common.questions")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument(); + expect(screen.getByTestId("timer-icon")).toBeInTheDocument(); + expect(screen.getByText("environments.surveys.summary.impressions")).toBeInTheDocument(); + expect(screen.getByText("environments.surveys.summary.drop_offs")).toBeInTheDocument(); + }); + + test("renders tooltip with correct content", () => { + render(); + + expect(screen.getByTestId("tooltip-content")).toBeInTheDocument(); + expect(screen.getByText("environments.surveys.summary.ttc_tooltip")).toBeInTheDocument(); + }); + + test("renders all drop-off items with correct data", () => { + render(); + + // There should be 3 rows of data (one for each question) + expect(screen.getAllByTestId("question-icon")).toHaveLength(3); + expect(screen.getAllByTestId("formatted-text")).toHaveLength(3); + + // Check time to complete values + expect(screen.getByText("15.00s")).toBeInTheDocument(); // 15000ms converted to seconds + expect(screen.getByText("30.00s")).toBeInTheDocument(); // 30000ms converted to seconds + expect(screen.getByText("N/A")).toBeInTheDocument(); // 0ms shown as N/A + + // Check impressions values + expect(screen.getByText("100")).toBeInTheDocument(); + expect(screen.getByText("80")).toBeInTheDocument(); + expect(screen.getByText("65")).toBeInTheDocument(); + + // Check drop-off counts and percentages + expect(screen.getByText("20")).toBeInTheDocument(); + expect(screen.getByText("15")).toBeInTheDocument(); + expect(screen.getByText("10")).toBeInTheDocument(); + + // Check percentage values + const percentageElements = screen.getAllByText(/\d+%/); + expect(percentageElements).toHaveLength(3); + expect(percentageElements[0]).toHaveTextContent("20%"); + expect(percentageElements[1]).toHaveTextContent("19%"); + expect(percentageElements[2]).toHaveTextContent("15%"); + }); + + test("renders empty state when dropOff array is empty", () => { + render(); + + // Header should still be visible + expect(screen.getByText("common.questions")).toBeInTheDocument(); + + // But no question icons + expect(screen.queryByTestId("question-icon")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx index 5478eff0a5e6..ae86641f2f22 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx @@ -1,11 +1,11 @@ "use client"; +import { recallToHeadline } from "@/lib/utils/recall"; +import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils"; import { getQuestionIcon } from "@/modules/survey/lib/questions"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { useTranslate } from "@tolgee/react"; import { TimerIcon } from "lucide-react"; -import { JSX } from "react"; -import { recallToHeadline } from "@formbricks/lib/utils/recall"; import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types"; interface SummaryDropOffsProps { @@ -20,30 +20,12 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => { return ; }; - const formatTextWithSlashes = (text: string): (string | JSX.Element)[] => { - const regex = /\/(.*?)\\/g; - const parts = text.split(regex); - - return parts.map((part, index) => { - // Check if the part was inside slashes - if (index % 2 !== 0) { - return ( - - @{part} - - ); - } else { - return part; - } - }); - }; - return (
-
-
{t("common.questions")}
-
+
+
{t("common.questions")}
+
@@ -55,14 +37,16 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
-
{t("environments.surveys.summary.impressions")}
-
{t("environments.surveys.summary.drop_offs")}
+
{t("environments.surveys.summary.impressions")}
+
+ {t("environments.surveys.summary.drop_offs")} +
{dropOff.map((quesDropOff) => (
-
+ className="grid grid-cols-6 items-start border-b border-slate-100 text-xs text-slate-800 md:text-sm"> +
{getIcon(quesDropOff.questionType)}

{formatTextWithSlashes( @@ -73,17 +57,23 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => { survey, true, "default" - )["default"] + )["default"], + "@", + ["text-sm"] )}

-
+
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
-
{quesDropOff.impressions}
-
- {quesDropOff.dropOffCount} - ({Math.round(quesDropOff.dropOffPercentage)}%) +
+ {quesDropOff.impressions} +
+
+ + {Math.round(quesDropOff.dropOffPercentage)}% + + {quesDropOff.dropOffCount}
))} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.test.tsx new file mode 100644 index 000000000000..8f364fc12275 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.test.tsx @@ -0,0 +1,463 @@ +import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import { MultipleChoiceSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary"; +import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils"; +import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox"; +import { cleanup, render, screen } from "@testing-library/react"; +import { toast } from "react-hot-toast"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { + TI18nString, + TSurvey, + TSurveyQuestionTypeEnum, + TSurveySummary, +} from "@formbricks/types/surveys/types"; +import { TUserLocale } from "@formbricks/types/user"; +import { SummaryList } from "./SummaryList"; + +// Mock child components +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys", + () => ({ + EmptyAppSurveys: vi.fn(() =>
Mocked EmptyAppSurveys
), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary", + () => ({ + CTASummary: vi.fn(({ questionSummary }) =>
Mocked CTASummary: {questionSummary.question.id}
), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary", + () => ({ + CalSummary: vi.fn(({ questionSummary }) =>
Mocked CalSummary: {questionSummary.question.id}
), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary", + () => ({ + ConsentSummary: vi.fn(({ questionSummary }) => ( +
Mocked ConsentSummary: {questionSummary.question.id}
+ )), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary", + () => ({ + ContactInfoSummary: vi.fn(({ questionSummary }) => ( +
Mocked ContactInfoSummary: {questionSummary.question.id}
+ )), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary", + () => ({ + DateQuestionSummary: vi.fn(({ questionSummary }) => ( +
Mocked DateQuestionSummary: {questionSummary.question.id}
+ )), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary", + () => ({ + FileUploadSummary: vi.fn(({ questionSummary }) => ( +
Mocked FileUploadSummary: {questionSummary.question.id}
+ )), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary", + () => ({ + HiddenFieldsSummary: vi.fn(({ questionSummary }) => ( +
Mocked HiddenFieldsSummary: {questionSummary.id}
+ )), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary", + () => ({ + MatrixQuestionSummary: vi.fn(({ questionSummary }) => ( +
Mocked MatrixQuestionSummary: {questionSummary.question.id}
+ )), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary", + () => ({ + MultipleChoiceSummary: vi.fn(({ questionSummary }) => ( +
Mocked MultipleChoiceSummary: {questionSummary.question.id}
+ )), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary", + () => ({ + NPSSummary: vi.fn(({ questionSummary }) =>
Mocked NPSSummary: {questionSummary.question.id}
), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary", + () => ({ + OpenTextSummary: vi.fn(({ questionSummary }) => ( +
Mocked OpenTextSummary: {questionSummary.question.id}
+ )), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary", + () => ({ + PictureChoiceSummary: vi.fn(({ questionSummary }) => ( +
Mocked PictureChoiceSummary: {questionSummary.question.id}
+ )), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary", + () => ({ + RankingSummary: vi.fn(({ questionSummary }) => ( +
Mocked RankingSummary: {questionSummary.question.id}
+ )), + }) +); +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary", + () => ({ + RatingSummary: vi.fn(({ questionSummary }) => ( +
Mocked RatingSummary: {questionSummary.question.id}
+ )), + }) +); +vi.mock("./AddressSummary", () => ({ + AddressSummary: vi.fn(({ questionSummary }) => ( +
Mocked AddressSummary: {questionSummary.question.id}
+ )), +})); + +// Mock hooks and utils +vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({ + useResponseFilter: vi.fn(), +})); +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: vi.fn((label, _) => (typeof label === "string" ? label : label.default)), +})); +vi.mock("@/modules/ui/components/empty-space-filler", () => ({ + EmptySpaceFiller: vi.fn(() =>
Mocked EmptySpaceFiller
), +})); +vi.mock("@/modules/ui/components/skeleton-loader", () => ({ + SkeletonLoader: vi.fn(() =>
Mocked SkeletonLoader
), +})); +vi.mock("react-hot-toast", () => ({ + // This mock setup is for a named export 'toast' + toast: { + success: vi.fn(), + }, +})); +vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils", () => ({ + constructToastMessage: vi.fn(), +})); + +const mockEnvironment = { + id: "env_test_id", + type: "production", + createdAt: new Date(), + updatedAt: new Date(), + appSetupCompleted: true, +} as unknown as TEnvironment; + +const mockSurvey = { + id: "survey_test_id", + name: "Test Survey", + type: "app", + environmentId: "env_test_id", + status: "inProgress", + questions: [], + hiddenFields: { enabled: false }, + displayOption: "displayOnce", + autoClose: null, + triggers: [], + languages: [], + singleUse: null, + styling: null, + surveyClosedMessage: null, + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + closeOnDate: null, + delay: 0, + displayPercentage: null, + recontactDays: null, + autoComplete: null, + runOnDate: null, + segment: null, + variables: [], +} as unknown as TSurvey; + +const mockSelectedFilter = { filter: [], responseStatus: "all" }; +const mockSetSelectedFilter = vi.fn(); + +const defaultProps = { + summary: [] as TSurveySummary["summary"], + responseCount: 10, + environment: mockEnvironment, + survey: mockSurvey, + totalResponseCount: 20, + locale: "en" as TUserLocale, +}; + +const createMockQuestionSummary = ( + id: string, + type: TSurveyQuestionTypeEnum, + headline: string = "Test Question" +) => + ({ + question: { + id, + headline: { default: headline, en: headline }, + type, + required: false, + choices: + type === TSurveyQuestionTypeEnum.MultipleChoiceSingle || + type === TSurveyQuestionTypeEnum.MultipleChoiceMulti + ? [{ id: "choice1", label: { default: "Choice 1" } }] + : undefined, + logic: [], + }, + type, + responseCount: 5, + samples: type === TSurveyQuestionTypeEnum.OpenText ? [{ value: "sample" }] : [], + choices: + type === TSurveyQuestionTypeEnum.MultipleChoiceSingle || + type === TSurveyQuestionTypeEnum.MultipleChoiceMulti + ? [{ label: { default: "Choice 1" }, count: 5, percentage: 1 }] + : [], + dismissed: + type === TSurveyQuestionTypeEnum.MultipleChoiceSingle || + type === TSurveyQuestionTypeEnum.MultipleChoiceMulti + ? { count: 0, percentage: 0 } + : undefined, + others: + type === TSurveyQuestionTypeEnum.MultipleChoiceSingle || + type === TSurveyQuestionTypeEnum.MultipleChoiceMulti + ? [{ value: "other", count: 0, percentage: 0 }] + : [], + progress: type === TSurveyQuestionTypeEnum.NPS ? { total: 5, trend: 0.5 } : undefined, + average: type === TSurveyQuestionTypeEnum.Rating ? 3.5 : undefined, + accepted: type === TSurveyQuestionTypeEnum.Consent ? { count: 5, percentage: 1 } : undefined, + results: + type === TSurveyQuestionTypeEnum.PictureSelection + ? [{ imageUrl: "url", count: 5, percentage: 1 }] + : undefined, + files: type === TSurveyQuestionTypeEnum.FileUpload ? [{ url: "url", name: "file.pdf", size: 100 }] : [], + booked: type === TSurveyQuestionTypeEnum.Cal ? { count: 5, percentage: 1 } : undefined, + data: type === TSurveyQuestionTypeEnum.Matrix ? [{ rowLabel: "Row1", responses: {} }] : undefined, + ranking: type === TSurveyQuestionTypeEnum.Ranking ? [{ rank: 1, choiceLabel: "Choice1", count: 5 }] : [], + }) as unknown as TSurveySummary["summary"][number]; + +const createMockHiddenFieldSummary = (id: string, label: string = "Hidden Field") => + ({ + id, + type: "hiddenField", + label, + value: "some value", + count: 1, + samples: [{ personId: "person1", value: "Sample Value", updatedAt: new Date().toISOString() }], + responseCount: 1, + }) as unknown as TSurveySummary["summary"][number]; + +const typeToComponentMockNameMap: Record = { + [TSurveyQuestionTypeEnum.OpenText]: "OpenTextSummary", + [TSurveyQuestionTypeEnum.MultipleChoiceSingle]: "MultipleChoiceSummary", + [TSurveyQuestionTypeEnum.MultipleChoiceMulti]: "MultipleChoiceSummary", + [TSurveyQuestionTypeEnum.NPS]: "NPSSummary", + [TSurveyQuestionTypeEnum.CTA]: "CTASummary", + [TSurveyQuestionTypeEnum.Rating]: "RatingSummary", + [TSurveyQuestionTypeEnum.Consent]: "ConsentSummary", + [TSurveyQuestionTypeEnum.PictureSelection]: "PictureChoiceSummary", + [TSurveyQuestionTypeEnum.Date]: "DateQuestionSummary", + [TSurveyQuestionTypeEnum.FileUpload]: "FileUploadSummary", + [TSurveyQuestionTypeEnum.Cal]: "CalSummary", + [TSurveyQuestionTypeEnum.Matrix]: "MatrixQuestionSummary", + [TSurveyQuestionTypeEnum.Address]: "AddressSummary", + [TSurveyQuestionTypeEnum.Ranking]: "RankingSummary", + [TSurveyQuestionTypeEnum.ContactInfo]: "ContactInfoSummary", +}; + +describe("SummaryList", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + beforeEach(() => { + vi.mocked(useResponseFilter).mockReturnValue({ + selectedFilter: mockSelectedFilter, + setSelectedFilter: mockSetSelectedFilter, + resetFilter: vi.fn(), + } as any); + }); + + test("renders EmptyAppSurveys when survey type is app, responseCount is 0 and appSetupCompleted is false", () => { + const testEnv = { ...mockEnvironment, appSetupCompleted: false }; + const testSurvey = { ...mockSurvey, type: "app" as const }; + render(); + expect(screen.getByText("Mocked EmptyAppSurveys")).toBeInTheDocument(); + }); + + test("renders SkeletonLoader when summary is empty and responseCount is not 0", () => { + render(); + expect(screen.getByText("Mocked SkeletonLoader")).toBeInTheDocument(); + }); + + test("renders EmptySpaceFiller when responseCount is 0 and summary is not empty (no responses match filter)", () => { + const summaryWithItem = [createMockQuestionSummary("q1", TSurveyQuestionTypeEnum.OpenText)]; + render(); + expect(screen.getByText("Mocked EmptySpaceFiller")).toBeInTheDocument(); + }); + + test("renders EmptySpaceFiller when responseCount is 0 and totalResponseCount is 0 (no responses at all)", () => { + const summaryWithItem = [createMockQuestionSummary("q1", TSurveyQuestionTypeEnum.OpenText)]; + render(); + expect(screen.getByText("Mocked EmptySpaceFiller")).toBeInTheDocument(); + }); + + const questionTypesToTest: TSurveyQuestionTypeEnum[] = [ + TSurveyQuestionTypeEnum.OpenText, + TSurveyQuestionTypeEnum.MultipleChoiceSingle, + TSurveyQuestionTypeEnum.MultipleChoiceMulti, + TSurveyQuestionTypeEnum.NPS, + TSurveyQuestionTypeEnum.CTA, + TSurveyQuestionTypeEnum.Rating, + TSurveyQuestionTypeEnum.Consent, + TSurveyQuestionTypeEnum.PictureSelection, + TSurveyQuestionTypeEnum.Date, + TSurveyQuestionTypeEnum.FileUpload, + TSurveyQuestionTypeEnum.Cal, + TSurveyQuestionTypeEnum.Matrix, + TSurveyQuestionTypeEnum.Address, + TSurveyQuestionTypeEnum.Ranking, + TSurveyQuestionTypeEnum.ContactInfo, + ]; + + questionTypesToTest.forEach((type) => { + test(`renders ${type}Summary component`, () => { + const mockSummaryItem = createMockQuestionSummary(`q_${type}`, type); + const expectedComponentName = typeToComponentMockNameMap[type]; + render(); + expect( + screen.getByText(new RegExp(`Mocked ${expectedComponentName}:\\s*q_${type}`)) + ).toBeInTheDocument(); + }); + }); + + test("renders HiddenFieldsSummary component", () => { + const mockSummaryItem = createMockHiddenFieldSummary("hf1"); + render(); + expect(screen.getByText("Mocked HiddenFieldsSummary: hf1")).toBeInTheDocument(); + }); + + describe("setFilter function", () => { + const questionId = "q_mc_single"; + const label: TI18nString = { default: "MC Single Question" }; + const questionType = TSurveyQuestionTypeEnum.MultipleChoiceSingle; + const filterValue = "Choice 1"; + const filterComboBoxValue = "choice1_id"; + + beforeEach(() => { + // Render with a component that uses setFilter, e.g., MultipleChoiceSummary + const mockSummaryItem = createMockQuestionSummary(questionId, questionType, label.default); + render(); + }); + + const getSetFilterFn = () => { + const MultipleChoiceSummaryMock = vi.mocked(MultipleChoiceSummary); + return MultipleChoiceSummaryMock.mock.calls[0][0].setFilter; + }; + + test("adds a new filter", () => { + const setFilter = getSetFilterFn(); + vi.mocked(constructToastMessage).mockReturnValue("Custom add message"); + + setFilter(questionId, label, questionType, filterValue, filterComboBoxValue); + + expect(mockSetSelectedFilter).toHaveBeenCalledWith({ + filter: [ + { + questionType: { + id: questionId, + label: label.default, + questionType: questionType, + type: OptionsType.QUESTIONS, + }, + filterType: { + filterComboBoxValue: filterComboBoxValue, + filterValue: filterValue, + }, + }, + ], + responseStatus: "all", + }); + // Ensure vi.mocked(toast.success) refers to the spy from the named export + expect(vi.mocked(toast).success).toHaveBeenCalledWith("Custom add message", { duration: 5000 }); + expect(vi.mocked(constructToastMessage)).toHaveBeenCalledWith( + questionType, + filterValue, + mockSurvey, + questionId, + expect.any(Function), // t function + filterComboBoxValue + ); + }); + + test("updates an existing filter", () => { + const existingFilter = { + questionType: { + id: questionId, + label: label.default, + questionType: questionType, + type: OptionsType.QUESTIONS, + }, + filterType: { + filterComboBoxValue: "old_value_combo", + filterValue: "old_value", + }, + }; + vi.mocked(useResponseFilter).mockReturnValue({ + selectedFilter: { filter: [existingFilter], responseStatus: "all" }, + setSelectedFilter: mockSetSelectedFilter, + resetFilter: vi.fn(), + } as any); + // Re-render or get setFilter again as selectedFilter changed + cleanup(); + const mockSummaryItem = createMockQuestionSummary(questionId, questionType, label.default); + render(); + const setFilter = getSetFilterFn(); + + const newFilterValue = "New Choice"; + const newFilterComboBoxValue = "new_choice_id"; + setFilter(questionId, label, questionType, newFilterValue, newFilterComboBoxValue); + + expect(mockSetSelectedFilter).toHaveBeenCalledWith({ + filter: [ + { + questionType: { + id: questionId, + label: label.default, + questionType: questionType, + type: OptionsType.QUESTIONS, + }, + filterType: { + filterComboBoxValue: newFilterComboBoxValue, + filterValue: newFilterValue, + }, + }, + ], + responseStatus: "all", + }); + expect(vi.mocked(toast.success)).toHaveBeenCalledWith( + "environments.surveys.summary.filter_updated_successfully", + { + duration: 5000, + } + ); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx index 70be3b81b175..513eaa901511 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx @@ -21,11 +21,11 @@ import { RankingSummary } from "@/app/(app)/environments/[environmentId]/surveys import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary"; import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils"; import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox"; +import { getLocalizedValue } from "@/lib/i18n/utils"; import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler"; import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader"; import { useTranslate } from "@tolgee/react"; import { toast } from "react-hot-toast"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { TEnvironment } from "@formbricks/types/environment"; import { TI18nString, TSurveyQuestionId, TSurveySummary } from "@formbricks/types/surveys/types"; import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; @@ -38,22 +38,10 @@ interface SummaryListProps { responseCount: number | null; environment: TEnvironment; survey: TSurvey; - totalResponseCount: number; - isAIEnabled: boolean; - documentsPerPage?: number; locale: TUserLocale; } -export const SummaryList = ({ - summary, - environment, - responseCount, - survey, - totalResponseCount, - isAIEnabled, - documentsPerPage, - locale, -}: SummaryListProps) => { +export const SummaryList = ({ summary, environment, responseCount, survey, locale }: SummaryListProps) => { const { setSelectedFilter, selectedFilter } = useResponseFilter(); const { t } = useTranslate(); const setFilter = ( @@ -104,7 +92,7 @@ export const SummaryList = ({ setSelectedFilter({ filter: [...filterObject.filter], - onlyComplete: filterObject.onlyComplete, + responseStatus: filterObject.responseStatus, }); }; @@ -119,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) => { @@ -134,8 +118,6 @@ export const SummaryList = ({ questionSummary={questionSummary} environmentId={environment.id} survey={survey} - isAIEnabled={isAIEnabled} - documentsPerPage={documentsPerPage} locale={locale} /> ); @@ -262,7 +244,6 @@ export const SummaryList = ({ ); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.test.tsx new file mode 100644 index 000000000000..a7a692fc1222 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.test.tsx @@ -0,0 +1,143 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { SummaryMetadata } from "./SummaryMetadata"; + +vi.mock("lucide-react", () => ({ + ChevronDownIcon: () =>
, + ChevronUpIcon: () =>
, +})); +vi.mock("@/modules/ui/components/tooltip", () => ({ + TooltipProvider: ({ children }) => <>{children}, + Tooltip: ({ children }) => <>{children}, + TooltipTrigger: ({ children, onClick }) => ( + + ), + TooltipContent: ({ children }) => <>{children}, +})); + +const baseSummary = { + completedPercentage: 50, + completedResponses: 2, + displayCount: 3, + dropOffPercentage: 25, + dropOffCount: 1, + startsPercentage: 75, + totalResponses: 4, + ttcAverage: 65000, +}; + +describe("SummaryMetadata", () => { + afterEach(() => { + cleanup(); + }); + + test("renders loading skeletons when isLoading=true", () => { + const { container } = render( + {}} + surveySummary={baseSummary} + isLoading={true} + /> + ); + + expect(container.getElementsByClassName("animate-pulse")).toHaveLength(5); + }); + + test("renders all stats and formats time correctly, toggles dropOffs icon", async () => { + const Wrapper = () => { + const [show, setShow] = useState(false); + return ( + + ); + }; + render(); + // impressions, starts, completed, drop_offs, ttc + expect(screen.getByText("environments.surveys.summary.impressions")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.getByText("75%")).toBeInTheDocument(); + expect(screen.getByText("4")).toBeInTheDocument(); + expect(screen.getByText("50%")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + expect(screen.getByText("25%")).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.getByText("1m 5.00s")).toBeInTheDocument(); + const btn = screen + .getAllByRole("button") + .find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs")); + if (!btn) throw new Error("DropOffs toggle button not found"); + await userEvent.click(btn); + expect(screen.queryByTestId("up")).toBeInTheDocument(); + }); + + test("formats time correctly when < 60 seconds", () => { + const smallSummary = { ...baseSummary, ttcAverage: 5000 }; + render( + {}} + surveySummary={smallSummary} + isLoading={false} + /> + ); + expect(screen.getByText("5.00s")).toBeInTheDocument(); + }); + + test("renders '-' for dropOffCount=0 and still toggles icon", async () => { + const zeroSummary = { ...baseSummary, dropOffCount: 0 }; + const Wrapper = () => { + const [show, setShow] = useState(false); + return ( + + ); + }; + render(); + expect(screen.getAllByText("-")).toHaveLength(1); + const btn = screen + .getAllByRole("button") + .find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs")); + if (!btn) throw new Error("DropOffs toggle button not found"); + await userEvent.click(btn); + expect(screen.queryByTestId("up")).toBeInTheDocument(); + }); + + test("renders '-' for displayCount=0", () => { + const dispZero = { ...baseSummary, displayCount: 0 }; + render( + {}} + surveySummary={dispZero} + isLoading={false} + /> + ); + expect(screen.getAllByText("-")).toHaveLength(1); + }); + + test("renders '-' for totalResponses=0", () => { + const totZero = { ...baseSummary, totalResponses: 0 }; + render( + {}} + surveySummary={totZero} + isLoading={false} + /> + ); + expect(screen.getAllByText("-")).toHaveLength(1); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx index 6f3cae5f459a..115c7e1cafb7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx @@ -71,6 +71,8 @@ export const SummaryMetadata = ({ ttcAverage, } = surveySummary; const { t } = useTranslate(); + const displayCountValue = dropOffCount === 0 ? - : dropOffCount; + return (
@@ -98,10 +100,8 @@ export const SummaryMetadata = ({ - -
setShowDropOffs(!showDropOffs)} - className="group flex h-full w-full cursor-pointer flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm"> + setShowDropOffs(!showDropOffs)} data-testid="dropoffs-toggle"> +
{t("environments.surveys.summary.drop_offs")} {`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && ( @@ -112,20 +112,18 @@ export const SummaryMetadata = ({ {isLoading ? (
- ) : dropOffCount === 0 ? ( - - ) : ( - dropOffCount + displayCountValue )}
{!isLoading && ( - +
{showDropOffs ? ( ) : ( )} - +
)}
@@ -135,6 +133,7 @@ export const SummaryMetadata = ({
+ ({ + getResponseCountAction: vi.fn().mockResolvedValue({ data: 42 }), + getSurveySummaryAction: vi.fn().mockResolvedValue({ + data: { + meta: { + completedPercentage: 80, + completedResponses: 40, + displayCount: 50, + dropOffPercentage: 20, + dropOffCount: 10, + startsPercentage: 100, + totalResponses: 50, + ttcAverage: 120, + }, + dropOff: [ + { + questionId: "q1", + headline: "Question 1", + questionType: "openText", + ttc: 20000, + impressions: 50, + dropOffCount: 5, + dropOffPercentage: 10, + }, + ], + summary: [ + { + question: { id: "q1", headline: "Question 1", type: "openText", required: true }, + responseCount: 45, + type: "openText", + samples: [], + }, + ], + }, + }), +})); + +// Mock components +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs", + () => ({ + SummaryDropOffs: () =>
DropOffs Component
, + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList", + () => ({ + SummaryList: ({ summary, responseCount }: any) => ( +
+ Response Count: {responseCount} + Summary Items: {summary.length} +
+ ), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata", + () => ({ + SummaryMetadata: ({ showDropOffs, setShowDropOffs, isLoading }: any) => ( +
+ Is Loading: {isLoading ? "true" : "false"} + +
+ ), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop", + () => ({ + __esModule: true, + default: () =>
Scroll To Top
, + }) +); + +vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter", () => ({ + CustomFilter: () =>
Custom Filter
, +})); + +// Mock context +vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({ + useResponseFilter: () => ({ + selectedFilter: { filter: [], onlyComplete: false }, + dateRange: { from: null, to: null }, + resetState: vi.fn(), + }), +})); + +// Mock hooks +vi.mock("@/lib/utils/hooks/useIntervalWhenFocused", () => ({ + useIntervalWhenFocused: vi.fn(), +})); + +vi.mock("@/lib/utils/recall", () => ({ + replaceHeadlineRecall: (survey: any) => survey, +})); + +vi.mock("next/navigation", () => ({ + useParams: () => ({}), + useSearchParams: () => ({ get: () => null }), +})); + +describe("SummaryPage", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockEnvironment = { id: "env-123" } as TEnvironment; + const mockSurvey = { + id: "survey-123", + environmentId: "env-123", + } as TSurvey; + const locale = "en-US" as TUserLocale; + + const defaultProps = { + environment: mockEnvironment, + survey: mockSurvey, + surveyId: "survey-123", + webAppUrl: "https://app.example.com", + totalResponseCount: 50, + locale, + }; + + test("renders loading state initially", () => { + render(); + + expect(screen.getByTestId("summary-metadata")).toBeInTheDocument(); + expect(screen.getByText("Is Loading: true")).toBeInTheDocument(); + }); + + test("renders summary components after loading", async () => { + render(); + + // Wait for loading to complete + await waitFor(() => { + expect(screen.getByText("Is Loading: false")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("custom-filter")).toBeInTheDocument(); + expect(screen.getByTestId("scroll-to-top")).toBeInTheDocument(); + expect(screen.getByTestId("summary-list")).toBeInTheDocument(); + }); + + test("shows drop-offs component when toggled", async () => { + const user = userEvent.setup(); + render(); + + // Wait for loading to complete + await waitFor(() => { + expect(screen.getByText("Is Loading: false")).toBeInTheDocument(); + }); + + // Drop-offs should initially be hidden + expect(screen.queryByTestId("summary-drop-offs")).not.toBeInTheDocument(); + + // Toggle drop-offs + await user.click(screen.getByText("Toggle Dropoffs")); + + // Drop-offs should now be visible + expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx index c17d170570f6..b240d78f4fc1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage.tsx @@ -1,30 +1,21 @@ "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 { useParams, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useIntervalWhenFocused } from "@formbricks/lib/utils/hooks/useIntervalWhenFocused"; -import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall"; +import { replaceHeadlineRecall } from "@/lib/utils/recall"; +import { useSearchParams } from "next/navigation"; +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, @@ -43,114 +34,65 @@ interface SummaryPageProps { environment: TEnvironment; survey: TSurvey; surveyId: string; - webAppUrl: string; - user?: TUser; - totalResponseCount: number; - isAIEnabled: boolean; - documentsPerPage?: number; locale: TUserLocale; - isReadOnly: boolean; + initialSurveySummary?: TSurveySummary; } export const SummaryPage = ({ environment, survey, surveyId, - webAppUrl, - totalResponseCount, - isAIEnabled, - documentsPerPage, 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(null); - const [surveySummary, setSurveySummary] = useState(initialSurveySummary); + const [surveySummary, setSurveySummary] = useState( + initialSurveySummary || defaultSurveySummary + ); const [showDropOffs, setShowDropOffs] = useState(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)); + + if (initialSurveySummary && hasNoFilters) { + setIsLoading(false); + return; + } - // Use a ref to keep the latest state and props - const latestFiltersRef = useRef(filters); - latestFiltersRef.current = filters; - - 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; + 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, surveyId, initialSurveySummary]); const surveyMemoized = useMemo(() => { return replaceHeadlineRecall(survey, "default"); @@ -173,19 +115,13 @@ export const SummaryPage = ({ {showDropOffs && }
- {!isReadOnly && !isSharingPage && ( - - )}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx new file mode 100644 index 000000000000..d483fae4627d --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx @@ -0,0 +1,1045 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TSegment } from "@formbricks/types/segment"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUser } from "@formbricks/types/user"; +import { SurveyAnalysisCTA } from "./SurveyAnalysisCTA"; + +// Mock the useTranslate hook +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => { + if (key === "environments.surveys.summary.configure_alerts") { + return "Configure alerts"; + } + if (key === "common.preview") { + return "Preview"; + } + if (key === "common.edit") { + return "Edit"; + } + if (key === "environments.surveys.summary.share_survey") { + return "Share survey"; + } + if (key === "environments.surveys.summary.results_are_public") { + return "Results are public"; + } + if (key === "environments.surveys.survey_duplicated_successfully") { + return "Survey duplicated successfully"; + } + if (key === "environments.surveys.edit.caution_edit_duplicate") { + return "Duplicate & Edit"; + } + if (key === "environments.surveys.summary.reset_survey") { + return "Reset survey"; + } + if (key === "environments.surveys.summary.delete_all_existing_responses_and_displays") { + return "Delete all existing responses and displays"; + } + if (key === "environments.surveys.summary.reset_survey_warning") { + return "Resetting a survey removes all responses and metadata of this survey. This cannot be undone."; + } + if (key === "environments.surveys.summary.survey_reset_successfully") { + return "Survey reset successfully! 5 responses and 3 displays were deleted."; + } + return key; + }, + }), +})); + +// Mock Next.js hooks +const mockPush = vi.fn(); +const mockRefresh = vi.fn(); +const mockPathname = "/environments/test-env-id/surveys/test-survey-id/summary"; +const mockSearchParams = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: mockPush, + refresh: mockRefresh, + }), + usePathname: () => mockPathname, + useSearchParams: () => mockSearchParams, +})); + +// Mock react-hot-toast +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock helper functions +vi.mock("@/lib/utils/helper", () => ({ + getFormattedErrorMessage: vi.fn(() => "Error message"), +})); + +// Mock actions +vi.mock("@/modules/survey/list/actions", () => ({ + copySurveyToOtherEnvironmentAction: vi.fn(), +})); + +vi.mock("../actions", () => ({ + resetSurveyAction: vi.fn(), +})); + +// Mock the useSingleUseId hook +vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({ + useSingleUseId: vi.fn(() => ({ + singleUseId: "test-single-use-id", + refreshSingleUseId: vi.fn().mockResolvedValue("test-single-use-id"), + })), +})); + +// Mock child components +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage", + () => ({ + SuccessMessage: ({ environment, survey }: any) => ( +
+ Success Message for {environment.id} - {survey.id} +
+ ), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal", + () => ({ + ShareSurveyModal: ({ survey, open, setOpen, modalView, user }: any) => ( +
+ Share Survey Modal for {survey.id} - User: {user.id} + +
+ ), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown", + () => ({ + SurveyStatusDropdown: ({ environment, survey }: any) => ( +
+ Status Dropdown for {environment.id} - {survey.id} +
+ ), + }) +); + +vi.mock("@/modules/survey/components/edit-public-survey-alert-dialog", () => ({ + EditPublicSurveyAlertDialog: ({ + open, + setOpen, + isLoading, + primaryButtonAction, + primaryButtonText, + secondaryButtonAction, + secondaryButtonText, + }: any) => ( +
+ + + +
+ ), +})); + +// Mock UI components +vi.mock("@/modules/ui/components/badge", () => ({ + Badge: ({ type, size, className, text }: any) => ( +
+ {text} +
+ ), +})); + +vi.mock("@/modules/ui/components/confirmation-modal", () => ({ + ConfirmationModal: ({ + open, + setOpen, + title, + text, + buttonText, + onConfirm, + buttonVariant, + buttonLoading, + }: any) => ( +
+
{title}
+
{text}
+ + +
+ ), +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick, className }: any) => ( + + ), +})); + +vi.mock("@/modules/ui/components/iconbar", () => ({ + IconBar: ({ actions }: any) => ( +
+ {actions + .filter((action: any) => action.isVisible) + .map((action: any, index: number) => ( + + ))} +
+ ), +})); + +// Mock lucide-react icons +vi.mock("lucide-react", () => ({ + BellRing: () => , + Eye: () => , + ListRestart: () => , + SquarePenIcon: () => , +})); + +vi.mock("@/app/(app)/environments/[environmentId]/context/environment-context", () => ({ + useEnvironment: vi.fn(() => ({ + organizationId: "test-organization-id", + project: { id: "test-project-id" }, + })), +})); + +// Mock data +const mockEnvironment: TEnvironment = { + id: "test-env-id", + createdAt: new Date(), + updatedAt: new Date(), + type: "development", + projectId: "test-project-id", + appSetupCompleted: true, +}; + +const mockSurvey: TSurvey = { + id: "test-survey-id", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + type: "app", + environmentId: "test-env-id", + status: "inProgress", + displayOption: "displayOnce", + autoClose: null, + triggers: [], + + recontactDays: null, + displayLimit: null, + welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false }, + questions: [], + endings: [], + hiddenFields: { enabled: false }, + displayPercentage: null, + autoComplete: null, + + segment: null, + languages: [], + showLanguageSwitch: false, + singleUse: { enabled: false, isEncrypted: false }, + projectOverwrites: null, + surveyClosedMessage: null, + delay: 0, + isVerifyEmailEnabled: false, + createdBy: null, + variables: [], + followUps: [], + runOnDate: null, + closeOnDate: null, + styling: null, + pin: null, + recaptcha: null, + isSingleResponsePerEmailEnabled: false, + isBackButtonHidden: false, +}; + +const mockUser: TUser = { + id: "test-user-id", + name: "Test User", + email: "test@example.com", + emailVerified: new Date(), + imageUrl: "https://example.com/avatar.jpg", + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + + role: "other", + objective: "other", + locale: "en-US", + lastLoginAt: new Date(), + isActive: true, + notificationSettings: { + alert: { + responseFinished: true, + }, + unsubscribedOrganizationIds: [], + }, +}; + +const mockSegments: TSegment[] = []; + +const defaultProps = { + survey: mockSurvey, + environment: mockEnvironment, + isReadOnly: false, + user: mockUser, + publicDomain: "https://example.com", + responseCount: 0, + displayCount: 0, + segments: mockSegments, + isContactsEnabled: true, + isFormbricksCloud: false, +}; + +describe("SurveyAnalysisCTA", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSearchParams.delete("share"); + }); + + afterEach(() => { + cleanup(); + }); + + test("renders share survey button", () => { + render(); + + expect(screen.getByText("Share survey")).toBeInTheDocument(); + }); + + test("renders success message component", () => { + render(); + + expect(screen.getByTestId("success-message")).toBeInTheDocument(); + }); + + test("renders survey status dropdown when app setup is completed", () => { + render(); + + expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument(); + }); + + test("does not render survey status dropdown when read-only", () => { + render(); + + expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument(); + }); + + test("renders icon bar with correct actions", () => { + render(); + + expect(screen.getByTestId("icon-bar")).toBeInTheDocument(); + expect(screen.getByTestId("icon-bar-action-0")).toBeInTheDocument(); // Bell ring + expect(screen.getByTestId("icon-bar-action-1")).toBeInTheDocument(); // Square pen + }); + + test("shows preview icon for link surveys", () => { + const linkSurvey = { ...mockSurvey, type: "link" as const }; + render(); + + expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview"); + }); + + test("opens share modal when share button is clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("Share survey")); + + expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument(); + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true"); + }); + + test("opens share modal when share param is true", () => { + mockSearchParams.set("share", "true"); + render(); + + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true"); + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "start"); + }); + + test("navigates to edit when edit button is clicked and no responses", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("icon-bar-action-1")); + + expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/test-survey-id/edit"); + }); + + test("shows caution dialog when edit button is clicked and has responses", async () => { + const user = userEvent.setup(); + render(); + + // With responseCount > 0, the edit button should be at icon-bar-action-2 (after reset button) + await user.click(screen.getByTestId("icon-bar-action-2")); + + expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true"); + }); + + test("navigates to notifications when bell icon is clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("icon-bar-action-0")); + + expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/settings/notifications"); + }); + + test("opens preview window when preview icon is clicked", async () => { + const user = userEvent.setup(); + const linkSurvey = { ...mockSurvey, type: "link" as const }; + const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null); + + render(); + + await user.click(screen.getByTestId("icon-bar-action-1")); + + expect(windowOpenSpy).toHaveBeenCalledWith("https://example.com/s/test-survey-id?preview=true", "_blank"); + windowOpenSpy.mockRestore(); + }); + + test("does not show icon bar actions when read-only", () => { + render(); + + const iconBar = screen.getByTestId("icon-bar"); + expect(iconBar).toBeInTheDocument(); + // Should only show preview icon for link surveys, but this is app survey + expect(screen.queryByTestId("icon-bar-action-0")).not.toBeInTheDocument(); + }); + + test("handles modal close correctly", async () => { + mockSearchParams.set("share", "true"); + const user = userEvent.setup(); + render(); + + // Verify modal is open initially + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true"); + + await user.click(screen.getByText("Close Modal")); + + // Verify modal is closed + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false"); + }); + + test("shows status dropdown for link surveys", () => { + const linkSurvey = { ...mockSurvey, type: "link" as const }; + render(); + + expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument(); + }); + + test("does not show status dropdown for draft surveys", () => { + const draftSurvey = { ...mockSurvey, status: "draft" as const }; + render(); + + expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument(); + }); + + test("does not show status dropdown when app setup is not completed", () => { + const environmentWithoutAppSetup = { ...mockEnvironment, appSetupCompleted: false }; + render(); + + expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument(); + }); + + test("renders correctly with all props", () => { + render(); + + expect(screen.getByTestId("icon-bar")).toBeInTheDocument(); + expect(screen.getByText("Share survey")).toBeInTheDocument(); + expect(screen.getByTestId("success-message")).toBeInTheDocument(); + expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument(); + }); + + test("duplicates survey when primary button is clicked in edit dialog", async () => { + const mockCopySurveyAction = vi.mocked( + await import("@/modules/survey/list/actions") + ).copySurveyToOtherEnvironmentAction; + mockCopySurveyAction.mockResolvedValue({ + data: { + ...mockSurvey, + id: "new-survey-id", + environmentId: "test-env-id", + triggers: [], + segment: null, + languages: [], + }, + }); + + const toast = await import("react-hot-toast"); + const user = userEvent.setup(); + + render(); + + // Click edit button to open dialog + await user.click(screen.getByTestId("icon-bar-action-1")); + + // Click primary button (duplicate & edit) + await user.click(screen.getByTestId("primary-button")); + + expect(mockCopySurveyAction).toHaveBeenCalledWith({ + environmentId: "test-env-id", + surveyId: "test-survey-id", + targetEnvironmentId: "test-env-id", + }); + expect(toast.default.success).toHaveBeenCalledWith("Survey duplicated successfully"); + expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/new-survey-id/edit"); + }); + + test("handles error when duplicating survey fails", async () => { + const mockCopySurveyAction = vi.mocked( + await import("@/modules/survey/list/actions") + ).copySurveyToOtherEnvironmentAction; + mockCopySurveyAction.mockResolvedValue({ + data: undefined, + serverError: "Duplication failed", + validationErrors: undefined, + bindArgsValidationErrors: [], + }); + + const toast = await import("react-hot-toast"); + const user = userEvent.setup(); + + render(); + + // Click edit button to open dialog + await user.click(screen.getByTestId("icon-bar-action-1")); + + // Click primary button (duplicate & edit) + await user.click(screen.getByTestId("primary-button")); + + expect(toast.default.error).toHaveBeenCalledWith("Error message"); + }); + + test("navigates to edit when secondary button is clicked in edit dialog", async () => { + const user = userEvent.setup(); + + render(); + + // Click edit button to open dialog + await user.click(screen.getByTestId("icon-bar-action-1")); + + // Click secondary button (edit) + await user.click(screen.getByTestId("secondary-button")); + + expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/test-survey-id/edit"); + }); + + test("shows loading state during duplication", async () => { + const mockCopySurveyAction = vi.mocked( + await import("@/modules/survey/list/actions") + ).copySurveyToOtherEnvironmentAction; + + // Mock a delayed response + mockCopySurveyAction.mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + data: { + ...mockSurvey, + id: "new-survey-id", + environmentId: "test-env-id", + triggers: [], + segment: null, + languages: [], + }, + }), + 100 + ) + ) + ); + + const user = userEvent.setup(); + + render(); + + // Click edit button to open dialog + await user.click(screen.getByTestId("icon-bar-action-1")); + + // Click primary button (duplicate & edit) + await user.click(screen.getByTestId("primary-button")); + + // Check loading state + expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-loading", "true"); + }); + + test("closes dialog after successful duplication", async () => { + const mockCopySurveyAction = vi.mocked( + await import("@/modules/survey/list/actions") + ).copySurveyToOtherEnvironmentAction; + mockCopySurveyAction.mockResolvedValue({ + data: { + ...mockSurvey, + id: "new-survey-id", + environmentId: "test-env-id", + triggers: [], + segment: null, + languages: [], + }, + }); + + const user = userEvent.setup(); + + render(); + + // Click edit button to open dialog (should be icon-bar-action-2 with responses) + await user.click(screen.getByTestId("icon-bar-action-2")); + expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true"); + + // Click primary button (duplicate & edit) + await user.click(screen.getByTestId("primary-button")); + + // Dialog should be closed + expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "false"); + }); + + test("opens preview with single use ID when enabled", async () => { + const mockUseSingleUseId = vi.mocked( + await import("@/modules/survey/hooks/useSingleUseId") + ).useSingleUseId; + mockUseSingleUseId.mockReturnValue({ + singleUseId: "test-single-use-id", + refreshSingleUseId: vi.fn().mockResolvedValue("new-single-use-id"), + }); + + const surveyWithSingleUse = { + ...mockSurvey, + type: "link" as const, + singleUse: { enabled: true, isEncrypted: false }, + }; + + const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByTestId("icon-bar-action-1")); + + expect(windowOpenSpy).toHaveBeenCalledWith( + "https://example.com/s/test-survey-id?suId=new-single-use-id&preview=true", + "_blank" + ); + windowOpenSpy.mockRestore(); + }); + + test("handles single use ID generation failure", async () => { + const mockUseSingleUseId = vi.mocked( + await import("@/modules/survey/hooks/useSingleUseId") + ).useSingleUseId; + mockUseSingleUseId.mockReturnValue({ + singleUseId: "test-single-use-id", + refreshSingleUseId: vi.fn().mockResolvedValue(undefined), + }); + + const surveyWithSingleUse = { + ...mockSurvey, + type: "link" as const, + singleUse: { enabled: true, isEncrypted: false }, + }; + + const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByTestId("icon-bar-action-1")); + + expect(windowOpenSpy).toHaveBeenCalledWith("https://example.com/s/test-survey-id?preview=true", "_blank"); + windowOpenSpy.mockRestore(); + }); + + test("opens share modal with correct modal view when share button clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("Share survey")); + + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "share"); + }); + + test("handles different survey statuses correctly", () => { + const completedSurvey = { ...mockSurvey, status: "completed" as const }; + render(); + + expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument(); + }); + + test("handles paused survey status", () => { + const pausedSurvey = { ...mockSurvey, status: "paused" as const }; + render(); + + expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument(); + }); + + test("does not render share modal when user is null", () => { + render(); + + expect(screen.queryByTestId("share-survey-modal")).not.toBeInTheDocument(); + }); + + test("renders with different isFormbricksCloud values", () => { + const { rerender } = render( + + ); + expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument(); + }); + + test("renders with different isContactsEnabled values", () => { + const { rerender } = render( + + ); + expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument(); + }); + + test("handles app survey type", () => { + const appSurvey = { ...mockSurvey, type: "app" as const }; + render(); + + // Should not show preview icon for app surveys + expect(screen.queryByTestId("icon-bar-action-1")).toBeInTheDocument(); // This should be edit button + expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Edit"); + }); + + test("handles modal state changes correctly", async () => { + const user = userEvent.setup(); + render(); + + // Open modal via share button + await user.click(screen.getByText("Share survey")); + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true"); + + // Close modal + await user.click(screen.getByText("Close Modal")); + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false"); + }); + + test("opens share modal via share button", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("Share survey")); + + // Should open the modal with share view + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true"); + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "share"); + }); + + test("closes share modal and updates modal state", async () => { + mockSearchParams.set("share", "true"); + const user = userEvent.setup(); + render(); + + // Modal should be open initially due to share param + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true"); + + await user.click(screen.getByText("Close Modal")); + + // Should close the modal + expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false"); + }); + + test("handles empty segments array", () => { + render(); + + expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument(); + }); + + test("handles zero response count", () => { + render(); + + expect(screen.queryByTestId("edit-public-survey-alert-dialog")).not.toBeInTheDocument(); + }); + + test("shows all icon actions for non-readonly app survey", () => { + render(); + + // Should show bell (notifications) and edit actions + expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts"); + expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Edit"); + }); + + test("shows all icon actions for non-readonly link survey", () => { + const linkSurvey = { ...mockSurvey, type: "link" as const }; + render(); + + // Should show bell (notifications), preview, and edit actions + expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts"); + expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview"); + expect(screen.getByTestId("icon-bar-action-2")).toHaveAttribute("title", "Edit"); + }); + + // Reset Survey Feature Tests + test("shows reset survey button when responses exist", () => { + render(); + + const iconActions = screen.getAllByTestId(/icon-bar-action-/); + const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey"); + expect(resetButton).toBeInTheDocument(); + }); + + test("shows reset survey button when displays exist", () => { + render(); + + const iconActions = screen.getAllByTestId(/icon-bar-action-/); + const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey"); + expect(resetButton).toBeInTheDocument(); + }); + + test("hides reset survey button when no responses or displays exist", () => { + render(); + + const iconActions = screen.getAllByTestId(/icon-bar-action-/); + const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey"); + expect(resetButton).toBeUndefined(); + }); + + test("hides reset survey button for read-only users", () => { + render(); + + // For read-only users, there should be no icon bar actions + expect(screen.queryAllByTestId(/icon-bar-action-/)).toHaveLength(0); + }); + + test("opens reset confirmation modal when reset button is clicked", async () => { + const user = userEvent.setup(); + render(); + + const iconActions = screen.getAllByTestId(/icon-bar-action-/); + const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey"); + + expect(resetButton).toBeDefined(); + await user.click(resetButton!); + + expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true"); + expect(screen.getByTestId("modal-title")).toHaveTextContent("Delete all existing responses and displays"); + expect(screen.getByTestId("modal-text")).toHaveTextContent( + "Resetting a survey removes all responses and metadata of this survey. This cannot be undone." + ); + }); + + test("executes reset survey action when confirmed", async () => { + const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction; + mockResetSurveyAction.mockResolvedValue({ + data: { + success: true, + deletedResponsesCount: 5, + deletedDisplaysCount: 3, + }, + }); + + const toast = await import("react-hot-toast"); + const user = userEvent.setup(); + + render(); + + // Open reset modal + const iconActions = screen.getAllByTestId(/icon-bar-action-/); + const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey"); + expect(resetButton).toBeDefined(); + await user.click(resetButton!); + + // Confirm reset + await user.click(screen.getByTestId("confirm-button")); + + expect(mockResetSurveyAction).toHaveBeenCalledWith({ + surveyId: "test-survey-id", + organizationId: "test-organization-id", + projectId: "test-project-id", + }); + expect(toast.default.success).toHaveBeenCalledWith( + "Survey reset successfully! 5 responses and 3 displays were deleted." + ); + }); + + test("handles reset survey action error", async () => { + const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction; + mockResetSurveyAction.mockResolvedValue({ + data: undefined, + serverError: "Reset failed", + validationErrors: undefined, + bindArgsValidationErrors: [], + }); + + const toast = await import("react-hot-toast"); + const user = userEvent.setup(); + + render(); + + // Open reset modal + const iconActions = screen.getAllByTestId(/icon-bar-action-/); + const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey"); + expect(resetButton).toBeDefined(); + await user.click(resetButton!); + + // Confirm reset + await user.click(screen.getByTestId("confirm-button")); + + expect(toast.default.error).toHaveBeenCalledWith("Error message"); + }); + + test("shows loading state during reset operation", async () => { + const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction; + + // Mock a delayed response + mockResetSurveyAction.mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + data: { + success: true, + deletedResponsesCount: 5, + deletedDisplaysCount: 3, + }, + }), + 100 + ) + ) + ); + + const user = userEvent.setup(); + render(); + + // Open reset modal + const iconActions = screen.getAllByTestId(/icon-bar-action-/); + const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey"); + expect(resetButton).toBeDefined(); + await user.click(resetButton!); + + // Confirm reset + await user.click(screen.getByTestId("confirm-button")); + + // Check loading state + expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-loading", "true"); + }); + + test("closes reset modal after successful reset", async () => { + const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction; + mockResetSurveyAction.mockResolvedValue({ + data: { + success: true, + deletedResponsesCount: 5, + deletedDisplaysCount: 3, + }, + }); + + const user = userEvent.setup(); + render(); + + // Open reset modal + const iconActions = screen.getAllByTestId(/icon-bar-action-/); + const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey"); + expect(resetButton).toBeDefined(); + await user.click(resetButton!); + expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true"); + + // Confirm reset - wait for the action to complete + await user.click(screen.getByTestId("confirm-button")); + + // Wait for the action to complete and the modal to close + await vi.waitFor(() => { + expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false"); + }); + }); + + test("cancels reset operation when cancel button is clicked", async () => { + const user = userEvent.setup(); + render(); + + // Open reset modal + const iconActions = screen.getAllByTestId(/icon-bar-action-/); + const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey"); + expect(resetButton).toBeDefined(); + await user.click(resetButton!); + expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true"); + + // Cancel reset + await user.click(screen.getByTestId("cancel-button")); + + // Modal should be closed + expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false"); + }); + + test("shows destructive button variant for reset confirmation", async () => { + const user = userEvent.setup(); + render(); + + // Open reset modal + const iconActions = screen.getAllByTestId(/icon-bar-action-/); + const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey"); + expect(resetButton).toBeDefined(); + await user.click(resetButton!); + + expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-variant", "destructive"); + }); + + test("refreshes page after successful reset", async () => { + const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction; + + mockResetSurveyAction.mockResolvedValue({ + data: { + success: true, + deletedResponsesCount: 5, + deletedDisplaysCount: 3, + }, + }); + + const user = userEvent.setup(); + render(); + + // Open reset modal + const iconActions = screen.getAllByTestId(/icon-bar-action-/); + const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey"); + expect(resetButton).toBeDefined(); + await user.click(resetButton!); + + // Confirm reset + await user.click(screen.getByTestId("confirm-button")); + + expect(mockRefresh).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx index 69a28d746eda..e9b5c9d35793 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx @@ -1,34 +1,43 @@ "use client"; -import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey"; +import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context"; import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage"; +import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal"; import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog"; import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId"; -import { copySurveyLink } from "@/modules/survey/lib/client-utils"; -import { Badge } from "@/modules/ui/components/badge"; +import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions"; +import { Button } from "@/modules/ui/components/button"; +import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal"; import { IconBar } from "@/modules/ui/components/iconbar"; import { useTranslate } from "@tolgee/react"; -import { BellRing, Code2Icon, Eye, LinkIcon, SquarePenIcon, UsersRound } from "lucide-react"; +import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { TEnvironment } from "@formbricks/types/environment"; +import { TSegment } from "@formbricks/types/segment"; import { TSurvey } from "@formbricks/types/surveys/types"; import { TUser } from "@formbricks/types/user"; +import { resetSurveyAction } from "../actions"; interface SurveyAnalysisCTAProps { survey: TSurvey; environment: TEnvironment; isReadOnly: boolean; user: TUser; - surveyDomain: string; + publicDomain: string; + responseCount: number; + displayCount: number; + segments: TSegment[]; + isContactsEnabled: boolean; + isFormbricksCloud: boolean; } interface ModalState { + start: boolean; share: boolean; - embed: boolean; - panel: boolean; - dropdown: boolean; } export const SurveyAnalysisCTA = ({ @@ -36,96 +45,110 @@ export const SurveyAnalysisCTA = ({ environment, isReadOnly, user, - surveyDomain, + publicDomain, + responseCount, + displayCount, + segments, + isContactsEnabled, + isFormbricksCloud, }: SurveyAnalysisCTAProps) => { const { t } = useTranslate(); - const searchParams = useSearchParams(); - const pathname = usePathname(); const router = useRouter(); - + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [loading, setLoading] = useState(false); const [modalState, setModalState] = useState({ - share: searchParams.get("share") === "true", - embed: false, - panel: false, - dropdown: false, + start: searchParams.get("share") === "true", + share: false, }); + const [isResetModalOpen, setIsResetModalOpen] = useState(false); + const [isResetting, setIsResetting] = useState(false); - const surveyUrl = useMemo(() => `${surveyDomain}/s/${survey.id}`, [survey.id, surveyDomain]); - const { refreshSingleUseId } = useSingleUseId(survey); + const { organizationId, project } = useEnvironment(); + const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly); const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted; useEffect(() => { setModalState((prev) => ({ ...prev, - share: searchParams.get("share") === "true", + start: searchParams.get("share") === "true", })); }, [searchParams]); const handleShareModalToggle = (open: boolean) => { const params = new URLSearchParams(window.location.search); - if (open) { + const currentShareParam = params.get("share") === "true"; + + if (open && !currentShareParam) { params.set("share", "true"); - } else { + router.push(`${pathname}?${params.toString()}`); + } else if (!open && currentShareParam) { params.delete("share"); + router.push(`${pathname}?${params.toString()}`); } - router.push(`${pathname}?${params.toString()}`); - setModalState((prev) => ({ ...prev, share: open })); - }; - const handleCopyLink = () => { - refreshSingleUseId() - .then((newId) => { - const linkToCopy = copySurveyLink(surveyUrl, newId); - return navigator.clipboard.writeText(linkToCopy); - }) - .then(() => { - toast.success(t("common.copied_to_clipboard")); - }) - .catch((err) => { - toast.error(t("environments.surveys.summary.failed_to_copy_link")); - console.error(err); - }); - setModalState((prev) => ({ ...prev, dropdown: false })); + setModalState((prev) => ({ ...prev, start: open })); }; - const getPreviewUrl = () => { - const separator = surveyUrl.includes("?") ? "&" : "?"; - return `${surveyUrl}${separator}preview=true`; + const duplicateSurveyAndRoute = async (surveyId: string) => { + setLoading(true); + const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({ + environmentId: environment.id, + surveyId: surveyId, + targetEnvironmentId: environment.id, + }); + if (duplicatedSurveyResponse?.data) { + toast.success(t("environments.surveys.survey_duplicated_successfully")); + router.push(`/environments/${environment.id}/surveys/${duplicatedSurveyResponse.data.id}/edit`); + } else { + const errorMessage = getFormattedErrorMessage(duplicatedSurveyResponse); + toast.error(errorMessage); + } + setIsCautionDialogOpen(false); + setLoading(false); }; - const handleModalState = (modalView: keyof Omit) => { - return (open: boolean | ((prevState: boolean) => boolean)) => { - const newValue = typeof open === "function" ? open(modalState[modalView]) : open; - setModalState((prev) => ({ ...prev, [modalView]: newValue })); - }; + const getPreviewUrl = async () => { + const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`); + + if (survey.singleUse?.enabled) { + const newId = await refreshSingleUseId(); + if (newId) { + surveyUrl.searchParams.set("suId", newId); + } + } + + surveyUrl.searchParams.set("preview", "true"); + return surveyUrl.toString(); }; - const shareEmbedViews = [ - { key: "share", modalView: "start" as const, setOpen: handleShareModalToggle }, - { key: "embed", modalView: "embed" as const, setOpen: handleModalState("embed") }, - { key: "panel", modalView: "panel" as const, setOpen: handleModalState("panel") }, - ]; + const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false); + + const handleResetSurvey = async () => { + setIsResetting(true); + const result = await resetSurveyAction({ + surveyId: survey.id, + organizationId: organizationId, + projectId: project.id, + }); + if (result?.data) { + toast.success( + t("environments.surveys.summary.survey_reset_successfully", { + responseCount: result.data.deletedResponsesCount, + displayCount: result.data.deletedDisplaysCount, + }) + ); + router.refresh(); + } else { + const errorMessage = getFormattedErrorMessage(result); + toast.error(errorMessage); + } + setIsResetting(false); + setIsResetModalOpen(false); + }; const iconActions = [ - { - icon: Eye, - tooltip: t("common.preview"), - onClick: () => window.open(getPreviewUrl(), "_blank"), - isVisible: survey.type === "link", - }, - { - icon: LinkIcon, - tooltip: t("common.copy_link"), - onClick: handleCopyLink, - isVisible: survey.type === "link", - }, - { - icon: Code2Icon, - tooltip: t("common.embed"), - onClick: () => handleModalState("embed")(true), - isVisible: !isReadOnly, - }, { icon: BellRing, tooltip: t("environments.surveys.summary.configure_alerts"), @@ -133,55 +156,91 @@ export const SurveyAnalysisCTA = ({ isVisible: !isReadOnly, }, { - icon: UsersRound, - tooltip: t("environments.surveys.summary.send_to_panel"), - onClick: () => { - handleModalState("panel")(true); - setModalState((prev) => ({ ...prev, dropdown: false })); + icon: Eye, + tooltip: t("common.preview"), + onClick: async () => { + const previewUrl = await getPreviewUrl(); + window.open(previewUrl, "_blank"); }, - isVisible: !isReadOnly, + isVisible: survey.type === "link", + }, + { + icon: ListRestart, + tooltip: t("environments.surveys.summary.reset_survey"), + onClick: () => setIsResetModalOpen(true), + isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0), }, { icon: SquarePenIcon, tooltip: t("common.edit"), - onClick: () => router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`), + onClick: () => { + responseCount > 0 + ? setIsCautionDialogOpen(true) + : router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`); + }, isVisible: !isReadOnly, }, ]; return (
- {survey.resultShareKey && ( - - )} - {!isReadOnly && (widgetSetupCompleted || survey.type === "link") && survey.status !== "draft" && ( )} + {user && ( - <> - {shareEmbedViews.map(({ key, modalView, setOpen }) => ( - - ))} - - + { + if (!open) { + handleShareModalToggle(false); + setModalState((prev) => ({ ...prev, share: false })); + } + }} + user={user} + modalView={modalState.start ? "start" : "share"} + segments={segments} + isContactsEnabled={isContactsEnabled} + isFormbricksCloud={isFormbricksCloud} + isReadOnly={isReadOnly} + /> )} + + + {responseCount > 0 && ( + duplicateSurveyAndRoute(survey.id)} + primaryButtonText={t("environments.surveys.edit.caution_edit_duplicate")} + secondaryButtonAction={() => + router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`) + } + secondaryButtonText={t("common.edit")} + /> + )} + +
); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.test.tsx new file mode 100644 index 000000000000..cb2818610e48 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.test.tsx @@ -0,0 +1,471 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TSegment } from "@formbricks/types/segment"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUser } from "@formbricks/types/user"; +import { ShareSurveyModal } from "./share-survey-modal"; + +// Mock getPublicDomain - must be first to prevent server-side env access +vi.mock("@/lib/getPublicUrl", () => ({ + getPublicDomain: vi.fn().mockReturnValue("https://example.com"), +})); + +// Mock env to prevent server-side env access +vi.mock("@/lib/env", () => ({ + env: { + IS_FORMBRICKS_CLOUD: "0", + NODE_ENV: "test", + E2E_TESTING: "0", + ENCRYPTION_KEY: "test-encryption-key-32-characters", + WEBAPP_URL: "https://example.com", + CRON_SECRET: "test-cron-secret", + PUBLIC_URL: "https://example.com", + VERCEL_URL: "", + }, +})); + +// Mock the useTranslate hook +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => { + const translations: Record = { + "environments.surveys.summary.single_use_links": "Single-use links", + "environments.surveys.summary.share_the_link": "Share the link", + "environments.surveys.summary.qr_code": "QR Code", + "environments.surveys.summary.personal_links": "Personal links", + "environments.surveys.summary.embed_in_an_email": "Embed in email", + "environments.surveys.summary.embed_on_website": "Embed on website", + "environments.surveys.summary.dynamic_popup": "Dynamic popup", + "environments.surveys.summary.in_app.title": "In-app survey", + "environments.surveys.summary.in_app.description": "Display survey in your app", + "environments.surveys.share.anonymous_links.nav_title": "Share the link", + "environments.surveys.share.single_use_links.nav_title": "Single-use links", + "environments.surveys.share.personal_links.nav_title": "Personal links", + "environments.surveys.share.embed_on_website.nav_title": "Embed on website", + "environments.surveys.share.send_email.nav_title": "Embed in email", + "environments.surveys.share.social_media.title": "Social media", + "environments.surveys.share.dynamic_popup.nav_title": "Dynamic popup", + }; + return translations[key] || key; + }, + }), +})); + +// Mock analysis utils +vi.mock("@/modules/analysis/utils", () => ({ + getSurveyUrl: vi.fn().mockResolvedValue("https://example.com/s/test-survey-id"), +})); + +// Mock logger +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + log: vi.fn(), + }, +})); + +// Mock dialog components +vi.mock("@/modules/ui/components/dialog", () => ({ + Dialog: ({ open, onOpenChange, children }: any) => ( +
onOpenChange(false)}> + {children} +
+ ), + DialogContent: ({ children, width }: any) => ( +
+ {children} +
+ ), + DialogTitle: ({ children }: any) =>
{children}
, +})); + +// Mock VisuallyHidden +vi.mock("@radix-ui/react-visually-hidden", () => ({ + VisuallyHidden: ({ asChild, children }: any) => ( +
{asChild ? children : {children}}
+ ), +})); + +// Mock child components +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab", + () => ({ + AppTab: () =>
App Tab Content
, + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container", + () => ({ + TabContainer: ({ title, description, children }: any) => ( +
+

{title}

+

{description}

+ {children} +
+ ), + }) +); + +vi.mock("./shareEmbedModal/share-view", () => ({ + ShareView: ({ tabs, activeId, setActiveId }: any) => ( +
+

Share View

+
+
Active Tab: {activeId}
+
+
+ {tabs.map((tab: any) => ( + + ))} +
+
+ ), +})); + +vi.mock("./shareEmbedModal/success-view", () => ({ + SuccessView: ({ + survey, + surveyUrl, + publicDomain, + user, + tabs, + handleViewChange, + handleEmbedViewWithTab, + }: any) => ( +
+

Success View

+
+
Survey: {survey?.id}
+
URL: {surveyUrl}
+
Domain: {publicDomain}
+
User: {user?.id}
+
+
+ {tabs.map((tab: any) => { + // Handle single-use links case + let displayLabel = tab.label; + if (tab.id === "anon-links" && survey?.singleUse?.enabled) { + displayLabel = "Single-use links"; + } + return ( + + ); + })} +
+ +
+ ), +})); + +// Mock lucide-react icons +vi.mock("lucide-react", async (importOriginal) => { + const actual = (await importOriginal()) as any; + return { + ...actual, + Code2Icon: () => , + LinkIcon: () => , + MailIcon: () => , + QrCodeIcon: () => , + SmartphoneIcon: () => , + SquareStack: () => , + UserIcon: () => , + }; +}); + +// Mock data +const mockSurvey: TSurvey = { + id: "test-survey-id", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + type: "link", + environmentId: "test-env-id", + status: "inProgress", + displayOption: "displayOnce", + autoClose: null, + triggers: [], + + recontactDays: null, + displayLimit: null, + welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false }, + questions: [], + endings: [], + hiddenFields: { enabled: false }, + displayPercentage: null, + autoComplete: null, + + segment: null, + languages: [], + showLanguageSwitch: false, + singleUse: { enabled: false, isEncrypted: false }, + projectOverwrites: null, + surveyClosedMessage: null, + delay: 0, + isVerifyEmailEnabled: false, + createdBy: null, + variables: [], + followUps: [], + runOnDate: null, + closeOnDate: null, + styling: null, + pin: null, + recaptcha: null, + isSingleResponsePerEmailEnabled: false, + isBackButtonHidden: false, +}; + +const mockAppSurvey: TSurvey = { + ...mockSurvey, + type: "app", +}; + +const mockUser: TUser = { + id: "test-user-id", + name: "Test User", + email: "test@example.com", + emailVerified: new Date(), + imageUrl: "https://example.com/avatar.jpg", + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + + role: "other", + objective: "other", + locale: "en-US", + lastLoginAt: new Date(), + isActive: true, + notificationSettings: { + alert: {}, + unsubscribedOrganizationIds: [], + }, +}; + +const mockSegments: TSegment[] = []; + +const mockSetOpen = vi.fn(); + +const defaultProps = { + survey: mockSurvey, + publicDomain: "https://example.com", + open: true, + modalView: "start" as const, + setOpen: mockSetOpen, + user: mockUser, + segments: mockSegments, + isContactsEnabled: true, + isFormbricksCloud: false, +}; + +describe("ShareSurveyModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + test("renders dialog when open is true", () => { + render(); + + expect(screen.getByTestId("dialog")).toHaveAttribute("data-open", "true"); + expect(screen.getByTestId("dialog-content")).toBeInTheDocument(); + }); + + test("renders success view when modalView is start", () => { + render(); + + expect(screen.getByTestId("success-view")).toBeInTheDocument(); + expect(screen.getByText("Success View")).toBeInTheDocument(); + }); + + test("renders share view when modalView is share and survey is link type", () => { + render(); + + expect(screen.getByTestId("share-view")).toBeInTheDocument(); + expect(screen.getByText("Share View")).toBeInTheDocument(); + }); + + test("renders app tab when survey is app type and modalView is share", () => { + render(); + + expect(screen.getByTestId("tab-container")).toBeInTheDocument(); + expect(screen.getByTestId("app-tab")).toBeInTheDocument(); + expect(screen.getByText("In-app survey")).toBeInTheDocument(); + expect(screen.getByText("Display survey in your app")).toBeInTheDocument(); + }); + + test("renders success view when survey is app type and modalView is start", () => { + render(); + + expect(screen.getByTestId("success-view")).toBeInTheDocument(); + expect(screen.queryByTestId("tab-container")).not.toBeInTheDocument(); + }); + + test("sets correct width for dialog content based on survey type", () => { + const { rerender } = render(); + + expect(screen.getByTestId("dialog-content")).toHaveAttribute("data-width", "wide"); + + rerender(); + + expect(screen.getByTestId("dialog-content")).toHaveAttribute("data-width", "default"); + }); + + test("generates correct tabs for link survey", () => { + render(); + + expect(screen.getByTestId("success-tab-anon-links")).toHaveTextContent("Share the link"); + expect(screen.getByTestId("success-tab-qr-code")).toHaveTextContent("QR Code"); + expect(screen.getByTestId("success-tab-personal-links")).toHaveTextContent("Personal links"); + expect(screen.getByTestId("success-tab-email")).toHaveTextContent("Embed in email"); + expect(screen.getByTestId("success-tab-website-embed")).toHaveTextContent("Embed on website"); + expect(screen.getByTestId("success-tab-dynamic-popup")).toHaveTextContent("Dynamic popup"); + }); + + test("shows single-use links label when singleUse is enabled", () => { + const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } }; + render(); + + expect(screen.getByTestId("success-tab-anon-links")).toHaveTextContent("Single-use links"); + }); + + test("calls setOpen when dialog is closed", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("dialog")); + + expect(mockSetOpen).toHaveBeenCalledWith(false); + }); + + test("fetches survey URL on mount", async () => { + const { getSurveyUrl } = await import("@/modules/analysis/utils"); + + render(); + + await waitFor(() => { + expect(getSurveyUrl).toHaveBeenCalledWith(mockSurvey, "https://example.com", "default"); + }); + }); + + test("handles getSurveyUrl failure gracefully", async () => { + const { getSurveyUrl } = await import("@/modules/analysis/utils"); + vi.mocked(getSurveyUrl).mockRejectedValue(new Error("Failed to fetch")); + + // Render and verify it doesn't crash, even if nothing renders due to the error + expect(() => { + render(); + }).not.toThrow(); + }); + + test("renders ShareView with correct active tab", () => { + render(); + + const shareViewData = screen.getByTestId("share-view-data"); + expect(shareViewData).toHaveTextContent("Active Tab: anon-links"); + }); + + test("passes correct props to SuccessView", () => { + render(); + + const successViewData = screen.getByTestId("success-view-data"); + expect(successViewData).toHaveTextContent("Survey: test-survey-id"); + expect(successViewData).toHaveTextContent("Domain: https://example.com"); + expect(successViewData).toHaveTextContent("User: test-user-id"); + }); + + test("resets to start view when modal is closed and reopened", async () => { + const user = userEvent.setup(); + const { rerender } = render(); + + expect(screen.getByTestId("share-view")).toBeInTheDocument(); + + rerender(); + rerender(); + + expect(screen.getByTestId("share-view")).toBeInTheDocument(); + }); + + test("sets correct active tab for link survey", () => { + render(); + + expect(screen.getByTestId("share-view")).toHaveAttribute("data-active-id", "anon-links"); + }); + + test("renders tab container for app survey in share mode", () => { + render(); + + expect(screen.getByTestId("tab-container")).toBeInTheDocument(); + expect(screen.getByTestId("app-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("share-view")).not.toBeInTheDocument(); + }); + + test("renders with contacts disabled", () => { + render(); + + // Just verify the ShareView renders correctly regardless of isContactsEnabled prop + expect(screen.getByTestId("share-view")).toBeInTheDocument(); + expect(screen.getByTestId("share-view")).toHaveAttribute("data-active-id", "anon-links"); + }); + + test("renders with formbricks cloud enabled", () => { + render(); + + // Just verify the ShareView renders correctly regardless of isFormbricksCloud prop + expect(screen.getByTestId("share-view")).toBeInTheDocument(); + }); + + test("correctly handles direct navigation to share view", () => { + render(); + + expect(screen.getByTestId("share-view")).toBeInTheDocument(); + expect(screen.queryByTestId("success-view")).not.toBeInTheDocument(); + }); + + test("handler functions are passed to child components", () => { + render(); + + // Verify SuccessView receives the handler functions by checking buttons exist + expect(screen.getByTestId("go-to-share-view")).toBeInTheDocument(); + expect(screen.getByTestId("success-tab-anon-links")).toBeInTheDocument(); + expect(screen.getByTestId("success-tab-qr-code")).toBeInTheDocument(); + }); + + test("tab switching functionality is available in ShareView", () => { + render(); + + // Verify ShareView has tab switching buttons + expect(screen.getByTestId("tab-anon-links")).toBeInTheDocument(); + expect(screen.getByTestId("tab-qr-code")).toBeInTheDocument(); + expect(screen.getByTestId("tab-personal-links")).toBeInTheDocument(); + }); + + test("renders different content based on survey type", () => { + // Link survey renders ShareView + const { rerender } = render(); + expect(screen.getByTestId("share-view")).toBeInTheDocument(); + + // App survey renders TabContainer with AppTab + rerender(); + expect(screen.getByTestId("tab-container")).toBeInTheDocument(); + expect(screen.getByTestId("app-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("share-view")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.tsx new file mode 100644 index 000000000000..3038ec05dd5c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab"; +import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab"; +import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab"; +import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab"; +import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab"; +import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab"; +import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab"; +import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container"; +import { WebsiteEmbedTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab"; +import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share"; +import { getSurveyUrl } from "@/modules/analysis/utils"; +import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import { useTranslate } from "@tolgee/react"; +import { Code2Icon, LinkIcon, MailIcon, QrCodeIcon, Share2Icon, SquareStack, UserIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { TSegment } from "@formbricks/types/segment"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUser } from "@formbricks/types/user"; +import { ShareView } from "./shareEmbedModal/share-view"; +import { SuccessView } from "./shareEmbedModal/success-view"; + +type ModalView = "start" | "share"; + +interface ShareSurveyModalProps { + survey: TSurvey; + publicDomain: string; + open: boolean; + modalView: ModalView; + setOpen: React.Dispatch>; + user: TUser; + segments: TSegment[]; + isContactsEnabled: boolean; + isFormbricksCloud: boolean; + isReadOnly: boolean; +} + +export const ShareSurveyModal = ({ + survey, + publicDomain, + open, + modalView, + setOpen, + user, + segments, + isContactsEnabled, + isFormbricksCloud, + isReadOnly, +}: ShareSurveyModalProps) => { + const environmentId = survey.environmentId; + const [surveyUrl, setSurveyUrl] = useState(getSurveyUrl(survey, publicDomain, "default")); + const [showView, setShowView] = useState(modalView); + const { email } = user; + const { t } = useTranslate(); + const linkTabs: { + id: ShareViewType; + label: string; + icon: React.ElementType; + title: string; + description: string; + componentType: React.ComponentType; + componentProps: any; + }[] = useMemo( + () => [ + { + id: ShareViewType.ANON_LINKS, + label: t("environments.surveys.share.anonymous_links.nav_title"), + icon: LinkIcon, + title: t("environments.surveys.share.anonymous_links.nav_title"), + description: t("environments.surveys.share.anonymous_links.description"), + componentType: AnonymousLinksTab, + componentProps: { + survey, + publicDomain, + setSurveyUrl, + locale: user.locale, + surveyUrl, + isReadOnly, + }, + }, + { + id: ShareViewType.PERSONAL_LINKS, + label: t("environments.surveys.share.personal_links.nav_title"), + icon: UserIcon, + title: t("environments.surveys.share.personal_links.nav_title"), + description: t("environments.surveys.share.personal_links.description"), + componentType: PersonalLinksTab, + componentProps: { + environmentId, + surveyId: survey.id, + segments, + isContactsEnabled, + isFormbricksCloud, + }, + }, + { + id: ShareViewType.WEBSITE_EMBED, + label: t("environments.surveys.share.embed_on_website.nav_title"), + icon: Code2Icon, + title: t("environments.surveys.share.embed_on_website.nav_title"), + description: t("environments.surveys.share.embed_on_website.description"), + componentType: WebsiteEmbedTab, + componentProps: { surveyUrl }, + }, + { + id: ShareViewType.EMAIL, + label: t("environments.surveys.share.send_email.nav_title"), + icon: MailIcon, + title: t("environments.surveys.share.send_email.nav_title"), + description: t("environments.surveys.share.send_email.description"), + componentType: EmailTab, + componentProps: { surveyId: survey.id, email }, + }, + { + id: ShareViewType.SOCIAL_MEDIA, + label: t("environments.surveys.share.social_media.title"), + icon: Share2Icon, + title: t("environments.surveys.share.social_media.title"), + description: t("environments.surveys.share.social_media.description"), + componentType: SocialMediaTab, + componentProps: { surveyUrl, surveyTitle: survey.name }, + }, + { + id: ShareViewType.QR_CODE, + label: t("environments.surveys.summary.qr_code"), + icon: QrCodeIcon, + title: t("environments.surveys.summary.qr_code"), + description: t("environments.surveys.summary.qr_code_description"), + componentType: QRCodeTab, + componentProps: { surveyUrl }, + }, + { + id: ShareViewType.DYNAMIC_POPUP, + label: t("environments.surveys.share.dynamic_popup.nav_title"), + icon: SquareStack, + title: t("environments.surveys.share.dynamic_popup.nav_title"), + description: t("environments.surveys.share.dynamic_popup.description"), + componentType: DynamicPopupTab, + componentProps: { environmentId, surveyId: survey.id }, + }, + ], + [ + t, + survey, + publicDomain, + setSurveyUrl, + user.locale, + surveyUrl, + environmentId, + segments, + isContactsEnabled, + isFormbricksCloud, + email, + ] + ); + + const [activeId, setActiveId] = useState( + survey.type === "link" ? ShareViewType.ANON_LINKS : ShareViewType.APP + ); + + useEffect(() => { + if (open) { + setShowView(modalView); + } + }, [open, modalView]); + + const handleOpenChange = (open: boolean) => { + setOpen(open); + if (!open) { + setShowView("start"); + setActiveId(ShareViewType.ANON_LINKS); + } + }; + + const handleViewChange = (view: ModalView) => { + setShowView(view); + }; + + const handleEmbedViewWithTab = (tabId: ShareViewType) => { + setShowView("share"); + setActiveId(tabId); + }; + + const renderContent = () => { + if (showView === "start") { + return ( + + ); + } + + if (survey.type === "link") { + return ; + } + + return ( +
+ + + +
+ ); + }; + + return ( + + + + + + {renderContent()} + + + ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/AppTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/AppTab.tsx deleted file mode 100644 index 3d72b38aef38..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/AppTab.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import { MobileAppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/MobileAppTab"; -import { WebAppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebAppTab"; -import { OptionsSwitch } from "@/modules/ui/components/options-switch"; -import { useTranslate } from "@tolgee/react"; -import { useState } from "react"; - -export const AppTab = () => { - const { t } = useTranslate(); - const [selectedTab, setSelectedTab] = useState("webapp"); - - return ( -
- setSelectedTab(value)} - /> - -
{selectedTab === "webapp" ? : }
-
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmailTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmailTab.tsx deleted file mode 100644 index b85e1683af11..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmailTab.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -import { getFormattedErrorMessage } from "@/lib/utils/helper"; -import { Button } from "@/modules/ui/components/button"; -import { CodeBlock } from "@/modules/ui/components/code-block"; -import { LoadingSpinner } from "@/modules/ui/components/loading-spinner"; -import { useTranslate } from "@tolgee/react"; -import { Code2Icon, CopyIcon, MailIcon } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import toast from "react-hot-toast"; -import { AuthenticationError } from "@formbricks/types/errors"; -import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions"; - -interface EmailTabProps { - surveyId: string; - email: string; -} - -export const EmailTab = ({ surveyId, email }: EmailTabProps) => { - const [showEmbed, setShowEmbed] = useState(false); - const [emailHtmlPreview, setEmailHtmlPreview] = useState(""); - const { t } = useTranslate(); - const emailHtml = useMemo(() => { - if (!emailHtmlPreview) return ""; - return emailHtmlPreview - .replaceAll("?preview=true&", "?") - .replaceAll("?preview=true&;", "?") - .replaceAll("?preview=true", ""); - }, [emailHtmlPreview]); - - useEffect(() => { - const getData = async () => { - const emailHtml = await getEmailHtmlAction({ surveyId }); - setEmailHtmlPreview(emailHtml?.data || ""); - }; - - getData(); - }, [surveyId]); - - const sendPreviewEmail = async () => { - try { - const val = await sendEmbedSurveyPreviewEmailAction({ surveyId }); - if (val?.data) { - toast.success(t("environments.surveys.summary.email_sent")); - } else { - const errorMessage = getFormattedErrorMessage(val); - toast.error(errorMessage); - } - } catch (err) { - if (err instanceof AuthenticationError) { - toast.error(t("common.not_authenticated")); - return; - } - toast.error(t("common.something_went_wrong_please_try_again")); - } - }; - - return ( -
-
- {showEmbed ? ( - - ) : ( - <> - - - )} - -
- {showEmbed ? ( -
- - {emailHtml} - -
- ) : ( -
-
-
-
-
-
-
-
To : {email || "user@mail.com"}
-
- Subject : {t("environments.surveys.summary.formbricks_email_survey_preview")} -
-
- {emailHtml ? ( -
- ) : ( - - )} -
-
-
- )} -
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.tsx deleted file mode 100644 index 2f75d5237fb7..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/EmbedView.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use client"; - -import { Button } from "@/modules/ui/components/button"; -import { useTranslate } from "@tolgee/react"; -import { ArrowLeftIcon } from "lucide-react"; -import { cn } from "@formbricks/lib/cn"; -import { TUserLocale } from "@formbricks/types/user"; -import { AppTab } from "./AppTab"; -import { EmailTab } from "./EmailTab"; -import { LinkTab } from "./LinkTab"; -import { WebsiteTab } from "./WebsiteTab"; - -interface EmbedViewProps { - handleInitialPageButton: () => void; - tabs: Array<{ id: string; label: string; icon: any }>; - activeId: string; - setActiveId: React.Dispatch>; - environmentId: string; - disableBack: boolean; - survey: any; - email: string; - surveyUrl: string; - surveyDomain: string; - setSurveyUrl: React.Dispatch>; - locale: TUserLocale; -} - -export const EmbedView = ({ - handleInitialPageButton, - tabs, - disableBack, - activeId, - setActiveId, - environmentId, - survey, - email, - surveyUrl, - surveyDomain, - setSurveyUrl, - locale, -}: EmbedViewProps) => { - const { t } = useTranslate(); - return ( -
- {!disableBack && ( -
- -
- )} -
- {survey.type === "link" && ( - - )} -
- {activeId === "email" ? ( - - ) : activeId === "webpage" ? ( - - ) : activeId === "link" ? ( - - ) : activeId === "app" ? ( - - ) : null} -
- {tabs.slice(0, 2).map((tab) => ( - - ))} -
-
-
-
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/LinkTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/LinkTab.tsx deleted file mode 100644 index 0c53c04a2ffc..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/LinkTab.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink"; -import { useTranslate } from "@tolgee/react"; -import Link from "next/link"; -import { TSurvey } from "@formbricks/types/surveys/types"; -import { TUserLocale } from "@formbricks/types/user"; - -interface LinkTabProps { - survey: TSurvey; - surveyUrl: string; - surveyDomain: string; - setSurveyUrl: (url: string) => void; - locale: TUserLocale; -} - -export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale }: LinkTabProps) => { - const { t } = useTranslate(); - - const docsLinks = [ - { - title: t("environments.surveys.summary.data_prefilling"), - description: t("environments.surveys.summary.data_prefilling_description"), - link: "https://formbricks.com/docs/link-surveys/data-prefilling", - }, - { - title: t("environments.surveys.summary.source_tracking"), - description: t("environments.surveys.summary.source_tracking_description"), - link: "https://formbricks.com/docs/link-surveys/source-tracking", - }, - { - title: t("environments.surveys.summary.create_single_use_links"), - description: t("environments.surveys.summary.create_single_use_links_description"), - link: "https://formbricks.com/docs/link-surveys/single-use-links", - }, - ]; - - return ( -
-
-

- {t("environments.surveys.summary.share_the_link_to_get_responses")} -

- -
- -
-

- {t("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys")} 💡 -

-
- {docsLinks.map((tip) => ( - -

{tip.title}

-

{tip.description}

- - ))} -
-
-
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/MobileAppTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/MobileAppTab.tsx deleted file mode 100644 index fd3fb6b66600..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/MobileAppTab.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; -import { Button } from "@/modules/ui/components/button"; -import { useTranslate } from "@tolgee/react"; -import Link from "next/link"; - -export const MobileAppTab = () => { - const { t } = useTranslate(); - return ( - - {t("environments.surveys.summary.quickstart_mobile_apps")} - - {t("environments.surveys.summary.quickstart_mobile_apps_description")} - - - - ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/PanelInfoView.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/PanelInfoView.tsx deleted file mode 100644 index ca9cad500fb2..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/PanelInfoView.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; - -import ProlificLogo from "@/images/prolific-logo.webp"; -import ProlificUI from "@/images/prolific-screenshot.webp"; -import { Button } from "@/modules/ui/components/button"; -import { useTranslate } from "@tolgee/react"; -import { ArrowLeftIcon } from "lucide-react"; -import Image from "next/image"; -import Link from "next/link"; - -interface PanelInfoViewProps { - disableBack: boolean; - handleInitialPageButton: () => void; -} - -export const PanelInfoView = ({ disableBack, handleInitialPageButton }: PanelInfoViewProps) => { - const { t } = useTranslate(); - return ( -
- {!disableBack && ( -
- -
- )} -
-
- Prolific panel selection UI -
-

{t("environments.surveys.summary.what_is_a_panel")}

-

{t("environments.surveys.summary.what_is_a_panel_answer")}

-
-
-

{t("environments.surveys.summary.when_do_i_need_it")}

-

{t("environments.surveys.summary.when_do_i_need_it_answer")}

-
-
-

{t("environments.surveys.summary.what_is_prolific")}

-

{t("environments.surveys.summary.what_is_prolific_answer")}

-
-
-
- Prolific panel selection UI -
-

- {t("environments.surveys.summary.how_to_create_a_panel")} -

-
-
-

- {t("environments.surveys.summary.how_to_create_a_panel_step_1")} -

-

- {t("environments.surveys.summary.how_to_create_a_panel_step_1_description")} -

-
-
-

- {t("environments.surveys.summary.how_to_create_a_panel_step_2")} -

-

- {t("environments.surveys.summary.how_to_create_a_panel_step_2_description")} -

-
-
-

- {t("environments.surveys.summary.how_to_create_a_panel_step_3")} -

-

- {t("environments.surveys.summary.how_to_create_a_panel_step_3_description")} -

-
-
-

- {t("environments.surveys.summary.how_to_create_a_panel_step_4")} -

-

- {t("environments.surveys.summary.how_to_create_a_panel_step_4_description")} -

-
- -
-
-
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebAppTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebAppTab.tsx deleted file mode 100644 index 28bfaac59b8c..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebAppTab.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; -import { Button } from "@/modules/ui/components/button"; -import { useTranslate } from "@tolgee/react"; -import Link from "next/link"; - -export const WebAppTab = () => { - const { t } = useTranslate(); - return ( - - {t("environments.surveys.summary.quickstart_web_apps")} - - {t("environments.surveys.summary.quickstart_web_apps_description")} - - - - ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebsiteTab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebsiteTab.tsx deleted file mode 100644 index 535e4c1c7f45..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebsiteTab.tsx +++ /dev/null @@ -1,118 +0,0 @@ -"use client"; - -import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; -import { Button } from "@/modules/ui/components/button"; -import { CodeBlock } from "@/modules/ui/components/code-block"; -import { OptionsSwitch } from "@/modules/ui/components/options-switch"; -import { useTranslate } from "@tolgee/react"; -import { CopyIcon } from "lucide-react"; -import Link from "next/link"; -import { useState } from "react"; -import toast from "react-hot-toast"; - -export const WebsiteTab = ({ surveyUrl, environmentId }) => { - const [selectedTab, setSelectedTab] = useState("static"); - const { t } = useTranslate(); - - return ( -
- setSelectedTab(value)} - /> - -
- {selectedTab === "static" ? ( - - ) : ( - - )} -
-
- ); -}; - -const StaticTab = ({ surveyUrl }) => { - const [embedModeEnabled, setEmbedModeEnabled] = useState(false); - const { t } = useTranslate(); - const iframeCode = `
- -
`; - - return ( -
-
-
- -
-
- - {iframeCode} - -
-
- -
-
- ); -}; - -const PopupTab = ({ environmentId }) => { - const { t } = useTranslate(); - return ( -
-

- {t("environments.surveys.summary.embed_pop_up_survey_title")} -

-
    -
  1. - {t("common.follow_these")}{" "} - - {t("environments.surveys.summary.setup_instructions")} - {" "} - {t("environments.surveys.summary.to_connect_your_website_with_formbricks")} -
  2. -
  3. - {t("environments.surveys.summary.make_sure_the_survey_type_is_set_to")}{" "} - {t("common.website_survey")} -
  4. -
  5. {t("environments.surveys.summary.define_when_and_where_the_survey_should_pop_up")}
  6. -
-
- -
-
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.test.tsx new file mode 100644 index 000000000000..b052a2364ef5 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.test.tsx @@ -0,0 +1,433 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUserLocale } from "@formbricks/types/user"; +import { AnonymousLinksTab } from "./anonymous-links-tab"; + +// Mock actions +vi.mock("../../actions", () => ({ + updateSingleUseLinksAction: vi.fn(), +})); + +vi.mock("@/modules/survey/list/actions", () => ({ + generateSingleUseIdsAction: vi.fn(), +})); + +// Mock components +vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({ + ShareSurveyLink: ({ surveyUrl, publicDomain }: any) => ( +
+

Survey URL: {surveyUrl}

+

Public Domain: {publicDomain}

+
+ ), +})); + +vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({ + AdvancedOptionToggle: ({ children, htmlId, isChecked, onToggle, title }: any) => ( +
+ + {children} +
+ ), +})); + +vi.mock("@/modules/ui/components/alert", () => ({ + Alert: ({ children, variant, size }: any) => ( +
+ {children} +
+ ), + AlertTitle: ({ children }: any) =>
{children}
, + AlertDescription: ({ children }: any) =>
{children}
, +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick, disabled, variant }: any) => ( + + ), +})); + +vi.mock("@/modules/ui/components/input", () => ({ + Input: ({ value, onChange, type, max, min, className }: any) => ( + + ), +})); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container", + () => ({ + TabContainer: ({ children, title }: any) => ( +
+

{title}

+ {children} +
+ ), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal", + () => ({ + DisableLinkModal: ({ open, type, onDisable }: any) => ( +
+ + +
+ ), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links", + () => ({ + DocumentationLinks: ({ links }: any) => ( +
+ {links.map((link: any, index: number) => ( + + {link.title} + + ))} +
+ ), + }) +); + +// Mock translations +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +// Mock Next.js router +const mockRefresh = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: mockRefresh, + }), +})); + +// Mock toast +vi.mock("react-hot-toast", () => ({ + default: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +// Mock URL and Blob for download functionality +global.URL.createObjectURL = vi.fn(() => "mock-url"); +global.URL.revokeObjectURL = vi.fn(); +global.Blob = vi.fn(() => ({}) as any); + +describe("AnonymousLinksTab", () => { + const mockSurvey = { + id: "test-survey-id", + environmentId: "test-env-id", + type: "link" as const, + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + createdBy: null, + status: "draft" as const, + questions: [], + thankYouCard: { enabled: false }, + welcomeCard: { enabled: false }, + hiddenFields: { enabled: false }, + singleUse: { + enabled: false, + isEncrypted: false, + }, + } as unknown as TSurvey; + + const surveyWithSingleUse = { + ...mockSurvey, + singleUse: { + enabled: true, + isEncrypted: false, + }, + } as TSurvey; + + const surveyWithEncryption = { + ...mockSurvey, + singleUse: { + enabled: true, + isEncrypted: true, + }, + } as TSurvey; + + const defaultProps = { + survey: mockSurvey, + surveyUrl: "https://example.com/survey", + publicDomain: "https://example.com", + setSurveyUrl: vi.fn(), + locale: "en-US" as TUserLocale, + }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { updateSingleUseLinksAction } = await import("../../actions"); + const { generateSingleUseIdsAction } = await import("@/modules/survey/list/actions"); + + vi.mocked(updateSingleUseLinksAction).mockResolvedValue({ data: mockSurvey }); + vi.mocked(generateSingleUseIdsAction).mockResolvedValue({ data: ["link1", "link2"] }); + }); + + afterEach(() => { + cleanup(); + }); + + test("renders with single-use link enabled when survey has singleUse enabled", () => { + render(); + + expect(screen.getByTestId("toggle-multi-use-link-switch")).toHaveAttribute("data-checked", "false"); + expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true"); + }); + + test("handles multi-use toggle when single-use is disabled", async () => { + const user = userEvent.setup(); + const { updateSingleUseLinksAction } = await import("../../actions"); + + render(); + + // When multi-use is enabled and we click it, it should show a modal to turn it off + const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch"); + await user.click(multiUseToggle); + + // Should show confirmation modal + expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true"); + expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "multi-use"); + + // Confirm the modal action + const confirmButton = screen.getByText("Confirm"); + await user.click(confirmButton); + + await waitFor(() => { + expect(updateSingleUseLinksAction).toHaveBeenCalledWith({ + surveyId: "test-survey-id", + environmentId: "test-env-id", + isSingleUse: true, + isSingleUseEncryption: true, + }); + }); + + expect(mockRefresh).toHaveBeenCalled(); + }); + + test("shows confirmation modal when toggling from single-use to multi-use", async () => { + const user = userEvent.setup(); + render(); + + const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch"); + await user.click(multiUseToggle); + + expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true"); + expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "single-use"); + }); + + test("shows confirmation modal when toggling from multi-use to single-use", async () => { + const user = userEvent.setup(); + render(); + + const singleUseToggle = screen.getByTestId("toggle-button-single-use-link-switch"); + await user.click(singleUseToggle); + + expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true"); + expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "multi-use"); + }); + + test("handles single-use encryption toggle", async () => { + const user = userEvent.setup(); + const { updateSingleUseLinksAction } = await import("../../actions"); + + render(); + + const encryptionToggle = screen.getByTestId("toggle-button-single-use-encryption-switch"); + await user.click(encryptionToggle); + + await waitFor(() => { + expect(updateSingleUseLinksAction).toHaveBeenCalledWith({ + surveyId: "test-survey-id", + environmentId: "test-env-id", + isSingleUse: true, + isSingleUseEncryption: true, + }); + }); + }); + + test("shows encryption info alert when encryption is disabled", () => { + render(); + + const alerts = screen.getAllByTestId("alert-info"); + const encryptionAlert = alerts.find( + (alert) => + alert.querySelector('[data-testid="alert-title"]')?.textContent === + "environments.surveys.share.anonymous_links.custom_single_use_id_title" + ); + + expect(encryptionAlert).toBeInTheDocument(); + expect(encryptionAlert?.querySelector('[data-testid="alert-title"]')).toHaveTextContent( + "environments.surveys.share.anonymous_links.custom_single_use_id_title" + ); + }); + + test("shows link generation section when encryption is enabled", () => { + render(); + + expect(screen.getByTestId("number-input")).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.share.anonymous_links.generate_and_download_links") + ).toBeInTheDocument(); + }); + + test("handles number of links input change", async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByTestId("number-input"); + await user.clear(input); + await user.type(input, "5"); + + expect(input).toHaveValue(5); + }); + + test("handles link generation error", async () => { + const user = userEvent.setup(); + const { generateSingleUseIdsAction } = await import("@/modules/survey/list/actions"); + vi.mocked(generateSingleUseIdsAction).mockResolvedValue({ data: undefined }); + + render(); + + const generateButton = screen.getByText( + "environments.surveys.share.anonymous_links.generate_and_download_links" + ); + await user.click(generateButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + "environments.surveys.share.anonymous_links.generate_links_error" + ); + }); + }); + + test("handles action error with generic message", async () => { + const user = userEvent.setup(); + const { updateSingleUseLinksAction } = await import("../../actions"); + vi.mocked(updateSingleUseLinksAction).mockResolvedValue({ data: undefined }); + + render(); + + // Click multi-use toggle to show modal + const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch"); + await user.click(multiUseToggle); + + // Confirm the modal action + const confirmButton = screen.getByText("Confirm"); + await user.click(confirmButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again"); + }); + }); + + test("confirms modal action when disable link modal is confirmed", async () => { + const user = userEvent.setup(); + const { updateSingleUseLinksAction } = await import("../../actions"); + + render(); + + const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch"); + await user.click(multiUseToggle); + + const confirmButton = screen.getByText("Confirm"); + await user.click(confirmButton); + + await waitFor(() => { + expect(updateSingleUseLinksAction).toHaveBeenCalledWith({ + surveyId: "test-survey-id", + environmentId: "test-env-id", + isSingleUse: false, + isSingleUseEncryption: false, + }); + }); + }); + + test("renders documentation links", () => { + render(); + + expect(screen.getByTestId("documentation-links")).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.share.anonymous_links.single_use_links") + ).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.share.anonymous_links.data_prefilling") + ).toBeInTheDocument(); + }); + + test("shows read-only input with copy button when encryption is disabled", async () => { + // surveyWithSingleUse has encryption disabled + render(); + + // Check if single-use link is enabled + expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true"); + + // Check if encryption is disabled + expect(screen.getByTestId("toggle-single-use-encryption-switch")).toHaveAttribute( + "data-checked", + "false" + ); + + // Check for the custom URL display + const surveyUrlWithCustomSuid = `${defaultProps.surveyUrl}?suId=CUSTOM-ID`; + expect(screen.getByText(surveyUrlWithCustomSuid)).toBeInTheDocument(); + + // Check for the copy button and try to click it + const copyButton = screen.getByText("common.copy"); + expect(copyButton).toBeInTheDocument(); + await userEvent.click(copyButton); + + // check if toast is called + expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard"); + + // Check for the alert + expect( + screen.getByText("environments.surveys.share.anonymous_links.custom_single_use_id_title") + ).toBeInTheDocument(); + + // Ensure the number of links input is not visible + expect( + screen.queryByText("environments.surveys.share.anonymous_links.number_of_links_label") + ).not.toBeInTheDocument(); + }); + + test("hides read-only input with copy button when encryption is enabled", async () => { + // surveyWithEncryption has encryption enabled + render(); + + // Check if single-use link is enabled + expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true"); + + // Check if encryption is enabled + expect(screen.getByTestId("toggle-single-use-encryption-switch")).toHaveAttribute("data-checked", "true"); + + // Ensure the number of links input is visible + expect( + screen.getByText("environments.surveys.share.anonymous_links.number_of_links_label") + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.tsx new file mode 100644 index 000000000000..a0ec4d7c9a63 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab.tsx @@ -0,0 +1,364 @@ +"use client"; + +import { updateSingleUseLinksAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions"; +import { DisableLinkModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal"; +import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links"; +import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink"; +import { generateSingleUseIdsAction } from "@/modules/survey/list/actions"; +import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; +import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; +import { Button } from "@/modules/ui/components/button"; +import { Input } from "@/modules/ui/components/input"; +import { useTranslate } from "@tolgee/react"; +import { CirclePlayIcon, CopyIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUserLocale } from "@formbricks/types/user"; + +interface AnonymousLinksTabProps { + survey: TSurvey; + surveyUrl: string; + publicDomain: string; + setSurveyUrl: (url: string) => void; + locale: TUserLocale; + isReadOnly: boolean; +} + +export const AnonymousLinksTab = ({ + survey, + surveyUrl, + publicDomain, + setSurveyUrl, + locale, + isReadOnly, +}: AnonymousLinksTabProps) => { + const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`; + const router = useRouter(); + const { t } = useTranslate(); + + const [isMultiUseLink, setIsMultiUseLink] = useState(!survey.singleUse?.enabled); + const [isSingleUseLink, setIsSingleUseLink] = useState(survey.singleUse?.enabled ?? false); + const [singleUseEncryption, setSingleUseEncryption] = useState(survey.singleUse?.isEncrypted ?? false); + const [numberOfLinks, setNumberOfLinks] = useState(1); + + const [disableLinkModal, setDisableLinkModal] = useState<{ + open: boolean; + type: "multi-use" | "single-use"; + pendingAction: () => Promise | void; + } | null>(null); + + const resetState = () => { + const { singleUse } = survey; + const { enabled, isEncrypted } = singleUse ?? {}; + + setIsMultiUseLink(!enabled); + setIsSingleUseLink(enabled ?? false); + setSingleUseEncryption(isEncrypted ?? false); + }; + + const updateSingleUseSettings = async ( + isSingleUse: boolean, + isSingleUseEncryption: boolean + ): Promise => { + try { + const updatedSurveyResponse = await updateSingleUseLinksAction({ + surveyId: survey.id, + environmentId: survey.environmentId, + isSingleUse, + isSingleUseEncryption, + }); + + if (updatedSurveyResponse?.data) { + router.refresh(); + return; + } + + toast.error(t("common.something_went_wrong_please_try_again")); + resetState(); + } catch { + toast.error(t("common.something_went_wrong_please_try_again")); + resetState(); + } + }; + + const handleMultiUseToggle = async (newValue: boolean) => { + if (newValue) { + // Turning multi-use on - show confirmation modal if single-use is currently enabled + if (isSingleUseLink) { + setDisableLinkModal({ + open: true, + type: "single-use", + pendingAction: async () => { + setIsMultiUseLink(true); + setIsSingleUseLink(false); + setSingleUseEncryption(false); + await updateSingleUseSettings(false, false); + }, + }); + } else { + // Single-use is already off, just enable multi-use + setIsMultiUseLink(true); + setIsSingleUseLink(false); + setSingleUseEncryption(false); + await updateSingleUseSettings(false, false); + } + } else { + // Turning multi-use off - need confirmation and turn single-use on + setDisableLinkModal({ + open: true, + type: "multi-use", + pendingAction: async () => { + setIsMultiUseLink(false); + setIsSingleUseLink(true); + setSingleUseEncryption(true); + await updateSingleUseSettings(true, true); + }, + }); + } + }; + + const handleSingleUseToggle = async (newValue: boolean) => { + if (newValue) { + // Turning single-use on - turn multi-use off + setDisableLinkModal({ + open: true, + type: "multi-use", + pendingAction: async () => { + setIsMultiUseLink(false); + setIsSingleUseLink(true); + setSingleUseEncryption(true); + await updateSingleUseSettings(true, true); + }, + }); + } else { + // Turning single-use off - show confirmation modal and then turn multi-use on + setDisableLinkModal({ + open: true, + type: "single-use", + pendingAction: async () => { + setIsMultiUseLink(true); + setIsSingleUseLink(false); + setSingleUseEncryption(false); + await updateSingleUseSettings(false, false); + }, + }); + } + }; + + const handleSingleUseEncryptionToggle = async (newValue: boolean) => { + setSingleUseEncryption(newValue); + await updateSingleUseSettings(true, newValue); + }; + + const handleNumberOfLinksChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; + + if (inputValue === "") { + setNumberOfLinks(""); + return; + } + + const value = Number(inputValue); + + if (!isNaN(value)) { + setNumberOfLinks(value); + } + }; + + const handleGenerateLinks = async (count: number) => { + try { + const response = await generateSingleUseIdsAction({ + surveyId: survey.id, + isEncrypted: singleUseEncryption, + count, + }); + + if (!!response?.data?.length) { + const singleUseIds = response.data; + const surveyLinks = singleUseIds.map((singleUseId) => `${surveyUrl}?suId=${singleUseId}`); + + // Create content with just the links + const csvContent = surveyLinks.join("\n"); + + // Create and download the file + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.setAttribute("href", url); + link.setAttribute("download", `single-use-links-${survey.id}.csv`); + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + return; + } + + toast.error(t("environments.surveys.share.anonymous_links.generate_links_error")); + } catch (error) { + toast.error(t("environments.surveys.share.anonymous_links.generate_links_error")); + } + }; + + return ( + <> +
+
+ +
+ + +
+ + + {t("environments.surveys.share.anonymous_links.multi_use_powers_other_channels_title")} + + + {t( + "environments.surveys.share.anonymous_links.multi_use_powers_other_channels_description" + )} + + +
+
+
+ + +
+ + + {!singleUseEncryption ? ( +
+ + + {t("environments.surveys.share.anonymous_links.custom_single_use_id_title")} + + + {t("environments.surveys.share.anonymous_links.custom_single_use_id_description")} + + + +
+
+ {surveyUrlWithCustomSuid} +
+ + +
+
+ ) : null} + + {singleUseEncryption && ( +
+

+ {t("environments.surveys.share.anonymous_links.number_of_links_label")} +

+ +
+
+
+ +
+ + +
+
+
+ )} +
+
+
+ + +
+ {disableLinkModal && ( + setDisableLinkModal(null)} + type={disableLinkModal.type} + onDisable={() => { + disableLinkModal.pendingAction(); + setDisableLinkModal(null); + }} + /> + )} + + ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab.test.tsx new file mode 100644 index 000000000000..9162e7a5ef2e --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab.test.tsx @@ -0,0 +1,383 @@ +import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context"; +import { SurveyContextWrapper } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TActionClass, TActionClassNoCodeConfig } from "@formbricks/types/action-classes"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TProject } from "@formbricks/types/project"; +import { TBaseFilter, TSegment } from "@formbricks/types/segment"; +import { TSurvey, TSurveyWelcomeCard } from "@formbricks/types/surveys/types"; +import { AppTab } from "./app-tab"; + +// Mock Next.js Link component +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: any) => ( + + {children} + + ), +})); + +// Mock DocumentationLinksSection +vi.mock("./documentation-links-section", () => ({ + DocumentationLinksSection: ({ title, links }: { title: string; links: any[] }) => ( +
+

{title}

+ {links.map((link) => ( + + ))} +
+ ), +})); + +// Mock segment +const mockSegment: TSegment = { + id: "test-segment-id", + title: "Test Segment", + description: "Test segment description", + environmentId: "test-env-id", + createdAt: new Date(), + updatedAt: new Date(), + isPrivate: false, + filters: [ + { + id: "test-filter-id", + connector: "and", + resource: "contact", + attributeKey: "test-attribute-key", + attributeType: "string", + condition: "equals", + value: "test", + } as unknown as TBaseFilter, + ], + surveys: ["test-survey-id"], +}; + +// Mock action class +const mockActionClass: TActionClass = { + id: "test-action-id", + name: "Test Action", + type: "code", + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "test-env-id", + description: "Test action description", + noCodeConfig: null, + key: "test-action-key", +}; + +const mockNoCodeActionClass: TActionClass = { + id: "test-no-code-action-id", + name: "Test No Code Action", + type: "noCode", + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "test-env-id", + description: "Test no code action description", + noCodeConfig: { + type: "click", + elementSelector: { + cssSelector: ".test-button", + innerHtml: "Click me", + }, + } as TActionClassNoCodeConfig, + key: "test-no-code-action-key", +}; + +// Mock environment data +const mockEnvironment: TEnvironment = { + id: "test-env-id", + createdAt: new Date(), + updatedAt: new Date(), + type: "development", + projectId: "test-project-id", + appSetupCompleted: true, +}; + +// Mock project data +const mockProject = { + id: "test-project-id", + createdAt: new Date(), + updatedAt: new Date(), + organizationId: "test-org-id", + recontactDays: 7, + config: { + channel: "app", + industry: "saas", + }, + linkSurveyBranding: true, + styling: { + allowStyleOverwrite: true, + brandColor: { light: "#ffffff", dark: "#000000" }, + questionColor: { light: "#000000", dark: "#ffffff" }, + inputColor: { light: "#000000", dark: "#ffffff" }, + inputBorderColor: { light: "#cccccc", dark: "#444444" }, + cardBackgroundColor: { light: "#ffffff", dark: "#000000" }, + cardBorderColor: { light: "#cccccc", dark: "#444444" }, + highlightBorderColor: { light: "#007bff", dark: "#0056b3" }, + isDarkModeEnabled: false, + isLogoHidden: false, + hideProgressBar: false, + roundness: 8, + cardArrangement: { linkSurveys: "casual", appSurveys: "casual" }, + }, + inAppSurveyBranding: true, + placement: "bottomRight", + clickOutsideClose: true, + darkOverlay: false, + logo: { url: "test-logo.png", bgColor: "#ffffff" }, +} as TProject; + +// Mock survey data +const mockSurvey: TSurvey = { + id: "test-survey-id", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + type: "app", + environmentId: "test-env-id", + status: "inProgress", + displayOption: "displayOnce", + autoClose: null, + triggers: [{ actionClass: mockActionClass }], + recontactDays: null, + displayLimit: null, + welcomeCard: { enabled: false } as unknown as TSurveyWelcomeCard, + questions: [], + endings: [], + hiddenFields: { enabled: false }, + displayPercentage: null, + autoComplete: null, + segment: null, + languages: [], + showLanguageSwitch: false, + singleUse: { enabled: false, isEncrypted: false }, + projectOverwrites: null, + surveyClosedMessage: null, + delay: 0, + isVerifyEmailEnabled: false, + inlineTriggers: {}, +} as unknown as TSurvey; + +describe("AppTab", () => { + afterEach(() => { + cleanup(); + }); + + const renderWithProviders = (appSetupCompleted = true, surveyOverrides = {}, projectOverrides = {}) => { + const environmentWithSetup = { + ...mockEnvironment, + appSetupCompleted, + }; + + const surveyWithOverrides = { + ...mockSurvey, + ...surveyOverrides, + }; + + const projectWithOverrides = { + ...mockProject, + ...projectOverrides, + }; + + return render( + + + + + + ); + }; + + test("renders setup completed content when app setup is completed", () => { + renderWithProviders(true); + expect(screen.getByText("environments.surveys.summary.in_app.connection_title")).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.summary.in_app.connection_description") + ).toBeInTheDocument(); + }); + + test("renders setup required content when app setup is not completed", () => { + renderWithProviders(false); + expect(screen.getByText("environments.surveys.summary.in_app.no_connection_title")).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.summary.in_app.no_connection_description") + ).toBeInTheDocument(); + expect(screen.getByText("common.connect_formbricks")).toBeInTheDocument(); + }); + + test("displays correct wait time when survey has recontact days", () => { + renderWithProviders(true, { recontactDays: 5 }); + expect( + screen.getByText("5 environments.surveys.summary.in_app.display_criteria.time_based_days") + ).toBeInTheDocument(); + expect( + screen.getByText("(environments.surveys.summary.in_app.display_criteria.overwritten)") + ).toBeInTheDocument(); + }); + + test("displays correct wait time when survey has 1 recontact day", () => { + renderWithProviders(true, { recontactDays: 1 }); + expect( + screen.getByText("1 environments.surveys.summary.in_app.display_criteria.time_based_day") + ).toBeInTheDocument(); + expect( + screen.getByText("(environments.surveys.summary.in_app.display_criteria.overwritten)") + ).toBeInTheDocument(); + }); + + test("displays correct wait time when survey has 0 recontact days", () => { + renderWithProviders(true, { recontactDays: 0 }); + expect( + screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_always") + ).toBeInTheDocument(); + expect( + screen.getByText("(environments.surveys.summary.in_app.display_criteria.overwritten)") + ).toBeInTheDocument(); + }); + + test("displays project recontact days when survey has no recontact days", () => { + renderWithProviders(true, { recontactDays: null }, { recontactDays: 3 }); + expect( + screen.getByText("3 environments.surveys.summary.in_app.display_criteria.time_based_days") + ).toBeInTheDocument(); + }); + + test("displays always when project has 0 recontact days", () => { + renderWithProviders(true, { recontactDays: null }, { recontactDays: 0 }); + expect( + screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_always") + ).toBeInTheDocument(); + }); + + test("displays always when both survey and project have null recontact days", () => { + renderWithProviders(true, { recontactDays: null }, { recontactDays: null }); + expect( + screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_always") + ).toBeInTheDocument(); + }); + + test("displays correct display option for displayOnce", () => { + renderWithProviders(true, { displayOption: "displayOnce" }); + expect(screen.getByText("environments.surveys.edit.show_only_once")).toBeInTheDocument(); + }); + + test("displays correct display option for displayMultiple", () => { + renderWithProviders(true, { displayOption: "displayMultiple" }); + expect(screen.getByText("environments.surveys.edit.until_they_submit_a_response")).toBeInTheDocument(); + }); + + test("displays correct display option for respondMultiple", () => { + renderWithProviders(true, { displayOption: "respondMultiple" }); + expect( + screen.getByText("environments.surveys.edit.keep_showing_while_conditions_match") + ).toBeInTheDocument(); + }); + + test("displays correct display option for displaySome", () => { + renderWithProviders(true, { displayOption: "displaySome" }); + expect(screen.getByText("environments.surveys.edit.show_multiple_times")).toBeInTheDocument(); + }); + + test("displays everyone when survey has no segment", () => { + renderWithProviders(true, { segment: null }); + expect( + screen.getByText("environments.surveys.summary.in_app.display_criteria.everyone") + ).toBeInTheDocument(); + }); + + test("displays targeted when survey has segment with filters", () => { + renderWithProviders(true, { + segment: mockSegment, + }); + expect(screen.getByText("Test Segment")).toBeInTheDocument(); + }); + + test("displays segment title when survey has public segment with filters", () => { + const publicSegment = { ...mockSegment, isPrivate: false, title: "Public Segment" }; + renderWithProviders(true, { + segment: publicSegment, + }); + expect(screen.getByText("Public Segment")).toBeInTheDocument(); + }); + + test("displays targeted when survey has private segment with filters", () => { + const privateSegment = { ...mockSegment, isPrivate: true }; + renderWithProviders(true, { + segment: privateSegment, + }); + expect( + screen.getByText("environments.surveys.summary.in_app.display_criteria.targeted") + ).toBeInTheDocument(); + }); + + test("displays everyone when survey has segment with no filters", () => { + const emptySegment = { ...mockSegment, filters: [] }; + renderWithProviders(true, { + segment: emptySegment, + }); + expect( + screen.getByText("environments.surveys.summary.in_app.display_criteria.everyone") + ).toBeInTheDocument(); + }); + + test("displays code trigger description correctly", () => { + renderWithProviders(true, { triggers: [{ actionClass: mockActionClass }] }); + expect(screen.getByText("Test Action")).toBeInTheDocument(); + expect( + screen.getByText("(environments.surveys.summary.in_app.display_criteria.code_trigger)") + ).toBeInTheDocument(); + }); + + test("displays no-code trigger description correctly", () => { + renderWithProviders(true, { triggers: [{ actionClass: mockNoCodeActionClass }] }); + expect(screen.getByText("Test No Code Action")).toBeInTheDocument(); + expect( + screen.getByText( + "(environments.surveys.summary.in_app.display_criteria.no_code_trigger, environments.actions.click)" + ) + ).toBeInTheDocument(); + }); + + test("displays randomizer when displayPercentage is set", () => { + renderWithProviders(true, { displayPercentage: 25 }); + expect( + screen.getAllByText(/environments\.surveys\.summary\.in_app\.display_criteria\.randomizer/)[0] + ).toBeInTheDocument(); + }); + + test("does not display randomizer when displayPercentage is null", () => { + renderWithProviders(true, { displayPercentage: null }); + expect(screen.queryByText("Show to")).not.toBeInTheDocument(); + }); + + test("does not display randomizer when displayPercentage is 0", () => { + renderWithProviders(true, { displayPercentage: 0 }); + expect(screen.queryByText("Show to")).not.toBeInTheDocument(); + }); + + test("renders documentation links section", () => { + renderWithProviders(true); + expect(screen.getByTestId("documentation-links")).toBeInTheDocument(); + expect(screen.getByText("environments.surveys.summary.in_app.documentation_title")).toBeInTheDocument(); + }); + + test("renders all display criteria items", () => { + renderWithProviders(true); + expect( + screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_description") + ).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.summary.in_app.display_criteria.audience_description") + ).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.summary.in_app.display_criteria.trigger_description") + ).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.summary.in_app.display_criteria.recontact_description") + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab.tsx new file mode 100644 index 000000000000..8c963034bd04 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context"; +import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context"; +import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; +import { H4, InlineSmall, Small } from "@/modules/ui/components/typography"; +import { useTranslate } from "@tolgee/react"; +import { + CodeXmlIcon, + MousePointerClickIcon, + PercentIcon, + Repeat1Icon, + TimerResetIcon, + UsersIcon, +} from "lucide-react"; +import Link from "next/link"; +import { ReactNode, useMemo } from "react"; +import { TActionClass } from "@formbricks/types/action-classes"; +import { TSegment } from "@formbricks/types/segment"; +import { DocumentationLinksSection } from "./documentation-links-section"; + +const createDocumentationLinks = (t: ReturnType["t"]) => [ + { + href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#html", + title: t("environments.surveys.summary.in_app.html_embed"), + }, + { + href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#react-js", + title: t("environments.surveys.summary.in_app.javascript_sdk"), + }, + { + href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#swift", + title: t("environments.surveys.summary.in_app.ios_sdk"), + }, + { + href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#android", + title: t("environments.surveys.summary.in_app.kotlin_sdk"), + }, + { + href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#react-native", + title: t("environments.surveys.summary.in_app.react_native_sdk"), + }, +]; + +const createNoCodeConfigType = (t: ReturnType["t"]) => ({ + click: t("environments.actions.click"), + pageView: t("environments.actions.page_view"), + exitIntent: t("environments.actions.exit_intent"), + fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"), +}); + +const formatRecontactDaysString = (days: number, t: ReturnType["t"]) => { + if (days === 0) { + return t("environments.surveys.summary.in_app.display_criteria.time_based_always"); + } else if (days === 1) { + return `${days} ${t("environments.surveys.summary.in_app.display_criteria.time_based_day")}`; + } else { + return `${days} ${t("environments.surveys.summary.in_app.display_criteria.time_based_days")}`; + } +}; + +interface DisplayCriteriaItemProps { + icon: ReactNode; + title: ReactNode; + titleSuffix?: ReactNode; + description: ReactNode; +} + +const DisplayCriteriaItem = ({ icon, title, titleSuffix, description }: DisplayCriteriaItemProps) => { + return ( +
+
{icon}
+
+ + {title} {titleSuffix && {titleSuffix}} + +
+
+
+ + {description} + +
+
+ ); +}; + +export const AppTab = () => { + const { t } = useTranslate(); + const { environment, project } = useEnvironment(); + const { survey } = useSurvey(); + + const documentationLinks = useMemo(() => createDocumentationLinks(t), [t]); + const noCodeConfigType = useMemo(() => createNoCodeConfigType(t), [t]); + + const waitTime = () => { + if (survey.recontactDays !== null) { + return formatRecontactDaysString(survey.recontactDays, t); + } + if (project.recontactDays !== null) { + return formatRecontactDaysString(project.recontactDays, t); + } + return t("environments.surveys.summary.in_app.display_criteria.time_based_always"); + }; + + const displayOption = () => { + if (survey.displayOption === "displayOnce") { + return t("environments.surveys.edit.show_only_once"); + } else if (survey.displayOption === "displayMultiple") { + return t("environments.surveys.edit.until_they_submit_a_response"); + } else if (survey.displayOption === "respondMultiple") { + return t("environments.surveys.edit.keep_showing_while_conditions_match"); + } else if (survey.displayOption === "displaySome") { + return t("environments.surveys.edit.show_multiple_times"); + } + + // Default fallback for undefined or unexpected displayOption values + return t("environments.surveys.edit.show_only_once"); + }; + + const getTriggerDescription = ( + actionClass: TActionClass, + noCodeConfigTypeParam: ReturnType + ) => { + if (actionClass.type === "code") { + return `(${t("environments.surveys.summary.in_app.display_criteria.code_trigger")})`; + } else { + const configType = actionClass.noCodeConfig?.type; + let configTypeLabel = "unknown"; + + if (configType && configType in noCodeConfigTypeParam) { + configTypeLabel = noCodeConfigTypeParam[configType]; + } else if (configType) { + configTypeLabel = configType; + } + + return `(${t("environments.surveys.summary.in_app.display_criteria.no_code_trigger")}, ${configTypeLabel})`; + } + }; + + const getSegmentTitle = (segment: TSegment | null) => { + if (segment?.filters?.length && segment.filters.length > 0) { + return segment.isPrivate + ? t("environments.surveys.summary.in_app.display_criteria.targeted") + : segment.title; + } + return t("environments.surveys.summary.in_app.display_criteria.everyone"); + }; + + return ( +
+
+ + + {environment.appSetupCompleted + ? t("environments.surveys.summary.in_app.connection_title") + : t("environments.surveys.summary.in_app.no_connection_title")} + + + {environment.appSetupCompleted + ? t("environments.surveys.summary.in_app.connection_description") + : t("environments.surveys.summary.in_app.no_connection_description")} + + {!environment.appSetupCompleted && ( + + + {t("common.connect_formbricks")} + + + )} + + +
+

{t("environments.surveys.summary.in_app.display_criteria")}

+
+ } + title={waitTime()} + titleSuffix={ + survey.recontactDays !== null + ? `(${t("environments.surveys.summary.in_app.display_criteria.overwritten")})` + : undefined + } + description={t("environments.surveys.summary.in_app.display_criteria.time_based_description")} + /> + } + title={getSegmentTitle(survey.segment)} + description={t("environments.surveys.summary.in_app.display_criteria.audience_description")} + /> + {survey.triggers.map((trigger) => ( + + ) : ( + + ) + } + title={trigger.actionClass.name} + titleSuffix={getTriggerDescription(trigger.actionClass, noCodeConfigType)} + description={t("environments.surveys.summary.in_app.display_criteria.trigger_description")} + /> + ))} + {survey.displayPercentage !== null && survey.displayPercentage > 0 && ( + } + title={t("environments.surveys.summary.in_app.display_criteria.randomizer", { + percentage: survey.displayPercentage, + })} + description={t( + "environments.surveys.summary.in_app.display_criteria.randomizer_description", + { + percentage: survey.displayPercentage, + } + )} + /> + )} + } + title={displayOption()} + description={t("environments.surveys.summary.in_app.display_criteria.recontact_description")} + /> +
+
+
+ + +
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal.test.tsx new file mode 100644 index 000000000000..a05167cba859 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal.test.tsx @@ -0,0 +1,95 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { DisableLinkModal } from "./disable-link-modal"; + +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +const onOpenChange = vi.fn(); +const onDisable = vi.fn(); + +describe("DisableLinkModal", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("should render the modal for multi-use link", () => { + render( + + ); + + expect( + screen.getByText("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title") + ).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description") + ).toBeInTheDocument(); + expect( + screen.getByText( + "environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description_subtext" + ) + ).toBeInTheDocument(); + }); + + test("should render the modal for single-use link", () => { + render( + + ); + + expect(screen.getByText("common.are_you_sure")).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.share.anonymous_links.disable_single_use_link_modal_description") + ).toBeInTheDocument(); + }); + + test("should call onDisable and onOpenChange when the disable button is clicked for multi-use", async () => { + render( + + ); + + const disableButton = screen.getByText( + "environments.surveys.share.anonymous_links.disable_multi_use_link_modal_button" + ); + await userEvent.click(disableButton); + + expect(onDisable).toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + test("should call onDisable and onOpenChange when the disable button is clicked for single-use", async () => { + render( + + ); + + const disableButton = screen.getByText( + "environments.surveys.share.anonymous_links.disable_single_use_link_modal_button" + ); + await userEvent.click(disableButton); + + expect(onDisable).toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + test("should call onOpenChange when the cancel button is clicked", async () => { + render( + + ); + + const cancelButton = screen.getByText("common.cancel"); + await userEvent.click(cancelButton); + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + test("should not render the modal when open is false", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal.tsx new file mode 100644 index 000000000000..145da025e05f --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal.tsx @@ -0,0 +1,74 @@ +import { Button } from "@/modules/ui/components/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; +import { useTranslate } from "@tolgee/react"; + +interface DisableLinkModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + type: "multi-use" | "single-use"; + onDisable: () => void; +} + +export const DisableLinkModal = ({ open, onOpenChange, type, onDisable }: DisableLinkModalProps) => { + const { t } = useTranslate(); + + return ( + + + + + {type === "multi-use" + ? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title") + : t("common.are_you_sure")} + + + + + {type === "multi-use" ? ( + <> +

+ {t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description")} +

+ +
+ +

+ {t( + "environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description_subtext" + )} +

+ + ) : ( +

{t("environments.surveys.share.anonymous_links.disable_single_use_link_modal_description")}

+ )} +
+ + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links-section.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links-section.tsx new file mode 100644 index 000000000000..eef4afb604e3 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links-section.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert"; +import { H4 } from "@/modules/ui/components/typography"; +import { useTranslate } from "@tolgee/react"; +import { ArrowUpRight } from "lucide-react"; +import Link from "next/link"; + +interface DocumentationLink { + href: string; + title: string; +} + +interface DocumentationLinksSectionProps { + title: string; + links: DocumentationLink[]; +} + +export const DocumentationLinksSection = ({ title, links }: DocumentationLinksSectionProps) => { + const { t } = useTranslate(); + + return ( +
+

{title}

+ {links.map((link) => ( + + + {link.title} + + + {t("common.read_docs")} + + + + ))} +
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links.test.tsx new file mode 100644 index 000000000000..2ea06df099a7 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links.test.tsx @@ -0,0 +1,102 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { DocumentationLinks } from "./documentation-links"; + +describe("DocumentationLinks", () => { + afterEach(() => { + cleanup(); + }); + + const mockLinks = [ + { + title: "Getting Started Guide", + href: "https://docs.formbricks.com/getting-started", + }, + { + title: "API Documentation", + href: "https://docs.formbricks.com/api", + }, + { + title: "Integration Guide", + href: "https://docs.formbricks.com/integrations", + }, + ]; + + test("renders all documentation links", () => { + render(); + + expect(screen.getByText("Getting Started Guide")).toBeInTheDocument(); + expect(screen.getByText("API Documentation")).toBeInTheDocument(); + expect(screen.getByText("Integration Guide")).toBeInTheDocument(); + }); + + test("renders correct number of alert components", () => { + render(); + + const alerts = screen.getAllByRole("alert"); + expect(alerts).toHaveLength(3); + }); + + test("renders learn more links with correct href attributes", () => { + render(); + + const learnMoreLinks = screen.getAllByText("common.learn_more"); + expect(learnMoreLinks).toHaveLength(3); + + expect(learnMoreLinks[0]).toHaveAttribute("href", "https://docs.formbricks.com/getting-started"); + expect(learnMoreLinks[1]).toHaveAttribute("href", "https://docs.formbricks.com/api"); + expect(learnMoreLinks[2]).toHaveAttribute("href", "https://docs.formbricks.com/integrations"); + }); + + test("renders learn more links with target blank", () => { + render(); + + const learnMoreLinks = screen.getAllByText("common.learn_more"); + learnMoreLinks.forEach((link) => { + expect(link).toHaveAttribute("target", "_blank"); + }); + }); + + test("renders learn more links with correct CSS classes", () => { + render(); + + const learnMoreLinks = screen.getAllByText("common.learn_more"); + learnMoreLinks.forEach((link) => { + expect(link).toHaveClass("text-slate-900", "hover:underline"); + }); + }); + + test("renders empty list when no links provided", () => { + render(); + + const alerts = screen.queryAllByRole("alert"); + expect(alerts).toHaveLength(0); + }); + + test("renders single link correctly", () => { + const singleLink = [mockLinks[0]]; + render(); + + expect(screen.getByText("Getting Started Guide")).toBeInTheDocument(); + expect(screen.getByText("common.learn_more")).toBeInTheDocument(); + expect(screen.getByText("common.learn_more")).toHaveAttribute( + "href", + "https://docs.formbricks.com/getting-started" + ); + }); + + test("renders with correct container structure", () => { + const { container } = render(); + + const mainContainer = container.firstChild as HTMLElement; + expect(mainContainer).toHaveClass("flex", "w-full", "flex-col", "space-y-2"); + + const linkContainers = mainContainer.children; + expect(linkContainers).toHaveLength(3); + + Array.from(linkContainers).forEach((linkContainer) => { + expect(linkContainer).toHaveClass("flex", "w-full", "flex-col", "gap-3"); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links.tsx new file mode 100644 index 000000000000..55d652c1aff3 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert"; +import { useTranslate } from "@tolgee/react"; +import Link from "next/link"; + +interface DocumentationLinksProps { + links: { + title: string; + href: string; + }[]; +} + +export const DocumentationLinks = ({ links }: DocumentationLinksProps) => { + const { t } = useTranslate(); + + return ( +
+ {links.map((link) => ( +
+ + {link.title} + + + {t("common.learn_more")} + + + +
+ ))} +
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentationL-links-section.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentationL-links-section.test.tsx new file mode 100644 index 000000000000..a304ffc0d0af --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentationL-links-section.test.tsx @@ -0,0 +1,165 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { DocumentationLinksSection } from "./documentation-links-section"; + +// Mock the useTranslate hook +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => { + if (key === "common.read_docs") { + return "Read docs"; + } + return key; + }, + }), +})); + +// Mock Next.js Link component +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: any) => ( + + {children} + + ), +})); + +// Mock Alert components +vi.mock("@/modules/ui/components/alert", () => ({ + Alert: ({ children, size, variant }: any) => ( +
+ {children} +
+ ), + AlertButton: ({ children }: any) =>
{children}
, + AlertTitle: ({ children }: any) =>
{children}
, +})); + +// Mock Typography components +vi.mock("@/modules/ui/components/typography", () => ({ + H4: ({ children }: any) =>

{children}

, +})); + +// Mock lucide-react icons +vi.mock("lucide-react", () => ({ + ArrowUpRight: ({ className }: any) => , +})); + +describe("DocumentationLinksSection", () => { + afterEach(() => { + cleanup(); + }); + + const mockLinks = [ + { + href: "https://example.com/docs/html", + title: "HTML Documentation", + }, + { + href: "https://example.com/docs/react", + title: "React Documentation", + }, + { + href: "https://example.com/docs/javascript", + title: "JavaScript Documentation", + }, + ]; + + test("renders title correctly", () => { + render(); + + expect(screen.getByTestId("h4")).toHaveTextContent("Test Documentation Title"); + }); + + test("renders all documentation links", () => { + render(); + + expect(screen.getAllByTestId("alert")).toHaveLength(3); + expect(screen.getByText("HTML Documentation")).toBeInTheDocument(); + expect(screen.getByText("React Documentation")).toBeInTheDocument(); + expect(screen.getByText("JavaScript Documentation")).toBeInTheDocument(); + }); + + test("renders links with correct href attributes", () => { + render(); + + const links = screen.getAllByRole("link"); + expect(links[0]).toHaveAttribute("href", "https://example.com/docs/html"); + expect(links[1]).toHaveAttribute("href", "https://example.com/docs/react"); + expect(links[2]).toHaveAttribute("href", "https://example.com/docs/javascript"); + }); + + test("renders links with correct target and rel attributes", () => { + render(); + + const links = screen.getAllByRole("link"); + links.forEach((link) => { + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + }); + + test("renders read docs button for each link", () => { + render(); + + const readDocsButtons = screen.getAllByText("Read docs"); + expect(readDocsButtons).toHaveLength(3); + }); + + test("renders icons for each alert", () => { + render(); + + const icons = screen.getAllByTestId("arrow-up-right-icon"); + expect(icons).toHaveLength(3); + }); + + test("renders alerts with correct props", () => { + render(); + + const alerts = screen.getAllByTestId("alert"); + alerts.forEach((alert) => { + expect(alert).toHaveAttribute("data-size", "small"); + expect(alert).toHaveAttribute("data-variant", "default"); + }); + }); + + test("renders with empty links array", () => { + render(); + + expect(screen.getByTestId("h4")).toHaveTextContent("Test Documentation Title"); + expect(screen.queryByTestId("alert")).not.toBeInTheDocument(); + }); + + test("renders single link correctly", () => { + const singleLink = [ + { + href: "https://example.com/docs/single", + title: "Single Documentation", + }, + ]; + + render(); + + expect(screen.getAllByTestId("alert")).toHaveLength(1); + expect(screen.getByText("Single Documentation")).toBeInTheDocument(); + expect(screen.getByRole("link")).toHaveAttribute("href", "https://example.com/docs/single"); + }); + + test("renders with special characters in title and links", () => { + const specialLinks = [ + { + href: "https://example.com/docs/special?param=value&other=test", + title: "Special Characters & Symbols", + }, + ]; + + render(); + + expect(screen.getByTestId("h4")).toHaveTextContent("Special Title & Characters"); + expect(screen.getByText("Special Characters & Symbols")).toBeInTheDocument(); + expect(screen.getByRole("link")).toHaveAttribute( + "href", + "https://example.com/docs/special?param=value&other=test" + ); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab.test.tsx new file mode 100644 index 000000000000..f57a1466ba29 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab.test.tsx @@ -0,0 +1,217 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { DynamicPopupTab } from "./dynamic-popup-tab"; + +// Mock components +vi.mock("@/modules/ui/components/alert", () => ({ + Alert: (props: { variant?: string; size?: string; children: React.ReactNode }) => ( +
+ {props.children} +
+ ), + AlertButton: (props: { asChild?: boolean; children: React.ReactNode }) => ( +
+ {props.children} +
+ ), + AlertDescription: (props: { children: React.ReactNode }) => ( +
{props.children}
+ ), + AlertTitle: (props: { children: React.ReactNode }) =>
{props.children}
, +})); + +// Mock DocumentationLinks +vi.mock("./documentation-links", () => ({ + DocumentationLinks: (props: { links: Array<{ href: string; title: string }> }) => ( +
+ {props.links.map((link) => ( +
+ {link.title} +
+ ))} +
+ ), +})); + +// Mock Next.js Link +vi.mock("next/link", () => ({ + default: (props: { href: string; target?: string; className?: string; children: React.ReactNode }) => ( + + {props.children} + + ), +})); + +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +describe("DynamicPopupTab", () => { + afterEach(() => { + cleanup(); + }); + + const defaultProps = { + environmentId: "env-123", + surveyId: "survey-123", + }; + + test("renders with correct container structure", () => { + render(); + + const container = screen.getByTestId("dynamic-popup-container"); + expect(container).toHaveClass("flex", "h-full", "flex-col", "justify-between", "space-y-4"); + }); + + test("renders alert with correct props", () => { + render(); + + const alert = screen.getByTestId("alert"); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveAttribute("data-variant", "info"); + expect(alert).toHaveAttribute("data-size", "default"); + }); + + test("renders alert title with correct translation key", () => { + render(); + + const alertTitle = screen.getByTestId("alert-title"); + expect(alertTitle).toBeInTheDocument(); + expect(alertTitle).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_title"); + }); + + test("renders alert description with correct translation key", () => { + render(); + + const alertDescription = screen.getByTestId("alert-description"); + expect(alertDescription).toBeInTheDocument(); + expect(alertDescription).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_description"); + }); + + test("renders alert button with link to survey edit page", () => { + render(); + + const alertButton = screen.getByTestId("alert-button"); + expect(alertButton).toBeInTheDocument(); + expect(alertButton).toHaveAttribute("data-as-child", "true"); + + const link = screen.getByTestId("next-link"); + expect(link).toHaveAttribute("href", "/environments/env-123/surveys/survey-123/edit"); + expect(link).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_button"); + }); + + test("renders DocumentationLinks component", () => { + render(); + + const documentationLinks = screen.getByTestId("documentation-links"); + expect(documentationLinks).toBeInTheDocument(); + }); + + test("passes correct documentation links to DocumentationLinks component", () => { + render(); + + const documentationLinks = screen.getAllByTestId("documentation-link"); + expect(documentationLinks).toHaveLength(3); + + // Check attribute-based targeting link + const attributeLink = documentationLinks.find( + (link) => + link.getAttribute("data-href") === + "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting" + ); + expect(attributeLink).toBeInTheDocument(); + expect(attributeLink).toHaveAttribute( + "data-title", + "environments.surveys.share.dynamic_popup.attribute_based_targeting" + ); + + // Check code and no code triggers link + const actionsLink = documentationLinks.find( + (link) => + link.getAttribute("data-href") === + "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions" + ); + expect(actionsLink).toBeInTheDocument(); + expect(actionsLink).toHaveAttribute( + "data-title", + "environments.surveys.share.dynamic_popup.code_no_code_triggers" + ); + + // Check recontact options link + const recontactLink = documentationLinks.find( + (link) => + link.getAttribute("data-href") === + "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact" + ); + expect(recontactLink).toBeInTheDocument(); + expect(recontactLink).toHaveAttribute( + "data-title", + "environments.surveys.share.dynamic_popup.recontact_options" + ); + }); + + test("renders documentation links with correct titles", () => { + render(); + + const documentationLinks = screen.getAllByTestId("documentation-link"); + + const expectedTitles = [ + "environments.surveys.share.dynamic_popup.attribute_based_targeting", + "environments.surveys.share.dynamic_popup.code_no_code_triggers", + "environments.surveys.share.dynamic_popup.recontact_options", + ]; + + expectedTitles.forEach((title) => { + const link = documentationLinks.find((link) => link.getAttribute("data-title") === title); + expect(link).toBeInTheDocument(); + expect(link).toHaveTextContent(title); + }); + }); + + test("renders documentation links with correct URLs", () => { + render(); + + const documentationLinks = screen.getAllByTestId("documentation-link"); + + const expectedUrls = [ + "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting", + "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions", + "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact", + ]; + + expectedUrls.forEach((url) => { + const link = documentationLinks.find((link) => link.getAttribute("data-href") === url); + expect(link).toBeInTheDocument(); + }); + }); + + test("calls translation function for all text content", () => { + render(); + + // Check alert translations + expect(screen.getByTestId("alert-title")).toHaveTextContent( + "environments.surveys.share.dynamic_popup.alert_title" + ); + expect(screen.getByTestId("alert-description")).toHaveTextContent( + "environments.surveys.share.dynamic_popup.alert_description" + ); + expect(screen.getByTestId("next-link")).toHaveTextContent( + "environments.surveys.share.dynamic_popup.alert_button" + ); + }); + + test("renders with correct props when environmentId and surveyId change", () => { + const newProps = { + environmentId: "env-456", + surveyId: "survey-456", + }; + + render(); + + const link = screen.getByTestId("next-link"); + expect(link).toHaveAttribute("href", "/environments/env-456/surveys/survey-456/edit"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab.tsx new file mode 100644 index 000000000000..bbb81a10d51d --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links"; +import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; +import { useTranslate } from "@tolgee/react"; +import Link from "next/link"; + +interface DynamicPopupTabProps { + environmentId: string; + surveyId: string; +} + +export const DynamicPopupTab = ({ environmentId, surveyId }: DynamicPopupTabProps) => { + const { t } = useTranslate(); + + return ( +
+ + {t("environments.surveys.share.dynamic_popup.alert_title")} + {t("environments.surveys.share.dynamic_popup.alert_description")} + + + {t("environments.surveys.share.dynamic_popup.alert_button")} + + + + + +
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab.test.tsx new file mode 100644 index 000000000000..dbc8b3ceb924 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab.test.tsx @@ -0,0 +1,294 @@ +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { AuthenticationError } from "@formbricks/types/errors"; +import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions"; +import { EmailTab } from "./email-tab"; + +// Mock actions +vi.mock("../../actions", () => ({ + getEmailHtmlAction: vi.fn(), + sendEmbedSurveyPreviewEmailAction: vi.fn(), +})); + +// Mock helper +vi.mock("@/lib/utils/helper", () => ({ + getFormattedErrorMessage: vi.fn((val) => val?.serverError || "Formatted error message"), +})); + +// Mock UI components +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick, variant, title, "aria-label": ariaLabel, ...props }: any) => ( + + ), +})); +vi.mock("@/modules/ui/components/code-block", () => ({ + CodeBlock: ({ + children, + language, + showCopyToClipboard, + }: { + children: React.ReactNode; + language: string; + showCopyToClipboard?: boolean; + }) => ( +
+ {children} +
+ ), +})); +vi.mock("@/modules/ui/components/loading-spinner", () => ({ + LoadingSpinner: () =>
LoadingSpinner
, +})); + +// Mock lucide-react icons +vi.mock("lucide-react", () => ({ + Code2Icon: () =>
, + CopyIcon: () =>
, + EyeIcon: () =>
, + MailIcon: () =>
, + SendIcon: () =>
, +})); + +// Mock navigator.clipboard +const mockWriteText = vi.fn().mockResolvedValue(undefined); +Object.defineProperty(navigator, "clipboard", { + value: { + writeText: mockWriteText, + }, + configurable: true, +}); + +const surveyId = "test-survey-id"; +const userEmail = "test@example.com"; +const mockEmailHtmlPreview = "

Hello World ?preview=true&foo=bar

"; +const mockCleanedEmailHtml = "

Hello World ?foo=bar

"; + +describe("EmailTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getEmailHtmlAction).mockResolvedValue({ data: mockEmailHtmlPreview }); + }); + + afterEach(() => { + cleanup(); + }); + + test("renders initial state correctly and fetches email HTML", async () => { + render(); + + expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalledWith({ surveyId }); + + // Buttons + expect( + screen.getByRole("button", { name: "environments.surveys.share.send_email.send_preview_email" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "environments.surveys.share.send_email.embed_code_tab" }) + ).toBeInTheDocument(); + expect(screen.getByTestId("send-icon")).toBeInTheDocument(); + // Note: code2-icon is only visible in the embed code tab, not in initial render + + // Email preview section + await waitFor(() => { + const emailToElements = screen.getAllByText((content, element) => { + return ( + element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false + ); + }); + expect(emailToElements.length).toBeGreaterThan(0); + }); + expect( + screen.getAllByText((content, element) => { + return ( + element?.textContent?.includes("environments.surveys.share.send_email.email_subject_label") || false + ); + }).length + ).toBeGreaterThan(0); + expect( + screen.getAllByText((content, element) => { + return ( + element?.textContent?.includes( + "environments.surveys.share.send_email.formbricks_email_survey_preview" + ) || false + ); + }).length + ).toBeGreaterThan(0); + await waitFor(() => { + expect(screen.getByText("Hello World ?foo=bar")).toBeInTheDocument(); // HTML content rendered as text (preview=true removed) + }); + expect(screen.queryByTestId("code-block")).not.toBeInTheDocument(); + }); + + test("toggles embed code view", async () => { + render(); + await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled()); + + const viewEmbedButton = screen.getByRole("button", { + name: "environments.surveys.share.send_email.embed_code_tab", + }); + await userEvent.click(viewEmbedButton); + + // Embed code view + expect( + screen.getByRole("button", { name: "environments.surveys.share.send_email.copy_embed_code" }) + ).toBeInTheDocument(); + expect(screen.getByTestId("copy-icon")).toBeInTheDocument(); + const codeBlock = screen.getByTestId("code-block"); + expect(codeBlock).toBeInTheDocument(); + expect(codeBlock).toHaveTextContent(mockCleanedEmailHtml); // Cleaned HTML + // The email_to_label should not be visible in embed code view + expect( + screen.queryByText((content, element) => { + return ( + element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false + ); + }) + ).not.toBeInTheDocument(); + + // Toggle back to preview + const previewButton = screen.getByRole("button", { + name: "environments.surveys.share.send_email.email_preview_tab", + }); + await userEvent.click(previewButton); + + expect( + screen.getByRole("button", { name: "environments.surveys.share.send_email.send_preview_email" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "environments.surveys.share.send_email.embed_code_tab" }) + ).toBeInTheDocument(); + await waitFor(() => { + const emailToElements = screen.getAllByText((content, element) => { + return ( + element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false + ); + }); + expect(emailToElements.length).toBeGreaterThan(0); + }); + expect(screen.queryByTestId("code-block")).not.toBeInTheDocument(); + }); + + test("copies code to clipboard", async () => { + render(); + await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled()); + + const viewEmbedButton = screen.getByRole("button", { + name: "environments.surveys.share.send_email.embed_code_tab", + }); + await userEvent.click(viewEmbedButton); + + const copyCodeButton = screen.getByRole("button", { + name: "environments.surveys.share.send_email.copy_embed_code", + }); + await userEvent.click(copyCodeButton); + + expect(mockWriteText).toHaveBeenCalledWith(mockCleanedEmailHtml); + expect(toast.success).toHaveBeenCalledWith( + "environments.surveys.share.send_email.embed_code_copied_to_clipboard" + ); + }); + + test("sends preview email successfully", async () => { + vi.mocked(sendEmbedSurveyPreviewEmailAction).mockResolvedValue({ data: true }); + render(); + await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled()); + + const sendPreviewButton = screen.getByRole("button", { + name: "environments.surveys.share.send_email.send_preview_email", + }); + await userEvent.click(sendPreviewButton); + + expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId }); + expect(toast.success).toHaveBeenCalledWith("environments.surveys.share.send_email.email_sent"); + }); + + test("handles send preview email failure (server error)", async () => { + const errorResponse = { serverError: "Server issue" }; + vi.mocked(sendEmbedSurveyPreviewEmailAction).mockResolvedValue(errorResponse as any); + render(); + await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled()); + + const sendPreviewButton = screen.getByRole("button", { + name: "environments.surveys.share.send_email.send_preview_email", + }); + await userEvent.click(sendPreviewButton); + + expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId }); + expect(getFormattedErrorMessage).toHaveBeenCalledWith(errorResponse); + expect(toast.error).toHaveBeenCalledWith("Server issue"); + }); + + test("handles send preview email failure (authentication error)", async () => { + vi.mocked(sendEmbedSurveyPreviewEmailAction).mockRejectedValue(new AuthenticationError("Auth failed")); + render(); + await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled()); + + const sendPreviewButton = screen.getByRole("button", { + name: "environments.surveys.share.send_email.send_preview_email", + }); + await userEvent.click(sendPreviewButton); + + expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId }); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("common.not_authenticated"); + }); + }); + + test("handles send preview email failure (generic error)", async () => { + vi.mocked(sendEmbedSurveyPreviewEmailAction).mockRejectedValue(new Error("Generic error")); + render(); + await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled()); + + const sendPreviewButton = screen.getByRole("button", { + name: "environments.surveys.share.send_email.send_preview_email", + }); + await userEvent.click(sendPreviewButton); + + expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId }); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again"); + }); + }); + + test("renders loading spinner if email HTML is not yet fetched", () => { + vi.mocked(getEmailHtmlAction).mockReturnValue(new Promise(() => {})); // Never resolves + render(); + expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); + }); + + test("renders default email if email prop is not provided", async () => { + render(); + await waitFor(() => { + expect( + screen.getByText((content, element) => { + return ( + element?.textContent === "environments.surveys.share.send_email.email_to_label : user@mail.com" + ); + }) + ).toBeInTheDocument(); + }); + }); + + test("emailHtml memo removes various ?preview=true patterns", async () => { + const htmlWithVariants = + "

Test1 ?preview=true

Test2 ?preview=true&next

Test3 ?preview=true&;next

"; + const expectedCleanHtml = "

Test1

Test2 ?next

Test3 ?next

"; + vi.mocked(getEmailHtmlAction).mockResolvedValue({ data: htmlWithVariants }); + + render(); + await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled()); + + const viewEmbedButton = screen.getByRole("button", { + name: "environments.surveys.share.send_email.embed_code_tab", + }); + await userEvent.click(viewEmbedButton); + + const codeBlock = screen.getByTestId("code-block"); + expect(codeBlock).toHaveTextContent(expectedCleanHtml); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab.tsx new file mode 100644 index 000000000000..4ccd6cbbe649 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { Button } from "@/modules/ui/components/button"; +import { CodeBlock } from "@/modules/ui/components/code-block"; +import { LoadingSpinner } from "@/modules/ui/components/loading-spinner"; +import { TabBar } from "@/modules/ui/components/tab-bar"; +import { useTranslate } from "@tolgee/react"; +import DOMPurify from "dompurify"; +import { CopyIcon, SendIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import { AuthenticationError } from "@formbricks/types/errors"; +import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions"; + +interface EmailTabProps { + surveyId: string; + email: string; +} + +export const EmailTab = ({ surveyId, email }: EmailTabProps) => { + const [activeTab, setActiveTab] = useState("preview"); + const [emailHtmlPreview, setEmailHtmlPreview] = useState(""); + const { t } = useTranslate(); + + const emailHtml = useMemo(() => { + if (!emailHtmlPreview) return ""; + return emailHtmlPreview + .replaceAll("?preview=true&", "?") + .replaceAll("?preview=true&;", "?") + .replaceAll("?preview=true", ""); + }, [emailHtmlPreview]); + + const tabs = [ + { + id: "preview", + label: t("environments.surveys.share.send_email.email_preview_tab"), + }, + { + id: "embed", + label: t("environments.surveys.share.send_email.embed_code_tab"), + }, + ]; + + useEffect(() => { + const getData = async () => { + const emailHtml = await getEmailHtmlAction({ surveyId }); + setEmailHtmlPreview(emailHtml?.data || ""); + }; + + getData(); + }, [surveyId]); + + const sendPreviewEmail = async () => { + try { + const val = await sendEmbedSurveyPreviewEmailAction({ surveyId }); + if (val?.data) { + toast.success(t("environments.surveys.share.send_email.email_sent")); + } else { + const errorMessage = getFormattedErrorMessage(val); + toast.error(errorMessage); + } + } catch (err) { + if (err instanceof AuthenticationError) { + toast.error(t("common.not_authenticated")); + return; + } + toast.error(t("common.something_went_wrong_please_try_again")); + } + }; + + const renderTabContent = () => { + if (activeTab === "preview") { + return ( +
+
+
+
+
+
+
+
+
+ {t("environments.surveys.share.send_email.email_to_label")} : {email || "user@mail.com"} +
+
+ {t("environments.surveys.share.send_email.email_subject_label")} :{" "} + {t("environments.surveys.share.send_email.formbricks_email_survey_preview")} +
+
+ {emailHtml ? ( +
+ ) : ( + + )} +
+
+
+ +
+ ); + } + + if (activeTab === "embed") { + return ( +
+ + {emailHtml} + + +
+ ); + } + + return null; + }; + + return ( +
+ +
{renderTabContent()}
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.test.tsx new file mode 100644 index 000000000000..e7e0ad8d8304 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.test.tsx @@ -0,0 +1,526 @@ +import { generatePersonalLinksAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import toast from "react-hot-toast"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { PersonalLinksTab } from "./personal-links-tab"; + +// Mock dependencies +vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions", () => ({ + generatePersonalLinksAction: vi.fn(), +})); + +vi.mock("react-hot-toast", () => ({ + default: { + loading: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/lib/utils/helper", () => ({ + getFormattedErrorMessage: vi.fn(), +})); + +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +// Mock UI components +vi.mock("@/modules/ui/components/alert", () => ({ + Alert: ({ children, variant }: any) => ( +
+ {children} +
+ ), + AlertButton: ({ children }: any) =>
{children}
, + AlertDescription: ({ children }: any) =>
{children}
, + AlertTitle: ({ children }: any) =>
{children}
, +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ children, onClick, disabled, loading, className, ...props }: any) => ( + + ), +})); + +vi.mock("@/modules/ui/components/date-picker", () => ({ + DatePicker: ({ date, updateSurveyDate, minDate, onClearDate }: any) => ( +
+ { + const newDate = e.target.value ? new Date(e.target.value) : null; + updateSurveyDate(newDate); + }} + /> + +
{minDate ? minDate.toISOString() : ""}
+
+ ), +})); + +vi.mock("@/modules/ui/components/select", () => { + let globalOnValueChange: ((value: string) => void) | null = null; + + return { + Select: ({ children, value, onValueChange, disabled }: any) => { + globalOnValueChange = onValueChange; + return ( +
+
{value || "Select option"}
+ {children} +
+ ); + }, + SelectContent: ({ children }: any) =>
{children}
, + SelectItem: ({ children, value }: any) => ( +
{ + if (globalOnValueChange) { + globalOnValueChange(value); + } + }}> + {children} +
+ ), + SelectTrigger: ({ children, className }: any) => ( +
+ {children} +
+ ), + SelectValue: ({ placeholder }: any) =>
{placeholder}
, + }; +}); + +// Mock icons +vi.mock("lucide-react", () => ({ + DownloadIcon: () =>
, + KeyIcon: () =>
, +})); + +// Mock Next.js Link +vi.mock("next/link", () => ({ + default: ({ children, href, target, rel }: any) => ( + + {children} + + ), +})); + +const mockGeneratePersonalLinksAction = vi.mocked(generatePersonalLinksAction); +const mockToast = vi.mocked(toast); +const mockGetFormattedErrorMessage = vi.mocked(getFormattedErrorMessage); + +// Mock segments data +const mockSegments = [ + { + id: "segment1", + title: "Public Segment 1", + isPrivate: false, + description: "Test segment 1", + filters: [], + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + surveys: [], + }, + { + id: "segment2", + title: "Public Segment 2", + isPrivate: false, + description: "Test segment 2", + filters: [], + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + surveys: [], + }, + { + id: "segment3", + title: "Private Segment", + isPrivate: true, + description: "Test private segment", + filters: [], + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + surveys: [], + }, +]; + +const defaultProps = { + environmentId: "test-env-id", + surveyId: "test-survey-id", + segments: mockSegments, + isContactsEnabled: true, + isFormbricksCloud: false, +}; + +// Helper function to trigger select change +const selectOption = (value: string) => { + const selectItems = screen.getAllByTestId("select-item"); + const targetItem = selectItems.find((item) => item.getAttribute("data-value") === value); + if (targetItem) { + fireEvent.click(targetItem); + } +}; + +describe("PersonalLinksTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + test("renders recipients section with segment selection", () => { + render(); + + expect(screen.getByText("common.recipients")).toBeInTheDocument(); + expect(screen.getByTestId("select")).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.share.personal_links.create_and_manage_segments") + ).toBeInTheDocument(); + }); + + test("renders expiry date section with date picker", () => { + render(); + + expect( + screen.getByText("environments.surveys.share.personal_links.expiry_date_optional") + ).toBeInTheDocument(); + expect(screen.getByTestId("date-picker")).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.share.personal_links.expiry_date_description") + ).toBeInTheDocument(); + }); + + test("renders generate button with correct initial state", () => { + render(); + + const button = screen.getByTestId("button"); + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + expect( + screen.getByText("environments.surveys.share.personal_links.generate_and_download_links") + ).toBeInTheDocument(); + expect(screen.getByTestId("download-icon")).toBeInTheDocument(); + }); + + test("renders info alert with correct content", () => { + render(); + + expect(screen.getByTestId("alert")).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.share.personal_links.work_with_segments") + ).toBeInTheDocument(); + expect(screen.getByTestId("link")).toHaveAttribute( + "href", + "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration" + ); + }); + + test("filters out private segments and shows only public segments", () => { + render(); + + const selectItems = screen.getAllByTestId("select-item"); + expect(selectItems).toHaveLength(2); // Only public segments + expect(selectItems[0]).toHaveTextContent("Public Segment 1"); + expect(selectItems[1]).toHaveTextContent("Public Segment 2"); + }); + + test("shows no segments message when no public segments available", () => { + const propsWithPrivateSegments = { + ...defaultProps, + segments: [mockSegments[2]], // Only private segment + }; + + render(); + + expect( + screen.getByText("environments.surveys.share.personal_links.no_segments_available") + ).toBeInTheDocument(); + expect(screen.getByTestId("select")).toHaveAttribute("data-disabled", "true"); + expect(screen.getByTestId("button")).toBeDisabled(); + }); + + test("enables button when segment is selected", () => { + render(); + + // Initially disabled + expect(screen.getByTestId("button")).toBeDisabled(); + + // Select a segment + selectOption("segment1"); + + // Should now be enabled + expect(screen.getByTestId("button")).not.toBeDisabled(); + }); + + test("handles date selection correctly", () => { + render(); + + const dateInput = screen.getByTestId("date-input"); + const testDate = "2024-12-31"; + + fireEvent.change(dateInput, { target: { value: testDate } }); + + expect(dateInput).toHaveValue(testDate); + }); + + test("clears date when clear button is clicked", () => { + render(); + + const dateInput = screen.getByTestId("date-input"); + const clearButton = screen.getByTestId("clear-date"); + + // Set a date first + fireEvent.change(dateInput, { target: { value: "2024-12-31" } }); + + // Clear the date + fireEvent.click(clearButton); + + expect(dateInput).toHaveValue(""); + }); + + test("sets minimum date to tomorrow", () => { + render(); + + const minDateElement = screen.getByTestId("min-date"); + // Should have some ISO date string for a future date + expect(minDateElement.textContent).toMatch(/\d{4}-\d{2}-\d{2}T/); + }); + + test("successfully generates and downloads links", async () => { + const mockResult = { + data: { + downloadUrl: "https://example.com/download/file.csv", + fileName: "personal-links.csv", + count: 5, + }, + }; + mockGeneratePersonalLinksAction.mockResolvedValue(mockResult); + + render(); + + // Select a segment + selectOption("segment1"); + + // Click generate button + const generateButton = screen.getByTestId("button"); + fireEvent.click(generateButton); + + // Verify action was called + await waitFor(() => { + expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({ + surveyId: "test-survey-id", + segmentId: "segment1", + environmentId: "test-env-id", + expirationDays: undefined, + }); + }); + + // Verify loading toast + expect(mockToast.loading).toHaveBeenCalledWith( + "environments.surveys.share.personal_links.generating_links_toast", + { + duration: 5000, + id: "generating-links", + } + ); + }); + + test("generates links with expiry date when date is selected", async () => { + const mockResult = { + data: { + downloadUrl: "https://example.com/download/file.csv", + fileName: "personal-links.csv", + count: 3, + }, + }; + mockGeneratePersonalLinksAction.mockResolvedValue(mockResult); + + render(); + + // Select a segment + selectOption("segment1"); + + // Set expiry date (10 days from now) + const dateInput = screen.getByTestId("date-input"); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 10); + const expiryDate = futureDate.toISOString().split("T")[0]; + fireEvent.change(dateInput, { target: { value: expiryDate } }); + + // Click generate button + const generateButton = screen.getByTestId("button"); + fireEvent.click(generateButton); + + await waitFor(() => { + expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({ + surveyId: "test-survey-id", + segmentId: "segment1", + environmentId: "test-env-id", + expirationDays: expect.any(Number), + }); + }); + + // Verify that expirationDays is a reasonable value (between 9-10 days) + const callArgs = mockGeneratePersonalLinksAction.mock.calls[0][0]; + expect(callArgs.expirationDays).toBeGreaterThanOrEqual(9); + expect(callArgs.expirationDays).toBeLessThanOrEqual(10); + }); + + test("handles error response from generatePersonalLinksAction", async () => { + const mockErrorResult = { + serverError: "Test error message", + }; + mockGeneratePersonalLinksAction.mockResolvedValue(mockErrorResult); + mockGetFormattedErrorMessage.mockReturnValue("Formatted error message"); + + render(); + + // Select a segment + selectOption("segment1"); + + // Click generate button + const generateButton = screen.getByTestId("button"); + fireEvent.click(generateButton); + + // Wait for the action to be called + await waitFor(() => { + expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({ + surveyId: "test-survey-id", + segmentId: "segment1", + environmentId: "test-env-id", + expirationDays: undefined, + }); + }); + + // Wait for error handling + await waitFor(() => { + expect(mockGetFormattedErrorMessage).toHaveBeenCalledWith(mockErrorResult); + expect(mockToast.error).toHaveBeenCalledWith("Formatted error message", { + duration: 5000, + id: "generating-links", + }); + }); + }); + + test("shows generating state when triggered", async () => { + // Mock a promise that resolves quickly + const mockResult = { data: { downloadUrl: "test", fileName: "test.csv", count: 1 } }; + mockGeneratePersonalLinksAction.mockResolvedValue(mockResult); + + render(); + + // Select a segment + selectOption("segment1"); + + // Click generate button + const generateButton = screen.getByTestId("button"); + fireEvent.click(generateButton); + + // Verify loading toast is called + expect(mockToast.loading).toHaveBeenCalledWith( + "environments.surveys.share.personal_links.generating_links_toast", + { + duration: 5000, + id: "generating-links", + } + ); + }); + + test("button is disabled when no segment is selected", () => { + render(); + + const generateButton = screen.getByTestId("button"); + expect(generateButton).toBeDisabled(); + }); + + test("button is disabled when no public segments are available", () => { + const propsWithNoPublicSegments = { + ...defaultProps, + segments: [mockSegments[2]], // Only private segments + }; + + render(); + + const generateButton = screen.getByTestId("button"); + expect(generateButton).toBeDisabled(); + }); + + test("handles empty segments array", () => { + const propsWithEmptySegments = { + ...defaultProps, + segments: [], + }; + + render(); + + expect( + screen.getByText("environments.surveys.share.personal_links.no_segments_available") + ).toBeInTheDocument(); + expect(screen.getByTestId("button")).toBeDisabled(); + }); + + test("calculates expiration days correctly for different dates", async () => { + const mockResult = { + data: { + downloadUrl: "https://example.com/download/file.csv", + fileName: "test.csv", + count: 1, + }, + }; + mockGeneratePersonalLinksAction.mockResolvedValue(mockResult); + + render(); + + // Select a segment + selectOption("segment1"); + + // Set expiry date to 5 days from now + const dateInput = screen.getByTestId("date-input"); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 5); + const expiryDate = futureDate.toISOString().split("T")[0]; + fireEvent.change(dateInput, { target: { value: expiryDate } }); + + // Click generate button + const generateButton = screen.getByTestId("button"); + fireEvent.click(generateButton); + + await waitFor(() => { + expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({ + surveyId: "test-survey-id", + segmentId: "segment1", + environmentId: "test-env-id", + expirationDays: expect.any(Number), + }); + }); + + // Verify that expirationDays is a reasonable value (between 4-5 days) + const callArgs = mockGeneratePersonalLinksAction.mock.calls[0][0]; + expect(callArgs.expirationDays).toBeGreaterThanOrEqual(4); + expect(callArgs.expirationDays).toBeLessThanOrEqual(5); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx new file mode 100644 index 000000000000..123f3517295d --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { Button } from "@/modules/ui/components/button"; +import { DatePicker } from "@/modules/ui/components/date-picker"; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormProvider, +} from "@/modules/ui/components/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/ui/components/select"; +import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt"; +import { useTranslate } from "@tolgee/react"; +import { DownloadIcon } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import toast from "react-hot-toast"; +import { TSegment } from "@formbricks/types/segment"; +import { generatePersonalLinksAction } from "../../actions"; + +interface PersonalLinksTabProps { + environmentId: string; + surveyId: string; + segments: TSegment[]; + isContactsEnabled: boolean; + isFormbricksCloud: boolean; +} + +interface PersonalLinksFormData { + selectedSegment: string; + expiryDate: Date | null; +} + +// Custom DatePicker component with date restrictions +const RestrictedDatePicker = ({ + date, + updateSurveyDate, +}: { + date: Date | null; + updateSurveyDate: (date: Date | null) => void; +}) => { + // Get tomorrow's date + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + + const handleDateUpdate = (date: Date) => { + updateSurveyDate(date); + }; + + return ( + updateSurveyDate(null)} + /> + ); +}; + +export const PersonalLinksTab = ({ + environmentId, + segments, + surveyId, + isContactsEnabled, + isFormbricksCloud, +}: PersonalLinksTabProps) => { + const { t } = useTranslate(); + + const form = useForm({ + defaultValues: { + selectedSegment: "", + expiryDate: null, + }, + }); + + const { watch } = form; + const selectedSegment = watch("selectedSegment"); + const expiryDate = watch("expiryDate"); + + const [isGenerating, setIsGenerating] = useState(false); + const publicSegments = segments.filter((segment) => !segment.isPrivate); + + // Utility function for file downloads + const downloadFile = (url: string, filename: string) => { + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleGenerateLinks = async () => { + if (!selectedSegment || isGenerating) return; + + setIsGenerating(true); + + // Show initial toast + toast.loading(t("environments.surveys.share.personal_links.generating_links_toast"), { + duration: 5000, + id: "generating-links", + }); + + const result = await generatePersonalLinksAction({ + surveyId: surveyId, + segmentId: selectedSegment, + environmentId: environmentId, + expirationDays: expiryDate + ? Math.max(1, Math.floor((expiryDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))) + : undefined, + }); + + if (result?.data) { + downloadFile(result.data.downloadUrl, result.data.fileName || "personal-links.csv"); + toast.success(t("environments.surveys.share.personal_links.links_generated_success_toast"), { + duration: 5000, + id: "generating-links", + }); + } else { + const errorMessage = getFormattedErrorMessage(result); + toast.error(errorMessage, { + duration: 5000, + id: "generating-links", + }); + } + setIsGenerating(false); + }; + + // Button state logic + const isButtonDisabled = !selectedSegment || isGenerating || publicSegments.length === 0; + const buttonText = isGenerating + ? t("environments.surveys.share.personal_links.generating_links") + : t("environments.surveys.share.personal_links.generate_and_download_links"); + + if (!isContactsEnabled) { + return ( + + ); + } + + return ( +
+ +
+ {/* Recipients Section */} + ( + + {t("common.recipients")} + + + + + {t("environments.surveys.share.personal_links.create_and_manage_segments")} + + + )} + /> + + {/* Expiry Date Section */} + ( + + {t("environments.surveys.share.personal_links.expiry_date_optional")} + + + + + {t("environments.surveys.share.personal_links.expiry_date_description")} + + + )} + /> + + {/* Generate Button */} + +
+
+ +
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab.test.tsx new file mode 100644 index 000000000000..c8492f5f4280 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab.test.tsx @@ -0,0 +1,284 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { QRCodeTab } from "./qr-code-tab"; + +// Mock the QR code options utility +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options", + () => ({ + getQRCodeOptions: vi.fn((width: number, height: number) => ({ + width, + height, + type: "svg", + data: "", + margin: 0, + qrOptions: { + typeNumber: 0, + mode: "Byte", + errorCorrectionLevel: "L", + }, + imageOptions: { + saveAsBlob: true, + hideBackgroundDots: false, + imageSize: 0, + margin: 0, + }, + dotsOptions: { + type: "extra-rounded", + color: "#000000", + roundSize: true, + }, + backgroundOptions: { + color: "#ffffff", + }, + cornersSquareOptions: { + type: "dot", + color: "#000000", + }, + cornersDotOptions: { + type: "dot", + color: "#000000", + }, + })), + }) +); + +// Mock UI components +vi.mock("@/modules/ui/components/alert", () => ({ + Alert: ({ children, variant }: { children: React.ReactNode; variant?: string }) => ( +
+ {children} +
+ ), + AlertDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AlertTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ + children, + onClick, + disabled, + variant, + size, + className, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: string; + size?: string; + className?: string; + }) => ( + + ), +})); + +// Mock lucide-react icons +vi.mock("lucide-react", () => ({ + Download: () =>
Download
, + LoaderCircle: ({ className }: { className?: string }) => ( +
+ LoaderCircle +
+ ), + RefreshCw: ({ className }: { className?: string }) => ( +
+ RefreshCw +
+ ), +})); + +// Mock logger +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +// Mock QRCodeStyling +const mockQRCodeStyling = { + update: vi.fn(), + append: vi.fn(), + download: vi.fn(), +}; + +// Simple boolean flag to control mock behavior +let shouldMockThrowError = false; + +// @ts-ignore - Ignore TypeScript error for mock +vi.mock("qr-code-styling", () => ({ + default: vi.fn(() => { + // Default to success, only throw error when explicitly requested + if (shouldMockThrowError) { + throw new Error("QR code generation failed"); + } + return mockQRCodeStyling; + }), +})); + +const mockSurveyUrl = "https://example.com/survey/123"; + +describe("QRCodeTab", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.clearAllMocks(); + + // Reset to success state by default + shouldMockThrowError = false; + + // Reset mock implementations + mockQRCodeStyling.update.mockReset(); + mockQRCodeStyling.append.mockReset(); + mockQRCodeStyling.download.mockReset(); + + // Set up default mock behavior + mockQRCodeStyling.update.mockImplementation(() => {}); + mockQRCodeStyling.append.mockImplementation(() => {}); + mockQRCodeStyling.download.mockImplementation(() => {}); + }); + + afterEach(() => { + cleanup(); + }); + + describe("QR Code generation", () => { + test("attempts to generate QR code when surveyUrl is provided", async () => { + render(); + + // Wait for either success or error state + await waitFor(() => { + const hasButton = screen.queryByTestId("button"); + const hasAlert = screen.queryByTestId("alert"); + expect(hasButton || hasAlert).toBeTruthy(); + }); + }); + + test("shows download button when QR code generation succeeds", async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId("button")).toBeInTheDocument(); + }); + }); + }); + + describe("Error handling", () => { + test("shows error state when QR code generation fails", async () => { + shouldMockThrowError = true; + + render(); + + await waitFor(() => { + expect(screen.getByTestId("alert")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("alert-title")).toHaveTextContent("common.something_went_wrong"); + expect(screen.getByTestId("alert-description")).toHaveTextContent( + "environments.surveys.summary.qr_code_generation_failed" + ); + }); + }); + + describe("Download functionality", () => { + test("has clickable download button when QR code is available", async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId("button")).toBeInTheDocument(); + }); + + const downloadButton = screen.getByTestId("button"); + expect(downloadButton).toBeInTheDocument(); + expect(downloadButton).toHaveAttribute("type", "button"); + + // Button should be clickable + await userEvent.click(downloadButton); + // If the button is clicked without throwing, it's working + }); + + test("handles button interactions properly", async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId("button")).toBeInTheDocument(); + }); + + const button = screen.getByTestId("button"); + expect(button).toBeInTheDocument(); + + // Test that button can be interacted with + await userEvent.click(button); + + // Button should still be present after click + expect(screen.getByTestId("button")).toBeInTheDocument(); + }); + + test("shows appropriate state when surveyUrl is empty", async () => { + render(); + + // Should show button (but disabled) when URL is empty, no alert + const button = screen.getByTestId("button"); + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + expect(screen.queryByTestId("alert")).not.toBeInTheDocument(); + }); + }); + + describe("Component lifecycle", () => { + test("responds to surveyUrl changes", async () => { + const { rerender } = render(); + + // Initial render should show download button + await waitFor(() => { + expect(screen.getByTestId("button")).toBeInTheDocument(); + }); + + const newSurveyUrl = "https://example.com/survey/456"; + rerender(); + + // After rerender, button should still be present + await waitFor(() => { + expect(screen.getByTestId("button")).toBeInTheDocument(); + }); + }); + }); + + describe("Accessibility", () => { + test("has proper button labels and states", async () => { + render(); + + await waitFor(() => { + const downloadButton = screen.getByTestId("button"); + expect(downloadButton).toBeInTheDocument(); + expect(downloadButton).toHaveAttribute("type", "button"); + }); + }); + + test("shows appropriate loading or success state", async () => { + render(); + + // Component should show either loading or success content + await waitFor(() => { + const hasButton = screen.queryByTestId("button"); + const hasLoader = screen.queryByTestId("loader-circle"); + expect(hasButton || hasLoader).toBeTruthy(); + }); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab.tsx new file mode 100644 index 000000000000..8589f81b6083 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { getQRCodeOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options"; +import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; +import { Button } from "@/modules/ui/components/button"; +import { useTranslate } from "@tolgee/react"; +import { Download, LoaderCircle } from "lucide-react"; +import QRCodeStyling from "qr-code-styling"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "react-hot-toast"; +import { logger } from "@formbricks/logger"; + +interface QRCodeTabProps { + surveyUrl: string; +} + +export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => { + const { t } = useTranslate(); + const qrCodeRef = useRef(null); + const qrInstance = useRef(null); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + + useEffect(() => { + const generateQRCode = async () => { + try { + setIsLoading(true); + setHasError(false); + + qrInstance.current ??= new QRCodeStyling(getQRCodeOptions(184, 184)); + + if (surveyUrl && qrInstance.current) { + qrInstance.current.update({ data: surveyUrl }); + + if (qrCodeRef.current) { + qrCodeRef.current.innerHTML = ""; + qrInstance.current.append(qrCodeRef.current); + } + } + } catch (error) { + logger.error("Failed to generate QR code:", error); + setHasError(true); + } finally { + setIsLoading(false); + } + }; + + if (surveyUrl) { + generateQRCode(); + } + + return () => { + const instance = qrInstance.current; + if (instance) { + qrInstance.current = null; + } + }; + }, [surveyUrl]); + + const downloadQRCode = async () => { + try { + setIsDownloading(true); + const downloadInstance = new QRCodeStyling(getQRCodeOptions(500, 500)); + downloadInstance.update({ data: surveyUrl }); + downloadInstance.download({ name: "survey-qr-code", extension: "png" }); + toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon")); + } catch (error) { + logger.error("Failed to download QR code:", error); + toast.error(t("environments.surveys.summary.qr_code_download_failed")); + } finally { + setIsDownloading(false); + } + }; + + return ( + <> + {isLoading && ( +
+ +

{t("environments.surveys.summary.generating_qr_code")}

+
+ )} + + {hasError && ( + + {t("common.something_went_wrong")} + {t("environments.surveys.summary.qr_code_generation_failed")} + + )} + + {!isLoading && !hasError && ( +
+
+
+
+ +
+ )} + + ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.test.tsx new file mode 100644 index 000000000000..49d6594764b9 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.test.tsx @@ -0,0 +1,551 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUserLocale } from "@formbricks/types/user"; +import { ShareViewType } from "../../types/share"; +import { ShareView } from "./share-view"; + +// Mock sidebar components +vi.mock("@/modules/ui/components/sidebar", () => ({ + SidebarProvider: ({ children, open, className, style }: any) => ( +
+ {children} +
+ ), + Sidebar: ({ children }: { children: React.ReactNode }) =>
{children}
, + SidebarContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SidebarGroup: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SidebarGroupContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SidebarGroupLabel: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SidebarMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SidebarMenuItem: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SidebarMenuButton: ({ + children, + onClick, + tooltip, + className, + isActive, + }: { + children: React.ReactNode; + onClick: () => void; + tooltip: string; + className?: string; + isActive?: boolean; + }) => ( + + ), +})); + +// Mock child components +vi.mock("./EmailTab", () => ({ + EmailTab: (props: { surveyId: string; email: string }) => ( +
+ EmailTab Content for {props.surveyId} with {props.email} +
+ ), +})); + +vi.mock("./anonymous-links-tab", () => ({ + AnonymousLinksTab: (props: { + survey: TSurvey; + surveyUrl: string; + publicDomain: string; + setSurveyUrl: (url: string) => void; + locale: TUserLocale; + }) => ( +
+ AnonymousLinksTab Content for {props.survey.id} at {props.surveyUrl} +
+ ), +})); + +vi.mock("./qr-code-tab", () => ({ + QRCodeTab: (props: { surveyUrl: string }) => ( +
QRCodeTab Content for {props.surveyUrl}
+ ), +})); +vi.mock("./website-embed-tab", () => ({ + WebsiteEmbedTab: (props: { surveyUrl: string }) => ( +
WebsiteEmbedTab Content for {props.surveyUrl}
+ ), +})); + +vi.mock("./dynamic-popup-tab", () => ({ + DynamicPopupTab: (props: { environmentId: string; surveyId: string }) => ( +
+ DynamicPopupTab Content for {props.surveyId} in {props.environmentId} +
+ ), +})); +vi.mock("./tab-container", () => ({ + TabContainer: (props: { children: React.ReactNode; title: string; description: string }) => ( +
+
{props.title}
+
{props.description}
+ {props.children} +
+ ), +})); + +vi.mock("./personal-links-tab", () => ({ + PersonalLinksTab: (props: { surveyId: string; environmentId: string }) => ( +
+ PersonalLinksTab Content for {props.surveyId} in {props.environmentId} +
+ ), +})); + +vi.mock("./social-media-tab", () => ({ + SocialMediaTab: (props: { surveyUrl: string; surveyTitle: string }) => ( +
+ SocialMediaTab Content for {props.surveyTitle} at {props.surveyUrl} +
+ ), +})); + +vi.mock("@/modules/ui/components/upgrade-prompt", () => ({ + UpgradePrompt: (props: { title: string; description: string }) => ( +
+ {props.title} - {props.description} +
+ ), +})); + +// Mock lucide-react +vi.mock("lucide-react", () => ({ + CopyIcon: () =>
CopyIcon
, + ArrowLeftIcon: () =>
ArrowLeftIcon
, + ArrowUpRightIcon: () =>
ArrowUpRightIcon
, + MailIcon: () =>
MailIcon
, + LinkIcon: () =>
LinkIcon
, + GlobeIcon: () =>
GlobeIcon
, + SmartphoneIcon: () =>
SmartphoneIcon
, + CheckCircle2Icon: () =>
CheckCircle2Icon
, + AlertCircleIcon: ({ className }: { className?: string }) => ( +
+ AlertCircleIcon +
+ ), + AlertTriangleIcon: ({ className }: { className?: string }) => ( +
+ AlertTriangleIcon +
+ ), + InfoIcon: ({ className }: { className?: string }) => ( +
+ InfoIcon +
+ ), + Download: ({ className }: { className?: string }) => ( +
+ Download +
+ ), + Code2Icon: () =>
Code2Icon
, + QrCodeIcon: () =>
QrCodeIcon
, + Share2Icon: () =>
Share2Icon
, + SquareStack: () =>
SquareStack
, + UserIcon: () =>
UserIcon
, +})); + +// Mock tooltip and typography components +vi.mock("@/modules/ui/components/tooltip", () => ({ + TooltipRenderer: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@/modules/ui/components/typography", () => ({ + Small: ({ children }: { children: React.ReactNode }) => {children}, +})); + +// Mock button component +vi.mock("@/modules/ui/components/button", () => ({ + Button: ({ + children, + onClick, + className, + variant, + }: { + children: React.ReactNode; + onClick: () => void; + className?: string; + variant?: string; + }) => ( + + ), +})); + +// Mock cn utility +vi.mock("@/lib/cn", () => ({ + cn: (...args: any[]) => args.filter(Boolean).join(" "), +})); + +// Mock i18n +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +// Mock component imports for tabs +const MockEmailTab = ({ surveyId, email }: { surveyId: string; email: string }) => ( +
+ EmailTab Content for {surveyId} with {email} +
+); + +const MockAnonymousLinksTab = ({ survey, surveyUrl }: { survey: any; surveyUrl: string }) => ( +
+ AnonymousLinksTab Content for {survey.id} at {surveyUrl} +
+); + +const MockWebsiteEmbedTab = ({ surveyUrl }: { surveyUrl: string }) => ( +
WebsiteEmbedTab Content for {surveyUrl}
+); + +const MockDynamicPopupTab = ({ environmentId, surveyId }: { environmentId: string; surveyId: string }) => ( +
+ DynamicPopupTab Content for {surveyId} in {environmentId} +
+); + +const MockQRCodeTab = ({ surveyUrl }: { surveyUrl: string }) => ( +
QRCodeTab Content for {surveyUrl}
+); + +const MockPersonalLinksTab = ({ surveyId, environmentId }: { surveyId: string; environmentId: string }) => ( +
+ PersonalLinksTab Content for {surveyId} in {environmentId} +
+); + +const MockSocialMediaTab = ({ surveyUrl, surveyTitle }: { surveyUrl: string; surveyTitle: string }) => ( +
+ SocialMediaTab Content for {surveyTitle} at {surveyUrl} +
+); + +const mockSurvey = { + id: "survey1", + type: "link", + name: "Test Survey", + status: "inProgress", + environmentId: "env1", + createdAt: new Date(), + updatedAt: new Date(), + questions: [], + displayOption: "displayOnce", + recontactDays: 0, + triggers: [], + languages: [], + autoClose: null, + delay: 0, + autoComplete: null, + runOnDate: null, + closeOnDate: null, + singleUse: { enabled: false, isEncrypted: false }, + styling: null, +} as any; + +const mockTabs = [ + { + id: ShareViewType.EMAIL, + label: "Email", + icon: () =>
, + componentType: MockEmailTab, + componentProps: { surveyId: "survey1", email: "test@example.com" }, + title: "Send Email", + description: "Send survey via email", + }, + { + id: ShareViewType.WEBSITE_EMBED, + label: "Website Embed", + icon: () =>
, + componentType: MockWebsiteEmbedTab, + componentProps: { surveyUrl: "http://example.com/survey1" }, + title: "Embed on Website", + description: "Embed survey on your website", + }, + { + id: ShareViewType.DYNAMIC_POPUP, + label: "Dynamic Popup", + icon: () =>
, + componentType: MockDynamicPopupTab, + componentProps: { environmentId: "env1", surveyId: "survey1" }, + title: "Dynamic Popup", + description: "Show survey as popup", + }, + { + id: ShareViewType.ANON_LINKS, + label: "Anonymous Links", + icon: () =>
, + componentType: MockAnonymousLinksTab, + componentProps: { + survey: mockSurvey, + surveyUrl: "http://example.com/survey1", + publicDomain: "http://example.com", + setSurveyUrl: vi.fn(), + locale: "en" as any, + }, + title: "Anonymous Links", + description: "Share anonymous links", + }, + { + id: ShareViewType.QR_CODE, + label: "QR Code", + icon: () =>
, + componentType: MockQRCodeTab, + componentProps: { surveyUrl: "http://example.com/survey1" }, + title: "QR Code", + description: "Generate QR code", + }, + { + id: ShareViewType.PERSONAL_LINKS, + label: "Personal Links", + icon: () =>
, + componentType: MockPersonalLinksTab, + componentProps: { surveyId: "survey1", environmentId: "env1" }, + title: "Personal Links", + description: "Create personal links", + }, + { + id: ShareViewType.SOCIAL_MEDIA, + label: "Social Media", + icon: () =>
, + componentType: MockSocialMediaTab, + componentProps: { surveyUrl: "http://example.com/survey1", surveyTitle: "Test Survey" }, + title: "Social Media", + description: "Share on social media", + }, +]; + +const defaultProps = { + tabs: mockTabs, + activeId: ShareViewType.EMAIL, + setActiveId: vi.fn(), +}; + +// Mock window object for resize testing +Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 1024, +}); + +describe("ShareView", () => { + beforeEach(() => { + // Reset window size to default before each test + window.innerWidth = 1024; + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders sidebar with tabs", () => { + render(); + + // Sidebar should always be rendered + const sidebarLabel = screen.getByText("environments.surveys.share.share_view_title"); + expect(sidebarLabel).toBeInTheDocument(); + }); + + test("renders desktop tabs", () => { + render(); + + // Desktop sidebar should be rendered + const sidebarLabel = screen.getByText("environments.surveys.share.share_view_title"); + expect(sidebarLabel).toBeInTheDocument(); + }); + + test("calls setActiveId when a tab is clicked (desktop)", async () => { + render(); + + const websiteEmbedTabButton = screen.getByLabelText("Website Embed"); + await userEvent.click(websiteEmbedTabButton); + expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED); + }); + + test("renders EmailTab when activeId is EMAIL", () => { + render(); + expect(screen.getByTestId("email-tab")).toBeInTheDocument(); + expect(screen.getByText("EmailTab Content for survey1 with test@example.com")).toBeInTheDocument(); + }); + + test("renders WebsiteEmbedTab when activeId is WEBSITE_EMBED", () => { + render(); + expect(screen.getByTestId("tab-container")).toBeInTheDocument(); + expect(screen.getByTestId("website-embed-tab")).toBeInTheDocument(); + expect(screen.getByText("WebsiteEmbedTab Content for http://example.com/survey1")).toBeInTheDocument(); + }); + + test("renders DynamicPopupTab when activeId is DYNAMIC_POPUP", () => { + render(); + expect(screen.getByTestId("tab-container")).toBeInTheDocument(); + expect(screen.getByTestId("dynamic-popup-tab")).toBeInTheDocument(); + expect(screen.getByText("DynamicPopupTab Content for survey1 in env1")).toBeInTheDocument(); + }); + + test("renders AnonymousLinksTab when activeId is ANON_LINKS", () => { + render(); + expect(screen.getByTestId("anonymous-links-tab")).toBeInTheDocument(); + expect( + screen.getByText("AnonymousLinksTab Content for survey1 at http://example.com/survey1") + ).toBeInTheDocument(); + }); + + test("renders QRCodeTab when activeId is QR_CODE", () => { + render(); + expect(screen.getByTestId("qr-code-tab")).toBeInTheDocument(); + }); + + test("renders nothing when activeId doesn't match any tab", () => { + // Create a special case with no matching tab + const propsWithNoMatchingTab = { + ...defaultProps, + tabs: mockTabs.slice(0, 3), // Only include first 3 tabs + activeId: ShareViewType.SOCIAL_MEDIA, // Use a tab not in the subset + }; + + render(); + + // Should not render any tab content for non-matching activeId + expect(screen.queryByTestId("email-tab")).not.toBeInTheDocument(); + expect(screen.queryByTestId("website-embed-tab")).not.toBeInTheDocument(); + expect(screen.queryByTestId("dynamic-popup-tab")).not.toBeInTheDocument(); + expect(screen.queryByTestId("anonymous-links-tab")).not.toBeInTheDocument(); + expect(screen.queryByTestId("qr-code-tab")).not.toBeInTheDocument(); + expect(screen.queryByTestId("personal-links-tab")).not.toBeInTheDocument(); + expect(screen.queryByTestId("social-media-tab")).not.toBeInTheDocument(); + }); + + test("renders PersonalLinksTab when activeId is PERSONAL_LINKS", () => { + render(); + expect(screen.getByTestId("personal-links-tab")).toBeInTheDocument(); + expect(screen.getByText("PersonalLinksTab Content for survey1 in env1")).toBeInTheDocument(); + }); + + test("renders SocialMediaTab when activeId is SOCIAL_MEDIA", () => { + render(); + expect(screen.getByTestId("social-media-tab")).toBeInTheDocument(); + expect( + screen.getByText("SocialMediaTab Content for Test Survey at http://example.com/survey1") + ).toBeInTheDocument(); + }); + + test("calls setActiveId when a responsive tab is clicked", async () => { + render(); + + // Get responsive buttons - these are Button components containing icons + const responsiveButtons = screen.getAllByTestId("website-embed-tab-icon"); + // The responsive button should be the one inside the md:hidden container + const responsiveButton = responsiveButtons + .find((icon) => { + const button = icon.closest("button"); + return button && button.getAttribute("data-variant") === "ghost"; + }) + ?.closest("button"); + + if (responsiveButton) { + await userEvent.click(responsiveButton); + expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED); + } + }); + + test("applies active styles to the active tab (desktop)", () => { + render(); + + const emailTabButton = screen.getByLabelText("Email"); + expect(emailTabButton).toHaveClass("bg-slate-100"); + expect(emailTabButton).toHaveClass("font-medium"); + expect(emailTabButton).toHaveClass("text-slate-900"); + + const websiteEmbedTabButton = screen.getByLabelText("Website Embed"); + expect(websiteEmbedTabButton).not.toHaveClass("bg-slate-100"); + expect(websiteEmbedTabButton).not.toHaveClass("font-medium"); + }); + + test("applies active styles to the active tab (responsive)", () => { + render(); + + // Get responsive buttons - these are Button components with ghost variant + const responsiveButtons = screen.getAllByTestId("email-tab-icon"); + const responsiveEmailButton = responsiveButtons + .find((icon) => { + const button = icon.closest("button"); + return button && button.getAttribute("data-variant") === "ghost"; + }) + ?.closest("button"); + + if (responsiveEmailButton) { + // Check that the button has the active classes + expect(responsiveEmailButton).toHaveClass("bg-white text-slate-900 shadow-sm hover:bg-white"); + } + + const responsiveWebsiteEmbedButtons = screen.getAllByTestId("website-embed-tab-icon"); + const responsiveWebsiteEmbedButton = responsiveWebsiteEmbedButtons + .find((icon) => { + const button = icon.closest("button"); + return button && button.getAttribute("data-variant") === "ghost"; + }) + ?.closest("button"); + + if (responsiveWebsiteEmbedButton) { + expect(responsiveWebsiteEmbedButton).toHaveClass( + "border-transparent text-slate-700 hover:text-slate-900" + ); + } + }); + + test("renders all tabs from props", () => { + render(); + + // Check that all tabs are rendered in the sidebar + mockTabs.forEach((tab) => { + expect(screen.getByLabelText(tab.label)).toBeInTheDocument(); + }); + }); + + test("renders responsive buttons for all tabs", () => { + render(); + + // Check that responsive buttons are rendered for all tabs + const expectedTestIds = [ + "email-tab-icon", + "website-embed-tab-icon", + "dynamic-popup-tab-icon", + "anonymous-links-tab-icon", + "qr-code-tab-icon", + "personal-links-tab-icon", + "social-media-tab-icon", + ]; + + expectedTestIds.forEach((testId) => { + const responsiveButtons = screen.getAllByTestId(testId); + const responsiveButton = responsiveButtons.find((icon) => { + const button = icon.closest("button"); + return button && button.getAttribute("data-variant") === "ghost"; + }); + expect(responsiveButton).toBeTruthy(); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.tsx new file mode 100644 index 000000000000..57efdf064f62 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/share-view.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container"; +import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share"; +import { cn } from "@/lib/cn"; +import { Button } from "@/modules/ui/components/button"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, +} from "@/modules/ui/components/sidebar"; +import { TooltipRenderer } from "@/modules/ui/components/tooltip"; +import { Small } from "@/modules/ui/components/typography"; +import { useTranslate } from "@tolgee/react"; +import { useEffect, useState } from "react"; + +interface ShareViewProps { + tabs: Array<{ + id: ShareViewType; + label: string; + icon: React.ElementType; + componentType: React.ComponentType; + componentProps: any; + title: string; + description?: string; + }>; + activeId: ShareViewType; + setActiveId: React.Dispatch>; +} + +export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => { + const { t } = useTranslate(); + const [isLargeScreen, setIsLargeScreen] = useState(true); + + useEffect(() => { + const checkScreenSize = () => { + setIsLargeScreen(window.innerWidth >= 1024); + }; + + checkScreenSize(); + + window.addEventListener("resize", checkScreenSize); + + return () => window.removeEventListener("resize", checkScreenSize); + }, []); + + const renderActiveTab = () => { + const activeTab = tabs.find((tab) => tab.id === activeId); + if (!activeTab) return null; + + const { componentType: Component, componentProps } = activeTab; + + return ( + + + + ); + }; + + return ( +
+
+ + + + + + + {t("environments.surveys.share.share_view_title")} + + + + + {tabs.map((tab) => ( + + setActiveId(tab.id)} + className={cn( + "flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900", + tab.id === activeId ? "bg-slate-100 font-medium text-slate-900" : "text-slate-700" + )} + tooltip={tab.label} + isActive={tab.id === activeId}> + + {tab.label} + + + ))} + + + + + + +
+ {renderActiveTab()} +
+ {tabs.map((tab) => ( + + + + ))} +
+
+
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab.test.tsx new file mode 100644 index 000000000000..aad6e879d2f2 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab.test.tsx @@ -0,0 +1,138 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { SocialMediaTab } from "./social-media-tab"; + +// Mock next/link +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: any) => ( + + {children} + + ), +})); + +// Mock window.open +Object.defineProperty(window, "open", { + writable: true, + value: vi.fn(), +}); + +const mockSurveyUrl = "https://app.formbricks.com/s/survey1"; +const mockSurveyTitle = "Test Survey"; + +const expectedPlatforms = [ + { name: "LinkedIn", description: "Share on LinkedIn" }, + { name: "Threads", description: "Share on Threads" }, + { name: "Facebook", description: "Share on Facebook" }, + { name: "Reddit", description: "Share on Reddit" }, + { name: "X", description: "Share on X (formerly Twitter)" }, +]; + +describe("SocialMediaTab", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders all social media platforms with correct names", () => { + render(); + + expectedPlatforms.forEach((platform) => { + expect(screen.getByText(platform.name)).toBeInTheDocument(); + }); + }); + + test("renders source tracking alert with correct content", () => { + render(); + + expect( + screen.getByText("environments.surveys.share.social_media.source_tracking_enabled") + ).toBeInTheDocument(); + expect( + screen.getByText("environments.surveys.share.social_media.source_tracking_enabled_alert_description") + ).toBeInTheDocument(); + expect(screen.getByText("common.learn_more")).toBeInTheDocument(); + + const learnMoreButton = screen.getByRole("button", { name: "common.learn_more" }); + expect(learnMoreButton).toBeInTheDocument(); + }); + + test("renders platform buttons for all platforms", () => { + render(); + + const platformButtons = expectedPlatforms.map((platform) => + screen.getByRole("button", { name: new RegExp(platform.name, "i") }) + ); + expect(platformButtons).toHaveLength(expectedPlatforms.length); + }); + + test("opens sharing window when LinkedIn button is clicked", async () => { + const mockWindowOpen = vi.spyOn(window, "open"); + render(); + + const linkedInButton = screen.getByRole("button", { name: /linkedin/i }); + await userEvent.click(linkedInButton); + + expect(mockWindowOpen).toHaveBeenCalledWith( + expect.stringContaining("linkedin.com/shareArticle"), + "share-dialog", + "width=1024,height=768,location=no,toolbar=no,status=no,menubar=no,scrollbars=yes,resizable=yes,noopener=yes,noreferrer=yes" + ); + }); + + test("includes source tracking in shared URLs", async () => { + const mockWindowOpen = vi.spyOn(window, "open"); + render(); + + const linkedInButton = screen.getByRole("button", { name: /linkedin/i }); + await userEvent.click(linkedInButton); + + const calledUrl = mockWindowOpen.mock.calls[0][0] as string; + const decodedUrl = decodeURIComponent(calledUrl); + expect(decodedUrl).toContain("source=linkedin"); + }); + + test("opens sharing window when Facebook button is clicked", async () => { + const mockWindowOpen = vi.spyOn(window, "open"); + render(); + + const facebookButton = screen.getByRole("button", { name: /facebook/i }); + await userEvent.click(facebookButton); + + expect(mockWindowOpen).toHaveBeenCalledWith( + expect.stringContaining("facebook.com/sharer"), + "share-dialog", + "width=1024,height=768,location=no,toolbar=no,status=no,menubar=no,scrollbars=yes,resizable=yes,noopener=yes,noreferrer=yes" + ); + }); + + test("opens sharing window when X button is clicked", async () => { + const mockWindowOpen = vi.spyOn(window, "open"); + render(); + + const xButton = screen.getByRole("button", { name: /^x$/i }); + await userEvent.click(xButton); + + expect(mockWindowOpen).toHaveBeenCalledWith( + expect.stringContaining("twitter.com/intent/tweet"), + "share-dialog", + "width=1024,height=768,location=no,toolbar=no,status=no,menubar=no,scrollbars=yes,resizable=yes,noopener=yes,noreferrer=yes" + ); + }); + + test("encodes URLs and titles correctly for sharing", async () => { + const specialCharUrl = "https://app.formbricks.com/s/survey1?param=test&other=value"; + const specialCharTitle = "Test Survey & More"; + const mockWindowOpen = vi.spyOn(window, "open"); + + render(); + + const linkedInButton = screen.getByRole("button", { name: /linkedin/i }); + await userEvent.click(linkedInButton); + + const calledUrl = mockWindowOpen.mock.calls[0][0] as string; + expect(calledUrl).toContain(encodeURIComponent(specialCharTitle)); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab.tsx new file mode 100644 index 000000000000..d64f3ca367b2 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; +import { Button } from "@/modules/ui/components/button"; +import { FacebookIcon } from "@/modules/ui/components/icons/facebook-icon"; +import { LinkedinIcon } from "@/modules/ui/components/icons/linkedin-icon"; +import { RedditIcon } from "@/modules/ui/components/icons/reddit-icon"; +import { ThreadsIcon } from "@/modules/ui/components/icons/threads-icon"; +import { XIcon } from "@/modules/ui/components/icons/x-icon"; +import { useTranslate } from "@tolgee/react"; +import { AlertCircleIcon } from "lucide-react"; +import { useMemo } from "react"; + +interface SocialMediaTabProps { + surveyUrl: string; + surveyTitle: string; +} + +export const SocialMediaTab: React.FC = ({ surveyUrl, surveyTitle }) => { + const { t } = useTranslate(); + + const socialMediaPlatforms = useMemo(() => { + const shareText = surveyTitle; + + // Add source tracking to the survey URL + const getTrackedUrl = (platform: string) => { + const sourceParam = `source=${platform.toLowerCase()}`; + const separator = surveyUrl.includes("?") ? "&" : "?"; + return `${surveyUrl}${separator}${sourceParam}`; + }; + + return [ + { + id: "linkedin", + name: "LinkedIn", + icon: , + url: `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(getTrackedUrl("linkedin"))}&title=${encodeURIComponent(shareText)}`, + description: "Share on LinkedIn", + }, + { + id: "threads", + name: "Threads", + icon: , + url: `https://www.threads.net/intent/post?text=${encodeURIComponent(shareText)}%20${encodeURIComponent(getTrackedUrl("threads"))}`, + description: "Share on Threads", + }, + { + id: "facebook", + name: "Facebook", + icon: , + url: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(getTrackedUrl("facebook"))}`, + description: "Share on Facebook", + }, + { + id: "reddit", + name: "Reddit", + icon: , + url: `https://www.reddit.com/submit?url=${encodeURIComponent(getTrackedUrl("reddit"))}&title=${encodeURIComponent(shareText)}`, + description: "Share on Reddit", + }, + { + id: "x", + name: "X", + icon: , + url: `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(getTrackedUrl("x"))}`, + description: "Share on X (formerly Twitter)", + }, + ]; + }, [surveyUrl, surveyTitle]); + + const handleSocialShare = (url: string) => { + // Open sharing window + window.open( + url, + "share-dialog", + "width=1024,height=768,location=no,toolbar=no,status=no,menubar=no,scrollbars=yes,resizable=yes,noopener=yes,noreferrer=yes" + ); + }; + + return ( + <> +
+ {socialMediaPlatforms.map((platform) => ( + + ))} +
+ + + + {t("environments.surveys.share.social_media.source_tracking_enabled")} + + {t("environments.surveys.share.social_media.source_tracking_enabled_alert_description")} + + { + window.open( + "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/source-tracking", + "_blank", + "noopener,noreferrer" + ); + }}> + {t("common.learn_more")} + + + + ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/success-view.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/success-view.tsx new file mode 100644 index 000000000000..2c520cefe6ba --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/success-view.tsx @@ -0,0 +1,87 @@ +import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink"; +import { Badge } from "@/modules/ui/components/badge"; +import { useTranslate } from "@tolgee/react"; +import { BellRing, BlocksIcon, Share2Icon, UserIcon } from "lucide-react"; +import Link from "next/link"; +import React from "react"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUser } from "@formbricks/types/user"; + +interface SuccessViewProps { + survey: TSurvey; + surveyUrl: string; + publicDomain: string; + setSurveyUrl: (url: string) => void; + user: TUser; + tabs: { id: string; label: string; icon: React.ElementType }[]; + handleViewChange: (view: string) => void; + handleEmbedViewWithTab: (tabId: string) => void; + isReadOnly: boolean; +} + +export const SuccessView: React.FC = ({ + survey, + surveyUrl, + publicDomain, + setSurveyUrl, + user, + tabs, + handleViewChange, + handleEmbedViewWithTab, + isReadOnly, +}) => { + const { t } = useTranslate(); + const environmentId = survey.environmentId; + return ( +
+ {survey.type === "link" && ( +
+

+ {t("environments.surveys.summary.your_survey_is_public")} 🎉 +

+ +
+ )} +
+

{t("environments.surveys.summary.whats_next")}

+
+ + + + + {t("environments.surveys.summary.configure_alerts")} + + + + {t("environments.surveys.summary.setup_integrations")} + +
+
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container.test.tsx new file mode 100644 index 000000000000..1e9740e76293 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container.test.tsx @@ -0,0 +1,68 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TabContainer } from "./tab-container"; + +// Mock components +vi.mock("@/modules/ui/components/typography", () => ({ + H3: (props: { children: React.ReactNode }) =>

{props.children}

, + Small: (props: { color?: string; margin?: string; children: React.ReactNode }) => ( +

+ {props.children} +

+ ), +})); + +describe("TabContainer", () => { + afterEach(() => { + cleanup(); + }); + + const defaultProps = { + title: "Test Tab Title", + description: "Test tab description", + children:
Tab content
, + }; + + test("renders title with correct props", () => { + render(); + + const title = screen.getByTestId("h3"); + expect(title).toBeInTheDocument(); + expect(title).toHaveTextContent("Test Tab Title"); + }); + + test("renders description with correct text and props", () => { + render(); + + const description = screen.getByTestId("small"); + expect(description).toBeInTheDocument(); + expect(description).toHaveTextContent("Test tab description"); + expect(description).toHaveAttribute("data-color", "muted"); + expect(description).toHaveAttribute("data-margin", "headerDescription"); + }); + + test("renders children content", () => { + render(); + + const tabContent = screen.getByTestId("tab-content"); + expect(tabContent).toBeInTheDocument(); + expect(tabContent).toHaveTextContent("Tab content"); + }); + + test("renders header with correct structure", () => { + render(); + + const header = screen.getByTestId("h3").parentElement; + expect(header).toBeInTheDocument(); + expect(header).toContainElement(screen.getByTestId("h3")); + expect(header).toContainElement(screen.getByTestId("small")); + }); + + test("renders children directly in container", () => { + render(); + + const container = screen.getByTestId("h3").parentElement?.parentElement; + expect(container).toContainElement(screen.getByTestId("tab-content")); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container.tsx new file mode 100644 index 000000000000..19b42e94e396 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container.tsx @@ -0,0 +1,21 @@ +import { H3, Small } from "@/modules/ui/components/typography"; + +interface TabContainerProps { + title: string; + description: string; + children: React.ReactNode; +} + +export const TabContainer = ({ title, description, children }: TabContainerProps) => { + return ( +
+
+

{title}

+ + {description} + +
+
{children}
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab.test.tsx new file mode 100644 index 000000000000..a2940c726cd0 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab.test.tsx @@ -0,0 +1,183 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { WebsiteEmbedTab } from "./website-embed-tab"; + +// Mock components +vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({ + AdvancedOptionToggle: (props: { + htmlId: string; + isChecked: boolean; + onToggle: (checked: boolean) => void; + title: string; + description: string; + customContainerClass?: string; + }) => ( +
+ + props.onToggle(e.target.checked)} + data-testid="embed-mode-toggle" + /> + {props.description} + {props.customContainerClass && ( + {props.customContainerClass} + )} +
+ ), +})); + +vi.mock("@/modules/ui/components/button", () => ({ + Button: (props: { + title?: string; + "aria-label"?: string; + onClick?: () => void; + children: React.ReactNode; + type?: "button" | "submit" | "reset"; + }) => ( + + ), +})); + +vi.mock("@/modules/ui/components/code-block", () => ({ + CodeBlock: (props: { + language: string; + showCopyToClipboard: boolean; + noMargin?: boolean; + children: string; + }) => ( +
+ {props.language} + {props.showCopyToClipboard?.toString() || "false"} + {props.noMargin && true} +
{props.children}
+
+ ), +})); + +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("lucide-react", () => ({ + CopyIcon: () =>
CopyIcon
, +})); + +// Mock react-hot-toast +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + }, +})); + +// Mock clipboard API +Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockImplementation(() => Promise.resolve()), + }, +}); + +describe("WebsiteEmbedTab", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const defaultProps = { + surveyUrl: "https://example.com/survey/123", + }; + + test("renders all components correctly", () => { + render(); + + expect(screen.getByTestId("code-block")).toBeInTheDocument(); + expect(screen.getByTestId("advanced-option-toggle")).toBeInTheDocument(); + expect(screen.getByTestId("copy-button")).toBeInTheDocument(); + expect(screen.getByTestId("copy-icon")).toBeInTheDocument(); + }); + + test("renders correct iframe code without embed mode", () => { + render(); + + const codeBlock = screen.getByTestId("code-block"); + expect(codeBlock).toBeInTheDocument(); + + const code = codeBlock.querySelector("pre")?.textContent; + expect(code).toContain(defaultProps.surveyUrl); + expect(code).toContain(" { + render(); + + const toggle = screen.getByTestId("embed-mode-toggle"); + await userEvent.click(toggle); + + const codeBlock = screen.getByTestId("code-block"); + const code = codeBlock.querySelector("pre")?.textContent; + expect(code).toContain('src="https://example.com/survey/123?embed=true"'); + }); + + test("toggle changes embed mode state", async () => { + render(); + + const toggle = screen.getByTestId("embed-mode-toggle"); + expect(toggle).not.toBeChecked(); + + await userEvent.click(toggle); + expect(toggle).toBeChecked(); + + await userEvent.click(toggle); + expect(toggle).not.toBeChecked(); + }); + + test("copy button copies iframe code to clipboard", async () => { + render(); + + const copyButton = screen.getByTestId("copy-button"); + await userEvent.click(copyButton); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining(defaultProps.surveyUrl) + ); + const toast = await import("react-hot-toast"); + expect(toast.default.success).toHaveBeenCalledWith( + "environments.surveys.share.embed_on_website.embed_code_copied_to_clipboard" + ); + }); + + test("copy button copies correct code with embed mode enabled", async () => { + render(); + + const toggle = screen.getByTestId("embed-mode-toggle"); + await userEvent.click(toggle); + + const copyButton = screen.getByTestId("copy-button"); + await userEvent.click(copyButton); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining("?embed=true")); + }); + + test("renders code block with correct props", () => { + render(); + + expect(screen.getByTestId("language")).toHaveTextContent("html"); + expect(screen.getByTestId("show-copy")).toHaveTextContent("false"); + expect(screen.getByTestId("no-margin")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab.tsx new file mode 100644 index 000000000000..6799b6cc553a --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; +import { Button } from "@/modules/ui/components/button"; +import { CodeBlock } from "@/modules/ui/components/code-block"; +import { useTranslate } from "@tolgee/react"; +import { CopyIcon } from "lucide-react"; +import { useState } from "react"; +import toast from "react-hot-toast"; + +interface WebsiteEmbedTabProps { + surveyUrl: string; +} + +export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => { + const [embedModeEnabled, setEmbedModeEnabled] = useState(false); + const { t } = useTranslate(); + + const iframeCode = `
+ +
`; + + return ( + <> + + {iframeCode} + + + + + + ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/tests/SurveyAnalysisCTA.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/tests/SurveyAnalysisCTA.test.tsx deleted file mode 100644 index 4533f1e897e0..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/tests/SurveyAnalysisCTA.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import "@testing-library/jest-dom/vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import toast from "react-hot-toast"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { TEnvironment } from "@formbricks/types/environment"; -import { TSurvey } from "@formbricks/types/surveys/types"; -import { TUser } from "@formbricks/types/user"; -import { SurveyAnalysisCTA } from "../SurveyAnalysisCTA"; - -// Mock constants -vi.mock("@formbricks/lib/constants", () => ({ - IS_FORMBRICKS_CLOUD: false, - ENCRYPTION_KEY: "test", - ENTERPRISE_LICENSE_KEY: "test", - GITHUB_ID: "test", - GITHUB_SECRET: "test", - GOOGLE_CLIENT_ID: "test", - GOOGLE_CLIENT_SECRET: "test", - AZUREAD_CLIENT_ID: "mock-azuread-client-id", - AZUREAD_CLIENT_SECRET: "mock-azure-client-secret", - AZUREAD_TENANT_ID: "mock-azuread-tenant-id", - OIDC_CLIENT_ID: "mock-oidc-client-id", - OIDC_CLIENT_SECRET: "mock-oidc-client-secret", - OIDC_ISSUER: "mock-oidc-issuer", - OIDC_DISPLAY_NAME: "mock-oidc-display-name", - OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm", - WEBAPP_URL: "mock-webapp-url", - AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name", - AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key", - AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id", - AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name", - AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key", - AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id", - IS_PRODUCTION: true, - FB_LOGO_URL: "https://example.com/mock-logo.png", - SMTP_HOST: "mock-smtp-host", - SMTP_PORT: "mock-smtp-port", - IS_POSTHOG_CONFIGURED: true, -})); - -// Create a spy for refreshSingleUseId so we can override it in tests -const refreshSingleUseIdSpy = vi.fn(() => Promise.resolve("newSingleUseId")); - -// Mock useSingleUseId hook -vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({ - useSingleUseId: () => ({ - refreshSingleUseId: refreshSingleUseIdSpy, - }), -})); - -const mockSearchParams = new URLSearchParams(); - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn() }), - useSearchParams: () => mockSearchParams, // Reuse the same object - usePathname: () => "/current", -})); - -// Mock copySurveyLink to return a predictable string -vi.mock("@/modules/survey/lib/client-utils", () => ({ - copySurveyLink: vi.fn((url: string, id: string) => `${url}?id=${id}`), -})); - -vi.spyOn(toast, "success"); -vi.spyOn(toast, "error"); - -// Set up a fake clipboard -const writeTextMock = vi.fn(() => Promise.resolve()); -Object.assign(navigator, { - clipboard: { writeText: writeTextMock }, -}); - -const dummySurvey = { - id: "survey123", - type: "link", - environmentId: "env123", - status: "active", -} as unknown as TSurvey; -const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment; -const dummyUser = { id: "user123", name: "Test User" } as TUser; -const surveyDomain = "https://surveys.test.formbricks.com"; - -describe("SurveyAnalysisCTA - handleCopyLink", () => { - afterEach(() => { - cleanup(); - }); - - it("calls copySurveyLink and clipboard.writeText on success", async () => { - render( - - ); - - const copyButton = screen.getByRole("button", { name: "common.copy_link" }); - fireEvent.click(copyButton); - - await waitFor(() => { - expect(refreshSingleUseIdSpy).toHaveBeenCalled(); - expect(writeTextMock).toHaveBeenCalledWith( - "https://surveys.test.formbricks.com/s/survey123?id=newSingleUseId" - ); - expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard"); - }); - }); - - it("shows error toast on failure", async () => { - refreshSingleUseIdSpy.mockImplementationOnce(() => Promise.reject(new Error("fail"))); - render( - - ); - - const copyButton = screen.getByRole("button", { name: "common.copy_link" }); - fireEvent.click(copyButton); - - await waitFor(() => { - expect(refreshSingleUseIdSpy).toHaveBeenCalled(); - expect(writeTextMock).not.toHaveBeenCalled(); - expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_copy_link"); - }); - }); -}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.test.tsx new file mode 100644 index 000000000000..8afd520827de --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.test.tsx @@ -0,0 +1,177 @@ +import { getPublicDomain } from "@/lib/getPublicUrl"; +import { getProjectByEnvironmentId } from "@/lib/project/service"; +import { getSurvey } from "@/lib/survey/service"; +import { getStyling } from "@/lib/utils/styling"; +import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template"; +import { cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TProject } from "@formbricks/types/project"; +import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { getEmailTemplateHtml } from "./emailTemplate"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", +})); + +vi.mock("@/lib/env", () => ({ + env: { + PUBLIC_URL: "https://public-domain.com", + }, +})); + +vi.mock("@/lib/getPublicUrl", () => ({ + getPublicDomain: vi.fn().mockReturnValue("https://public-domain.com"), +})); + +vi.mock("@/lib/project/service"); +vi.mock("@/lib/survey/service"); +vi.mock("@/lib/utils/styling"); +vi.mock("@/modules/email/components/preview-email-template"); +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); + +const mockSurveyId = "survey123"; +const mockLocale = "en"; +const doctype = + ''; + +const mockSurvey = { + id: mockSurveyId, + name: "Test Survey", + environmentId: "env456", + type: "app", + status: "inProgress", + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Question?" }, + } as unknown as TSurveyQuestion, + ], + styling: null, + createdAt: new Date(), + updatedAt: new Date(), + languages: [], + triggers: [], + recontactDays: null, + displayOption: "displayOnce", + displayPercentage: null, + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + surveyClosedMessage: null, + singleUse: null, + variables: [], + segment: null, + autoClose: null, + delay: 0, + autoComplete: null, + runOnDate: null, + closeOnDate: null, +} as unknown as TSurvey; + +const mockProject = { + id: "proj789", + name: "Test Project", + environments: [{ id: "env456", type: "production" } as unknown as TEnvironment], + styling: { + allowStyleOverwrite: true, + brandColor: { light: "#007BFF", dark: "#007BFF" }, + highlightBorderColor: null, + cardBackgroundColor: { light: "#FFFFFF", dark: "#000000" }, + cardBorderColor: { light: "#FFFFFF", dark: "#000000" }, + + questionColor: { light: "#FFFFFF", dark: "#000000" }, + inputColor: { light: "#FFFFFF", dark: "#000000" }, + inputBorderColor: { light: "#FFFFFF", dark: "#000000" }, + }, + createdAt: new Date(), + updatedAt: new Date(), + linkSurveyBranding: true, + placement: "bottomRight", + clickOutsideClose: true, + darkOverlay: false, + recontactDays: 30, + logo: null, +} as unknown as TProject; + +const mockComputedStyling = { + brandColor: "#007BFF", + questionColor: "#000000", + inputColor: "#000000", + inputBorderColor: "#000000", + cardBackgroundColor: "#FFFFFF", + cardBorderColor: "#EEEEEE", + + highlightBorderColor: null, + thankYouCardIconColor: "#007BFF", + thankYouCardIconBgColor: "#DDDDDD", +} as any; + +const mockPublicDomain = "https://app.formbricks.com"; +const mockRawHtml = `${doctype}Test Email Content for ${mockSurvey.name}`; +const mockCleanedHtml = `Test Email Content for ${mockSurvey.name}`; + +describe("getEmailTemplateHtml", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + vi.resetAllMocks(); + + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject); + vi.mocked(getStyling).mockReturnValue(mockComputedStyling); + vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain); + vi.mocked(getPreviewEmailTemplateHtml).mockResolvedValue(mockRawHtml); + }); + + test("should return cleaned HTML when all services provide data", async () => { + const html = await getEmailTemplateHtml(mockSurveyId, mockLocale); + + expect(html).toBe(mockCleanedHtml); + expect(getSurvey).toHaveBeenCalledWith(mockSurveyId); + expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockSurvey.environmentId); + expect(getStyling).toHaveBeenCalledWith(mockProject, mockSurvey); + expect(getPublicDomain).toHaveBeenCalledTimes(1); + const expectedSurveyUrl = `${mockPublicDomain}/s/${mockSurvey.id}`; + expect(getPreviewEmailTemplateHtml).toHaveBeenCalledWith( + mockSurvey, + expectedSurveyUrl, + mockComputedStyling, + mockLocale, + expect.any(Function) + ); + }); + + test("should throw error if survey is not found", async () => { + vi.mocked(getSurvey).mockResolvedValue(null); + await expect(getEmailTemplateHtml(mockSurveyId, mockLocale)).rejects.toThrow("Survey not found"); + }); + + test("should throw error if project is not found", async () => { + vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null); + await expect(getEmailTemplateHtml(mockSurveyId, mockLocale)).rejects.toThrow("Project not found"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx index 9fba9a8ecd8b..f0bd1cf0ad3d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx @@ -1,9 +1,9 @@ +import { getPublicDomain } from "@/lib/getPublicUrl"; +import { getProjectByEnvironmentId } from "@/lib/project/service"; +import { getSurvey } from "@/lib/survey/service"; +import { getStyling } from "@/lib/utils/styling"; import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template"; import { getTranslate } from "@/tolgee/server"; -import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; -import { getSurvey } from "@formbricks/lib/survey/service"; -import { getStyling } from "@formbricks/lib/utils/styling"; export const getEmailTemplateHtml = async (surveyId: string, locale: string) => { const t = await getTranslate(); @@ -17,7 +17,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) => } const styling = getStyling(project, survey); - const surveyUrl = getSurveyDomain() + "/s/" + survey.id; + const surveyUrl = getPublicDomain() + "/s/" + survey.id; const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t); const doctype = ''; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options.test.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options.test.ts new file mode 100644 index 000000000000..0a4e9b86aeb8 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "vitest"; +import { getQRCodeOptions } from "./get-qr-code-options"; + +describe("getQRCodeOptions", () => { + test("should return correct QR code options for given width and height", () => { + const width = 300; + const height = 300; + const options = getQRCodeOptions(width, height); + + expect(options).toEqual({ + width, + height, + type: "svg", + data: "", + margin: 0, + qrOptions: { + typeNumber: 0, + mode: "Byte", + errorCorrectionLevel: "L", + }, + imageOptions: { + saveAsBlob: true, + hideBackgroundDots: false, + imageSize: 0, + margin: 0, + }, + dotsOptions: { + type: "extra-rounded", + color: "#000000", + roundSize: true, + }, + backgroundOptions: { + color: "#ffffff", + }, + cornersSquareOptions: { + type: "dot", + color: "#000000", + }, + cornersDotOptions: { + type: "dot", + color: "#000000", + }, + }); + }); + + test("should return correct QR code options for different width and height", () => { + const width = 150; + const height = 200; + const options = getQRCodeOptions(width, height); + + expect(options.width).toBe(width); + expect(options.height).toBe(height); + expect(options.type).toBe("svg"); + // Check a few other properties to ensure the structure is consistent + expect(options.dotsOptions?.type).toBe("extra-rounded"); + expect(options.backgroundOptions?.color).toBe("#ffffff"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/insights.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/insights.ts deleted file mode 100644 index 81c273931319..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/insights.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { documentCache } from "@/lib/cache/document"; -import { Prisma } from "@prisma/client"; -import { cache as reactCache } from "react"; -import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { INSIGHTS_PER_PAGE } from "@formbricks/lib/constants"; -import { validateInputs } from "@formbricks/lib/utils/validate"; -import { ZId } from "@formbricks/types/common"; -import { DatabaseError } from "@formbricks/types/errors"; -import { - TSurveyQuestionId, - TSurveyQuestionSummaryOpenText, - ZSurveyQuestionId, -} from "@formbricks/types/surveys/types"; - -export const getInsightsBySurveyIdQuestionId = reactCache( - async ( - surveyId: string, - questionId: TSurveyQuestionId, - insightResponsesIds: string[], - limit?: number, - offset?: number - ): Promise => - cache( - async () => { - validateInputs([surveyId, ZId], [questionId, ZSurveyQuestionId]); - - limit = limit ?? INSIGHTS_PER_PAGE; - try { - const insights = await prisma.insight.findMany({ - where: { - documentInsights: { - some: { - document: { - surveyId, - questionId, - ...(insightResponsesIds.length > 0 && { - responseId: { - in: insightResponsesIds, - }, - }), - }, - }, - }, - }, - include: { - _count: { - select: { - documentInsights: { - where: { - document: { - surveyId, - questionId, - }, - }, - }, - }, - }, - }, - orderBy: [ - { - documentInsights: { - _count: "desc", - }, - }, - { - createdAt: "desc", - }, - ], - take: limit ? limit : undefined, - skip: offset ? offset : undefined, - }); - - return insights; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } - }, - [`getInsightsBySurveyIdQuestionId-${surveyId}-${questionId}-${limit}-${offset}`], - { - tags: [documentCache.tag.bySurveyId(surveyId)], - } - )() -); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey-qr-code.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey-qr-code.tsx deleted file mode 100644 index 2a74b2196149..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey-qr-code.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import { getQRCodeOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options"; -import { useTranslate } from "@tolgee/react"; -import QRCodeStyling from "qr-code-styling"; -import { useEffect, useRef } from "react"; -import { toast } from "react-hot-toast"; - -export const useSurveyQRCode = (surveyUrl: string) => { - const qrCodeRef = useRef(null); - const qrInstance = useRef(null); - const { t } = useTranslate(); - - useEffect(() => { - try { - if (!qrInstance.current) { - qrInstance.current = new QRCodeStyling(getQRCodeOptions(70, 70)); - } - - if (surveyUrl && qrInstance.current) { - qrInstance.current.update({ data: surveyUrl }); - - if (qrCodeRef.current) { - qrCodeRef.current.innerHTML = ""; - qrInstance.current.append(qrCodeRef.current); - } - } - } catch (error) { - toast.error(t("environments.surveys.summary.failed_to_generate_qr_code")); - } - }, [surveyUrl]); - - const downloadQRCode = () => { - try { - const downloadInstance = new QRCodeStyling(getQRCodeOptions(500, 500)); - downloadInstance.update({ data: surveyUrl }); - downloadInstance.download({ name: "survey-qr", extension: "png" }); - } catch (error) { - toast.error(t("environments.surveys.summary.failed_to_generate_qr_code")); - } - }; - - return { qrCodeRef, downloadQRCode }; -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey.test.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey.test.ts new file mode 100644 index 000000000000..07696b45c760 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey.test.ts @@ -0,0 +1,83 @@ +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { DatabaseError } from "@formbricks/types/errors"; +import { deleteResponsesAndDisplaysForSurvey } from "./survey"; + +// Mock prisma +vi.mock("@formbricks/database", () => ({ + prisma: { + response: { + deleteMany: vi.fn(), + }, + display: { + deleteMany: vi.fn(), + }, + $transaction: vi.fn(), + }, +})); + +const surveyId = "clq5n7p1q0000m7z0h5p6g3r2"; + +beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); +}); + +describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => { + describe("Happy Path", () => { + test("Deletes all responses and displays for a survey", async () => { + const { prisma } = await import("@formbricks/database"); + + // Mock $transaction to return the results directly + vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 5 }, { count: 3 }]); + + const result = await deleteResponsesAndDisplaysForSurvey(surveyId); + + expect(prisma.$transaction).toHaveBeenCalled(); + expect(result).toEqual({ + deletedResponsesCount: 5, + deletedDisplaysCount: 3, + }); + }); + + test("Handles case with no responses or displays to delete", async () => { + const { prisma } = await import("@formbricks/database"); + + // Mock $transaction to return zero counts + vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 0 }, { count: 0 }]); + + const result = await deleteResponsesAndDisplaysForSurvey(surveyId); + + expect(result).toEqual({ + deletedResponsesCount: 0, + deletedDisplaysCount: 0, + }); + }); + }); + + describe("Sad Path", () => { + test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => { + const { prisma } = await import("@formbricks/database"); + + const mockErrorMessage = "Mock error message"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + + vi.mocked(prisma.$transaction).mockRejectedValue(errToThrow); + + await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(DatabaseError); + }); + + test("Throws a generic Error for other exceptions", async () => { + const { prisma } = await import("@formbricks/database"); + + const mockErrorMessage = "Mock error message"; + vi.mocked(prisma.$transaction).mockRejectedValue(new Error(mockErrorMessage)); + + await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(Error); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey.ts new file mode 100644 index 000000000000..0494ef0ec4b5 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey.ts @@ -0,0 +1,36 @@ +import "server-only"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; + +export const deleteResponsesAndDisplaysForSurvey = async ( + surveyId: string +): Promise<{ deletedResponsesCount: number; deletedDisplaysCount: number }> => { + try { + // Delete all responses for this survey + + const [deletedResponsesCount, deletedDisplaysCount] = await prisma.$transaction([ + prisma.response.deleteMany({ + where: { + surveyId: surveyId, + }, + }), + prisma.display.deleteMany({ + where: { + surveyId: surveyId, + }, + }), + ]); + + return { + deletedResponsesCount: deletedResponsesCount.count, + deletedDisplaysCount: deletedDisplaysCount.count, + }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts new file mode 100644 index 000000000000..24ec96e4233f --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts @@ -0,0 +1,3377 @@ +import { getDisplayCountBySurveyId } from "@/lib/display/service"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { getResponseCountBySurveyId } from "@/lib/response/service"; +import { getSurvey } from "@/lib/survey/service"; +import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils"; +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TLanguage } from "@formbricks/types/project"; +import { TResponseFilterCriteria } from "@formbricks/types/responses"; +import { + TSurvey, + TSurveyQuestion, + TSurveyQuestionTypeEnum, + TSurveySummary, +} from "@formbricks/types/surveys/types"; +import { + getQuestionSummary, + getResponsesForSummary, + getSurveySummary, + getSurveySummaryDropOff, + getSurveySummaryMeta, +} from "./surveySummary"; +// Ensure this path is correct +import { convertFloatTo2Decimal } from "./utils"; + +vi.mock("@/lib/display/service", () => ({ + getDisplayCountBySurveyId: vi.fn(), +})); +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: vi.fn((value, lang) => { + // Handle the case when value is undefined or null + if (!value) return ""; + return value[lang] || value.default || ""; + }), +})); +vi.mock("@/lib/response/service", () => ({ + getResponseCountBySurveyId: vi.fn(), +})); +vi.mock("@/lib/response/utils", () => ({ + buildWhereClause: vi.fn(() => ({})), +})); +vi.mock("@/lib/survey/service", () => ({ + getSurvey: vi.fn(), +})); +vi.mock("@/lib/surveyLogic/utils", () => ({ + evaluateLogic: vi.fn(), + performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })), +})); +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), +})); +vi.mock("@formbricks/database", () => ({ + prisma: { + response: { + findMany: vi.fn(), + }, + }, +})); +vi.mock("./utils", () => ({ + convertFloatTo2Decimal: vi.fn((num) => + num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0 + ), +})); + +const mockSurveyId = "survey_123"; + +const mockBaseSurvey: TSurvey = { + id: mockSurveyId, + name: "Test Survey", + questions: [], + welcomeCard: { enabled: false, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"], + endings: [], + hiddenFields: { enabled: false, fieldIds: [] }, + languages: [ + { language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true }, + ], + variables: [], + autoClose: null, + triggers: [], + status: "inProgress", + type: "app", + styling: {}, + segment: null, + recontactDays: null, + autoComplete: null, + closeOnDate: null, + createdAt: new Date(), + updatedAt: new Date(), + displayOption: "displayOnce", + displayPercentage: null, + environmentId: "env_123", + singleUse: null, + surveyClosedMessage: null, + pin: null, + createdBy: "user_123", + isSingleResponsePerEmailEnabled: false, + isVerifyEmailEnabled: false, + projectOverwrites: null, + runOnDate: null, + showLanguageSwitch: false, + isBackButtonHidden: false, + followUps: [], + recaptcha: { enabled: false, threshold: 0.5 }, +} as unknown as TSurvey; + +const mockResponses = [ + { + id: "res1", + data: { q1: "Answer 1" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "en", + ttc: { q1: 100, _total: 100 }, + finished: true, + }, + { + id: "res2", + data: { q1: "Answer 2" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "en", + ttc: { q1: 150, _total: 150 }, + finished: true, + }, + { + id: "res3", + data: {}, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "en", + ttc: {}, + finished: false, + }, +] as any; + +describe("getSurveySummaryMeta", () => { + beforeEach(() => { + vi.mocked(convertFloatTo2Decimal).mockImplementation((num) => + num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0 + ); + }); + + test("calculates meta correctly", () => { + const meta = getSurveySummaryMeta(mockResponses, 10); + expect(meta.displayCount).toBe(10); + expect(meta.totalResponses).toBe(3); + expect(meta.startsPercentage).toBe(30); + expect(meta.completedResponses).toBe(2); + expect(meta.completedPercentage).toBe(20); + expect(meta.dropOffCount).toBe(1); + expect(meta.dropOffPercentage).toBe(33.33); // (1/3)*100 + expect(meta.ttcAverage).toBe(125); // (100+150)/2 + }); + + test("handles zero display count", () => { + const meta = getSurveySummaryMeta(mockResponses, 0); + expect(meta.startsPercentage).toBe(0); + expect(meta.completedPercentage).toBe(0); + }); + + test("handles zero responses", () => { + const meta = getSurveySummaryMeta([], 10); + expect(meta.totalResponses).toBe(0); + expect(meta.completedResponses).toBe(0); + expect(meta.dropOffCount).toBe(0); + expect(meta.dropOffPercentage).toBe(0); + expect(meta.ttcAverage).toBe(0); + }); +}); + +describe("getSurveySummaryDropOff", () => { + const surveyWithQuestions: TSurvey = { + ...mockBaseSurvey, + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Q1" }, + required: true, + } as unknown as TSurveyQuestion, + { + id: "q2", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Q2" }, + required: true, + } as unknown as TSurveyQuestion, + ] as TSurveyQuestion[], + }; + + beforeEach(() => { + vi.mocked(getLocalizedValue).mockImplementation((val, _) => val?.default || ""); + vi.mocked(convertFloatTo2Decimal).mockImplementation((num) => + num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0 + ); + vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers + vi.mocked(performActions).mockReturnValue({ + jumpTarget: undefined, + requiredQuestionIds: [], + calculations: {}, + }); + }); + + test("calculates dropOff correctly with welcome card disabled", () => { + const responses = [ + { + id: "r1", + data: { q1: "a" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "en", + ttc: { q1: 10 }, + finished: false, + }, // Dropped at q2 + { + id: "r2", + data: { q1: "b", q2: "c" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "en", + ttc: { q1: 10, q2: 10 }, + finished: true, + }, // Completed + ] as any; + const displayCount = 5; // 5 displays + const dropOff = getSurveySummaryDropOff(surveyWithQuestions, responses, displayCount); + + expect(dropOff.length).toBe(2); + // Q1 + expect(dropOff[0].questionId).toBe("q1"); + expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount + expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1 + expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100 + expect(dropOff[0].ttc).toBe(10); + + // Q2 + expect(dropOff[1].questionId).toBe("q2"); + expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2 + expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2 + expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100 + expect(dropOff[1].ttc).toBe(10); + }); + + test("handles logic jumps", () => { + const surveyWithLogic: TSurvey = { + ...mockBaseSurvey, + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Q1" }, + required: true, + } as unknown as TSurveyQuestion, + { + id: "q2", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Q2" }, + required: true, + logic: [{ conditions: [], actions: [{ type: "jumpTo", details: { value: "q4" } }] }], + } as unknown as TSurveyQuestion, + { id: "q3", type: TSurveyQuestionTypeEnum.OpenText, headline: { default: "Q3" }, required: true }, + { id: "q4", type: TSurveyQuestionTypeEnum.OpenText, headline: { default: "Q4" }, required: true }, + ] as TSurveyQuestion[], + }; + const responses = [ + { + id: "r1", + data: { q1: "a", q2: "b" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "en", + ttc: { q1: 10, q2: 10 }, + finished: false, + }, // Jumps from q2 to q4, drops at q4 + ]; + vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => { + // Simulate logic on q2 triggering + return data.q2 === "b"; + }); + vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => { + if ((actions[0] as any).type === "jumpTo") { + return { jumpTarget: (actions[0] as any).details.value, requiredQuestionIds: [], calculations: {} }; + } + return { jumpTarget: undefined, requiredQuestionIds: [], calculations: {} }; + }); + + const dropOff = getSurveySummaryDropOff(surveyWithLogic, responses, 1); + + expect(dropOff[0].impressions).toBe(1); // q1 + expect(dropOff[1].impressions).toBe(1); // q2 + expect(dropOff[2].impressions).toBe(0); // q3 (skipped) + expect(dropOff[3].impressions).toBe(1); // q4 (jumped to) + expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 + }); +}); + +describe("getQuestionSummary", () => { + const survey: TSurvey = { + ...mockBaseSurvey, + questions: [ + { + id: "q_open", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Open Text" }, + } as unknown as TSurveyQuestion, + { + id: "q_multi_single", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Multi Single" }, + choices: [ + { id: "c1", label: { default: "Choice 1" } }, + { id: "c2", label: { default: "Choice 2" } }, + ], + } as unknown as TSurveyQuestion, + ] as TSurveyQuestion[], + hiddenFields: { enabled: true, fieldIds: ["hidden1"] }, + }; + const responses = [ + { + id: "r1", + data: { q_open: "Open answer", q_multi_single: "Choice 1", hidden1: "Hidden val" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "en", + ttc: {}, + finished: true, + }, + ]; + const mockDropOff: TSurveySummary["dropOff"] = []; // Simplified for this test + + beforeEach(() => { + vi.mocked(getLocalizedValue).mockImplementation((val, _) => val?.default || ""); + vi.mocked(convertFloatTo2Decimal).mockImplementation((num) => + num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0 + ); + // React cache is already mocked globally - no need to mock it again + }); + + test("summarizes OpenText questions", async () => { + const summary = await getQuestionSummary(survey, responses, mockDropOff); + const openTextSummary = summary.find((s: any) => s.question?.id === "q_open"); + expect(openTextSummary?.type).toBe(TSurveyQuestionTypeEnum.OpenText); + expect(openTextSummary?.responseCount).toBe(1); + // @ts-expect-error + expect(openTextSummary?.samples[0].value).toBe("Open answer"); + }); + + test("summarizes MultipleChoiceSingle questions", async () => { + const summary = await getQuestionSummary(survey, responses, mockDropOff); + const multiSingleSummary = summary.find((s: any) => s.question?.id === "q_multi_single"); + expect(multiSingleSummary?.type).toBe(TSurveyQuestionTypeEnum.MultipleChoiceSingle); + expect(multiSingleSummary?.responseCount).toBe(1); + // @ts-expect-error + expect(multiSingleSummary?.choices[0].value).toBe("Choice 1"); + // @ts-expect-error + expect(multiSingleSummary?.choices[0].count).toBe(1); + // @ts-expect-error + expect(multiSingleSummary?.choices[0].percentage).toBe(100); + }); + + test("summarizes HiddenFields", async () => { + const summary = await getQuestionSummary(survey, responses, mockDropOff); + const hiddenFieldSummary = summary.find((s) => s.type === "hiddenField" && s.id === "hidden1"); + expect(hiddenFieldSummary).toBeDefined(); + expect(hiddenFieldSummary?.responseCount).toBe(1); + // @ts-expect-error + expect(hiddenFieldSummary?.samples[0].value).toBe("Hidden val"); + }); + + describe("Ranking question type tests", () => { + test("getQuestionSummary correctly processes ranking question with default language responses", async () => { + const question = { + id: "ranking-q1", + type: TSurveyQuestionTypeEnum.Ranking, + headline: { default: "Rank these items" }, + required: true, + choices: [ + { id: "item1", label: { default: "Item 1" } }, + { id: "item2", label: { default: "Item 2" } }, + { id: "item3", label: { default: "Item 3" } }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "ranking-q1": ["Item 1", "Item 2", "Item 3"] }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "ranking-q1": ["Item 2", "Item 1", "Item 3"] }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "ranking-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Ranking); + expect(summary[0].responseCount).toBe(2); + expect((summary[0] as any).choices).toHaveLength(3); + + // Item 1 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5 + const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1"); + expect(item1.count).toBe(2); + expect(item1.avgRanking).toBe(1.5); + + // Item 2 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5 + const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2"); + expect(item2.count).toBe(2); + expect(item2.avgRanking).toBe(1.5); + + // Item 3 is in position 3 twice, so avg ranking should be 3 + const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3"); + expect(item3.count).toBe(2); + expect(item3.avgRanking).toBe(3); + }); + + test("getQuestionSummary correctly processes ranking question with non-default language responses", async () => { + const question = { + id: "ranking-q1", + type: TSurveyQuestionTypeEnum.Ranking, + headline: { default: "Rank these items", es: "Clasifica estos elementos" }, + required: true, + choices: [ + { id: "item1", label: { default: "Item 1", es: "Elemento 1" } }, + { id: "item2", label: { default: "Item 2", es: "Elemento 2" } }, + { id: "item3", label: { default: "Item 3", es: "Elemento 3" } }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [{ language: { code: "es" }, default: false }], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // Spanish response with Spanish labels + const responses = [ + { + id: "response-1", + data: { "ranking-q1": ["Elemento 2", "Elemento 1", "Elemento 3"] }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "es", + ttc: {}, + finished: true, + }, + ]; + + // Mock checkForI18n for this test case + vi.mock("./surveySummary", async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...(originalModule as object), + checkForI18n: vi.fn().mockImplementation(() => { + // NOSONAR + // Convert Spanish labels to default language labels + return ["Item 2", "Item 1", "Item 3"]; + }), + }; + }); + + const dropOff = [ + { questionId: "ranking-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Ranking); + expect(summary[0].responseCount).toBe(1); + + // Item 1 is in position 2, so avg ranking should be 2 + const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1"); + expect(item1.count).toBe(1); + expect(item1.avgRanking).toBe(2); + + // Item 2 is in position 1, so avg ranking should be 1 + const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2"); + expect(item2.count).toBe(1); + expect(item2.avgRanking).toBe(1); + + // Item 3 is in position 3, so avg ranking should be 3 + const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3"); + expect(item3.count).toBe(1); + expect(item3.avgRanking).toBe(3); + }); + + test("getQuestionSummary handles ranking question with no ranking data in responses", async () => { + const question = { + id: "ranking-q1", + type: TSurveyQuestionTypeEnum.Ranking, + headline: { default: "Rank these items" }, + required: false, + choices: [ + { id: "item1", label: { default: "Item 1" } }, + { id: "item2", label: { default: "Item 2" } }, + { id: "item3", label: { default: "Item 3" } }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // Responses without any ranking data + const responses = [ + { + id: "response-1", + data: {}, // No ranking data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + } as any, + { + id: "response-2", + data: { "other-q": "some value" }, // No ranking data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + } as any, + ]; + + const dropOff = [ + { questionId: "ranking-q1", impressions: 2, dropOffCount: 2, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Ranking); + expect(summary[0].responseCount).toBe(0); + expect((summary[0] as any).choices).toHaveLength(3); + + // All items should have count 0 and avgRanking 0 + (summary[0] as any).choices.forEach((choice) => { + expect(choice.count).toBe(0); + expect(choice.avgRanking).toBe(0); + }); + }); + + test("getQuestionSummary handles ranking question with non-array answers", async () => { + const question = { + id: "ranking-q1", + type: TSurveyQuestionTypeEnum.Ranking, + headline: { default: "Rank these items" }, + required: true, + choices: [ + { id: "item1", label: { default: "Item 1" } }, + { id: "item2", label: { default: "Item 2" } }, + { id: "item3", label: { default: "Item 3" } }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // Responses with invalid ranking data (not an array) + const responses = [ + { + id: "response-1", + data: { "ranking-q1": "Item 1" }, // Not an array + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "ranking-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Ranking); + expect(summary[0].responseCount).toBe(0); // No valid responses + expect((summary[0] as any).choices).toHaveLength(3); + + // All items should have count 0 and avgRanking 0 since we had no valid ranking data + (summary[0] as any).choices.forEach((choice) => { + expect(choice.count).toBe(0); + expect(choice.avgRanking).toBe(0); + }); + }); + + test("getQuestionSummary handles ranking question with values not in choices", async () => { + const question = { + id: "ranking-q1", + type: TSurveyQuestionTypeEnum.Ranking, + headline: { default: "Rank these items" }, + required: true, + choices: [ + { id: "item1", label: { default: "Item 1" } }, + { id: "item2", label: { default: "Item 2" } }, + { id: "item3", label: { default: "Item 3" } }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // Response with some values not in choices + const responses = [ + { + id: "response-1", + data: { "ranking-q1": ["Item 1", "Unknown Item", "Item 3"] }, // "Unknown Item" is not in choices + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "ranking-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Ranking); + expect(summary[0].responseCount).toBe(1); + expect((summary[0] as any).choices).toHaveLength(3); + + // Item 1 is in position 1, so avg ranking should be 1 + const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1"); + expect(item1.count).toBe(1); + expect(item1.avgRanking).toBe(1); + + // Item 2 was not ranked, so should have count 0 and avgRanking 0 + const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2"); + expect(item2.count).toBe(0); + expect(item2.avgRanking).toBe(0); + + // Item 3 is in position 3, so avg ranking should be 3 + const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3"); + expect(item3.count).toBe(1); + expect(item3.avgRanking).toBe(3); + }); + }); +}); + +describe("getSurveySummary", () => { + beforeEach(() => { + vi.resetAllMocks(); + // Default mocks for services + vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey); + vi.mocked(getResponseCountBySurveyId).mockResolvedValue(mockResponses.length); + // For getResponsesForSummary mock, we need to ensure it's correctly used by getSurveySummary + // Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany + // which is used by the actual implementation of getResponsesForSummary. + vi.mocked(prisma.response.findMany).mockResolvedValue( + mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any + ); + vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10); + + // Mock internal function calls if they are complex, otherwise let them run with mocked data + // For simplicity, we can assume getSurveySummaryDropOff and getQuestionSummary are tested independently + // and will work correctly if their inputs (survey, responses, displayCount) are correct. + // Or, provide simplified mocks for them if needed. + vi.mocked(getLocalizedValue).mockImplementation((val, _) => val?.default || ""); + vi.mocked(convertFloatTo2Decimal).mockImplementation((num) => + num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0 + ); + // React cache is already mocked globally - no need to mock it again + }); + + test("returns survey summary successfully", async () => { + const summary = await getSurveySummary(mockSurveyId); + expect(summary.meta.totalResponses).toBe(mockResponses.length); + expect(summary.meta.displayCount).toBe(10); + expect(summary.dropOff).toBeDefined(); + expect(summary.summary).toBeDefined(); + expect(getSurvey).toHaveBeenCalledWith(mockSurveyId); + expect(prisma.response.findMany).toHaveBeenCalled(); // Check if getResponsesForSummary was effectively called + expect(getDisplayCountBySurveyId).toHaveBeenCalled(); + }); + + test("throws ResourceNotFoundError if survey not found", async () => { + vi.mocked(getSurvey).mockResolvedValue(null); + await expect(getSurveySummary(mockSurveyId)).rejects.toThrow(ResourceNotFoundError); + }); + + test("handles filterCriteria", async () => { + const filterCriteria: TResponseFilterCriteria = { finished: true }; + const finishedResponses = mockResponses + .filter((r) => r.finished) + .map((r) => ({ ...r, contactId: null, personAttributes: {} })); + vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any); + + await getSurveySummary(mockSurveyId, filterCriteria); + + expect(prisma.response.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ surveyId: mockSurveyId }), // buildWhereClause is mocked + }) + ); + expect(getDisplayCountBySurveyId).toHaveBeenCalledWith( + mockSurveyId, + expect.objectContaining({ responseIds: expect.any(Array) }) + ); + }); +}); + +describe("getResponsesForSummary", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey); + vi.mocked(prisma.response.findMany).mockResolvedValue( + mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any + ); + // React cache is already mocked globally - no need to mock it again + }); + + test("fetches and transforms responses", async () => { + const limit = 2; + const offset = 0; + const result = await getResponsesForSummary(mockSurveyId, limit, offset); + + expect(getSurvey).toHaveBeenCalledWith(mockSurveyId); + expect(prisma.response.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: limit, + skip: offset, + where: { surveyId: mockSurveyId }, // buildWhereClause is mocked to return {} + }) + ); + expect(result.length).toBe(mockResponses.length); // Mock returns all, actual would be limited by prisma + expect(result[0].id).toBe(mockResponses[0].id); + expect(result[0].contact).toBeNull(); // As per transformation logic + }); + + test("returns empty array if survey not found", async () => { + vi.mocked(getSurvey).mockResolvedValue(null); + const result = await getResponsesForSummary(mockSurveyId, 10, 0); + expect(result).toEqual([]); + }); + + test("throws DatabaseError on prisma failure", async () => { + vi.mocked(prisma.response.findMany).mockRejectedValue(new Error("DB error")); + await expect(getResponsesForSummary(mockSurveyId, 10, 0)).rejects.toThrow("DB error"); + }); + + test("getResponsesForSummary handles null contact properly", async () => { + const mockSurvey = { id: "survey-1" } as unknown as TSurvey; + const mockResponse = { + id: "response-1", + data: {}, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "en", + ttc: {}, + finished: true, + createdAt: new Date(), + meta: {}, + variables: {}, + surveyId: "survey-1", + contactId: null, + personAttributes: {}, + singleUseId: null, + isFinished: true, + displayId: "display-1", + endingId: null, + }; + + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + vi.mocked(prisma.response.findMany).mockResolvedValue([mockResponse]); + + const result = await getResponsesForSummary("survey-1", 10, 0); + + expect(result).toHaveLength(1); + expect(result[0].contact).toBeNull(); + expect(prisma.response.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { surveyId: "survey-1" }, + }) + ); + }); + + test("getResponsesForSummary extracts contact id and userId when contact exists", async () => { + const mockSurvey = { id: "survey-1" } as unknown as TSurvey; + const mockResponse = { + id: "response-1", + data: {}, + updatedAt: new Date(), + contact: { + id: "contact-1", + attributes: [ + { attributeKey: { key: "userId" }, value: "user-123" }, + { attributeKey: { key: "email" }, value: "test@example.com" }, + ], + }, + contactAttributes: {}, + language: "en", + ttc: {}, + finished: true, + createdAt: new Date(), + meta: {}, + variables: {}, + surveyId: "survey-1", + contactId: "contact-1", + personAttributes: {}, + singleUseId: null, + isFinished: true, + displayId: "display-1", + endingId: null, + }; + + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + vi.mocked(prisma.response.findMany).mockResolvedValue([mockResponse]); + + const result = await getResponsesForSummary("survey-1", 10, 0); + + expect(result).toHaveLength(1); + expect(result[0].contact).toEqual({ + id: "contact-1", + userId: "user-123", + }); + }); + + test("getResponsesForSummary handles contact without userId attribute", async () => { + const mockSurvey = { id: "survey-1" } as unknown as TSurvey; + const mockResponse = { + id: "response-1", + data: {}, + updatedAt: new Date(), + contact: { + id: "contact-1", + attributes: [{ attributeKey: { key: "email" }, value: "test@example.com" }], + }, + contactAttributes: {}, + language: "en", + ttc: {}, + finished: true, + createdAt: new Date(), + meta: {}, + variables: {}, + surveyId: "survey-1", + contactId: "contact-1", + personAttributes: {}, + singleUseId: null, + isFinished: true, + displayId: "display-1", + endingId: null, + }; + + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + vi.mocked(prisma.response.findMany).mockResolvedValue([mockResponse]); + + const result = await getResponsesForSummary("survey-1", 10, 0); + + expect(result).toHaveLength(1); + expect(result[0].contact).toEqual({ + id: "contact-1", + userId: undefined, + }); + }); + + test("getResponsesForSummary throws DatabaseError when Prisma throws PrismaClientKnownRequestError", async () => { + vi.mocked(getSurvey).mockResolvedValue({ id: "survey-1" } as unknown as TSurvey); + + const prismaError = new Prisma.PrismaClientKnownRequestError("Database connection error", { + code: "P2002", + clientVersion: "4.0.0", + }); + + vi.mocked(prisma.response.findMany).mockRejectedValue(prismaError); + + await expect(getResponsesForSummary("survey-1", 10, 0)).rejects.toThrow(DatabaseError); + await expect(getResponsesForSummary("survey-1", 10, 0)).rejects.toThrow("Database connection error"); + }); + + test("getResponsesForSummary rethrows non-Prisma errors", async () => { + vi.mocked(getSurvey).mockResolvedValue({ id: "survey-1" } as unknown as TSurvey); + + const genericError = new Error("Something else went wrong"); + vi.mocked(prisma.response.findMany).mockRejectedValue(genericError); + + await expect(getResponsesForSummary("survey-1", 10, 0)).rejects.toThrow("Something else went wrong"); + await expect(getResponsesForSummary("survey-1", 10, 0)).rejects.toThrow(Error); + await expect(getResponsesForSummary("survey-1", 10, 0)).rejects.not.toThrow(DatabaseError); + }); + + test("getSurveySummary throws DatabaseError when Prisma throws PrismaClientKnownRequestError", async () => { + vi.mocked(getSurvey).mockResolvedValue({ + id: "survey-1", + questions: [], + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + languages: [], + } as unknown as TSurvey); + + vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10); + + const prismaError = new Prisma.PrismaClientKnownRequestError("Database connection error", { + code: "P2002", + clientVersion: "4.0.0", + }); + + vi.mocked(prisma.response.findMany).mockRejectedValue(prismaError); + + await expect(getSurveySummary("survey-1")).rejects.toThrow(DatabaseError); + await expect(getSurveySummary("survey-1")).rejects.toThrow("Database connection error"); + }); + + test("getSurveySummary rethrows non-Prisma errors", async () => { + vi.mocked(getSurvey).mockResolvedValue({ + id: "survey-1", + questions: [], + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + languages: [], + } as unknown as TSurvey); + + vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10); + + const genericError = new Error("Something else went wrong"); + vi.mocked(prisma.response.findMany).mockRejectedValue(genericError); + + await expect(getSurveySummary("survey-1")).rejects.toThrow("Something else went wrong"); + await expect(getSurveySummary("survey-1")).rejects.toThrow(Error); + await expect(getSurveySummary("survey-1")).rejects.not.toThrow(DatabaseError); + }); +}); + +describe("Address and ContactInfo question types", () => { + test("getQuestionSummary correctly processes Address question with valid responses", async () => { + const question = { + id: "address-q1", + type: TSurveyQuestionTypeEnum.Address, + headline: { default: "What's your address?" }, + required: true, + fields: ["line1", "line2", "city", "state", "zip", "country"], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "address-q1": [ + { type: "line1", value: "123 Main St" }, + { type: "city", value: "San Francisco" }, + { type: "state", value: "CA" }, + ], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + } as any, + { + id: "response-2", + data: { + "address-q1": [ + { type: "line1", value: "456 Oak Ave" }, + { type: "city", value: "Seattle" }, + { type: "state", value: "WA" }, + ], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + } as any, + ]; + + const dropOff = [ + { questionId: "address-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Address); + expect(summary[0].responseCount).toBe(2); + expect((summary[0] as any).samples).toHaveLength(2); + expect((summary[0] as any).samples[0].value).toEqual(responses[0].data["address-q1"]); + expect((summary[0] as any).samples[1].value).toEqual(responses[1].data["address-q1"]); + }); + + test("getQuestionSummary correctly processes ContactInfo question with valid responses", async () => { + const question = { + id: "contact-q1", + type: TSurveyQuestionTypeEnum.ContactInfo, + headline: { default: "Your contact information" }, + required: true, + fields: ["firstName", "lastName", "email", "phone"], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "contact-q1": [ + { type: "firstName", value: "John" }, + { type: "lastName", value: "Doe" }, + { type: "email", value: "john@example.com" }, + ], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { + "contact-q1": [ + { type: "firstName", value: "Jane" }, + { type: "lastName", value: "Smith" }, + { type: "email", value: "jane@example.com" }, + ], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "contact-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.ContactInfo); + expect((summary[0] as any).responseCount).toBe(2); + expect((summary[0] as any).samples).toHaveLength(2); + expect((summary[0] as any).samples[0].value).toEqual(responses[0].data["contact-q1"]); + expect((summary[0] as any).samples[1].value).toEqual(responses[1].data["contact-q1"]); + }); + + test("getQuestionSummary handles empty array answers for Address type", async () => { + const question = { + id: "address-q1", + type: TSurveyQuestionTypeEnum.Address, + headline: { default: "What's your address?" }, + required: false, + fields: ["line1", "line2", "city", "state", "zip", "country"], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "address-q1": [] }, // Empty array + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "address-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect((summary[0] as any).type).toBe(TSurveyQuestionTypeEnum.Address); + expect((summary[0] as any).responseCount).toBe(0); // Should be 0 as empty array doesn't count as response + expect((summary[0] as any).samples).toHaveLength(0); + }); + + test("getQuestionSummary handles non-array answers for ContactInfo type", async () => { + const question = { + id: "contact-q1", + type: TSurveyQuestionTypeEnum.ContactInfo, + headline: { default: "Your contact information" }, + required: true, + fields: ["firstName", "lastName", "email", "phone"], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "contact-q1": "Not an array" }, // String instead of array + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "contact-q1": { name: "John" } }, // Object instead of array + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-3", + data: {}, // No data for this question + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "contact-q1", impressions: 3, dropOffCount: 3, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect((summary[0] as any).type).toBe(TSurveyQuestionTypeEnum.ContactInfo); + expect((summary[0] as any).responseCount).toBe(0); // Should be 0 as no valid responses + expect((summary[0] as any).samples).toHaveLength(0); + }); + + test("getQuestionSummary handles mix of valid and invalid responses for Address type", async () => { + const question = { + id: "address-q1", + type: TSurveyQuestionTypeEnum.Address, + headline: { default: "What's your address?" }, + required: true, + fields: ["line1", "line2", "city", "state", "zip", "country"], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // One valid response, one invalid + const responses = [ + { + id: "response-1", + data: { + "address-q1": [ + { type: "line1", value: "123 Main St" }, + { type: "city", value: "San Francisco" }, + ], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "address-q1": "Invalid format" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "address-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect((summary[0] as any).type).toBe(TSurveyQuestionTypeEnum.Address); + expect((summary[0] as any).responseCount).toBe(1); // Should be 1 as only one valid response + expect((summary[0] as any).samples).toHaveLength(1); + expect((summary[0] as any).samples[0].value).toEqual(responses[0].data["address-q1"]); + }); + + test("getQuestionSummary applies VALUES_LIMIT correctly for ContactInfo type", async () => { + const question = { + id: "contact-q1", + type: TSurveyQuestionTypeEnum.ContactInfo, + headline: { default: "Your contact information" }, + required: true, + fields: ["firstName", "lastName", "email"], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // Create 100 responses (more than VALUES_LIMIT which is 50) + const responses = Array.from( + { length: 100 }, + (_, i) => + ({ + id: `response-${i}`, + data: { + "contact-q1": [ + { type: "firstName", value: `First${i}` }, + { type: "lastName", value: `Last${i}` }, + { type: "email", value: `user${i}@example.com` }, + ], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }) as any + ); + + const dropOff = [ + { questionId: "contact-q1", impressions: 100, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect((summary[0] as any).type).toBe(TSurveyQuestionTypeEnum.ContactInfo); + expect((summary[0] as any).responseCount).toBe(100); // All responses are valid + expect((summary[0] as any).samples).toHaveLength(50); // Limited to VALUES_LIMIT (50) + }); +}); + +describe("Matrix question type tests", () => { + test("getQuestionSummary correctly processes Matrix question with valid responses", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects" }, + required: true, + rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }], + columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }, { default: "Excellent" }], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "matrix-q1": { + Speed: "Good", + Quality: "Excellent", + Price: "Average", + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { + "matrix-q1": { + Speed: "Average", + Quality: "Good", + Price: "Poor", + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(2); + + // Verify Speed row + const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); + expect(speedRow.totalResponsesForRow).toBe(2); + expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns + expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50); + expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50); + + // Verify Quality row + const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); + expect(qualityRow.totalResponsesForRow).toBe(2); + expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(50); + expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50); + + // Verify Price row + const priceRow = summary[0].data.find((row) => row.rowLabel === "Price"); + expect(priceRow.totalResponsesForRow).toBe(2); + expect(priceRow.columnPercentages.find((col) => col.column === "Poor").percentage).toBe(50); + expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50); + }); + + test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects", es: "Califica estos aspectos" }, + required: true, + rows: [ + { default: "Speed", es: "Velocidad" }, + { default: "Quality", es: "Calidad" }, + { default: "Price", es: "Precio" }, + ], + columns: [ + { default: "Poor", es: "Malo" }, + { default: "Average", es: "Promedio" }, + { default: "Good", es: "Bueno" }, + { default: "Excellent", es: "Excelente" }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [{ language: { code: "es" }, default: false }], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // Spanish response with Spanish labels + const responses = [ + { + id: "response-1", + data: { + "matrix-q1": { + Velocidad: "Bueno", + Calidad: "Excelente", + Precio: "Promedio", + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "es", + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + // Mock getLocalizedValue for this test + const getLocalizedValueOriginal = getLocalizedValue; + vi.mocked(getLocalizedValue).mockImplementation((obj, langCode) => { + if (!obj) return ""; + + if (langCode === "es" && typeof obj === "object" && "es" in obj) { + return obj.es; + } + + if (typeof obj === "object" && "default" in obj) { + return obj.default; + } + + return ""; + }); + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + // Reset the mock after test + vi.mocked(getLocalizedValue).mockImplementation(getLocalizedValueOriginal); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(1); + + // Verify Speed row with localized values mapped to default language + const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); + expect(speedRow.totalResponsesForRow).toBe(1); + expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); + + // Verify Quality row + const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); + expect(qualityRow.totalResponsesForRow).toBe(1); + expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(100); + + // Verify Price row + const priceRow = summary[0].data.find((row) => row.rowLabel === "Price"); + expect(priceRow.totalResponsesForRow).toBe(1); + expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100); + }); + + test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects" }, + required: false, + rows: [{ default: "Speed" }, { default: "Quality" }], + columns: [{ default: "Poor" }, { default: "Good" }], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: {}, // No matrix data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { + "matrix-q1": "Not an object", // Invalid format - not an object + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-3", + data: { + "matrix-q1": {}, // Empty object + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-4", + data: { + "matrix-q1": { + Speed: "Invalid", // Value not in columns + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 4, dropOffCount: 4, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property + + // All rows should have zero responses for all columns + summary[0].data.forEach((row) => { + expect(row.totalResponsesForRow).toBe(0); + row.columnPercentages.forEach((col) => { + expect(col.percentage).toBe(0); + }); + }); + }); + + test("getQuestionSummary handles partial and incomplete matrix responses", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects" }, + required: true, + rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }], + columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "matrix-q1": { + Speed: "Good", + // Quality is missing + Price: "Average", + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { + "matrix-q1": { + Speed: "Average", + Quality: "Good", + Price: "Poor", + ExtraRow: "Poor", // Row not in question definition + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(2); + + // Verify Speed row - both responses provided data + const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); + expect(speedRow.totalResponsesForRow).toBe(2); + expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50); + expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50); + + // Verify Quality row - only one response provided data + const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); + expect(qualityRow.totalResponsesForRow).toBe(1); + expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); + + // Verify Price row - both responses provided data + const priceRow = summary[0].data.find((row) => row.rowLabel === "Price"); + expect(priceRow.totalResponsesForRow).toBe(2); + + // ExtraRow should not appear in the summary + expect(summary[0].data.find((row) => row.rowLabel === "ExtraRow")).toBeUndefined(); + }); + + test("getQuestionSummary handles zero responses for Matrix question correctly", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects" }, + required: true, + rows: [{ default: "Speed" }, { default: "Quality" }], + columns: [{ default: "Poor" }, { default: "Good" }], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // No responses with matrix data + const responses = [ + { + id: "response-1", + data: { "other-question": "value" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(0); + + // All rows should have proper structure but zero counts + expect(summary[0].data).toHaveLength(2); // 2 rows + + summary[0].data.forEach((row) => { + expect(row.columnPercentages).toHaveLength(2); // 2 columns + expect(row.totalResponsesForRow).toBe(0); + expect(row.columnPercentages[0].percentage).toBe(0); + expect(row.columnPercentages[1].percentage).toBe(0); + }); + }); + + test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects" }, + required: true, + rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }], + columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "matrix-q1": { + Speed: "Good", // Valid + Quality: "Invalid Column", // Invalid + Price: "Average", // Valid + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(1); + + // Speed row should have a valid response + const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); + expect(speedRow.totalResponsesForRow).toBe(1); + expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); + + // Quality row should have no valid responses + const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); + expect(qualityRow.totalResponsesForRow).toBe(0); + qualityRow.columnPercentages.forEach((col) => { + expect(col.percentage).toBe(0); + }); + + // Price row should have a valid response + const priceRow = summary[0].data.find((row) => row.rowLabel === "Price"); + expect(priceRow.totalResponsesForRow).toBe(1); + expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100); + }); + + test("getQuestionSummary handles Matrix question with invalid row labels", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects" }, + required: true, + rows: [{ default: "Speed" }, { default: "Quality" }], + columns: [{ default: "Poor" }, { default: "Good" }], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "matrix-q1": { + Speed: "Good", // Valid + InvalidRow: "Poor", // Invalid row + AnotherInvalidRow: "Good", // Invalid row + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(1); + + // There should only be rows for the defined question rows + expect(summary[0].data).toHaveLength(2); // 2 rows + + // Speed row should have a valid response + const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); + expect(speedRow.totalResponsesForRow).toBe(1); + expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); + + // Quality row should have no responses + const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); + expect(qualityRow.totalResponsesForRow).toBe(0); + + // Invalid rows should not appear in the summary + expect(summary[0].data.find((row) => row.rowLabel === "InvalidRow")).toBeUndefined(); + expect(summary[0].data.find((row) => row.rowLabel === "AnotherInvalidRow")).toBeUndefined(); + }); + + test("getQuestionSummary handles Matrix question with mixed language responses", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects", fr: "Évaluez ces aspects" }, + required: true, + rows: [ + { default: "Speed", fr: "Vitesse" }, + { default: "Quality", fr: "Qualité" }, + ], + columns: [ + { default: "Poor", fr: "Médiocre" }, + { default: "Good", fr: "Bon" }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [ + { language: { code: "en" }, default: true }, + { language: { code: "fr" }, default: false }, + ], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "matrix-q1": { + Speed: "Good", // English + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "en", + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { + "matrix-q1": { + Vitesse: "Bon", // French + }, + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: "fr", + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + // Mock getLocalizedValue to handle our specific test case + const originalGetLocalizedValue = getLocalizedValue; + vi.mocked(getLocalizedValue).mockImplementation((obj, langCode) => { + if (!obj) return ""; + + if (langCode === "fr" && typeof obj === "object" && "fr" in obj) { + return obj.fr; + } + + if (typeof obj === "object" && "default" in obj) { + return obj.default; + } + + return ""; + }); + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + // Reset mock + vi.mocked(getLocalizedValue).mockImplementation(originalGetLocalizedValue); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(2); + + // Speed row should have both responses + const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed"); + expect(speedRow.totalResponsesForRow).toBe(2); + expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100); + + // Quality row should have no responses + const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality"); + expect(qualityRow.totalResponsesForRow).toBe(0); + }); + + test("getQuestionSummary handles Matrix question with null response data", async () => { + const question = { + id: "matrix-q1", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Rate these aspects" }, + required: true, + rows: [{ default: "Speed" }, { default: "Quality" }], + columns: [{ default: "Poor" }, { default: "Good" }], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "matrix-q1": null, // Null response data + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Matrix); + expect(summary[0].responseCount).toBe(0); // Counts as response even with null data + + // Both rows should have zero responses + summary[0].data.forEach((row) => { + expect(row.totalResponsesForRow).toBe(0); + row.columnPercentages.forEach((col) => { + expect(col.percentage).toBe(0); + }); + }); + }); +}); + +describe("NPS question type tests", () => { + test("getQuestionSummary correctly processes NPS question with valid responses", async () => { + const question = { + id: "nps-q1", + type: TSurveyQuestionTypeEnum.NPS, + headline: { default: "How likely are you to recommend us?" }, + required: true, + lowerLabel: { default: "Not likely" }, + upperLabel: { default: "Very likely" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "nps-q1": 10 }, // Promoter (9-10) + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "nps-q1": 7 }, // Passive (7-8) + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-3", + data: { "nps-q1": 3 }, // Detractor (0-6) + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-4", + data: { "nps-q1": 9 }, // Promoter (9-10) + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "nps-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.NPS); + expect(summary[0].responseCount).toBe(4); + + // NPS score = (promoters - detractors) / total * 100 + // Promoters: 2, Detractors: 1, Total: 4 + // (2 - 1) / 4 * 100 = 25 + expect(summary[0].score).toBe(25); + + // Verify promoters + expect(summary[0].promoters.count).toBe(2); + expect(summary[0].promoters.percentage).toBe(50); // 2/4 * 100 + + // Verify passives + expect(summary[0].passives.count).toBe(1); + expect(summary[0].passives.percentage).toBe(25); // 1/4 * 100 + + // Verify detractors + expect(summary[0].detractors.count).toBe(1); + expect(summary[0].detractors.percentage).toBe(25); // 1/4 * 100 + + // Verify dismissed (none in this test) + expect(summary[0].dismissed.count).toBe(0); + expect(summary[0].dismissed.percentage).toBe(0); + }); + + test("getQuestionSummary handles NPS question with dismissed responses", async () => { + const question = { + id: "nps-q1", + type: TSurveyQuestionTypeEnum.NPS, + headline: { default: "How likely are you to recommend us?" }, + required: false, + lowerLabel: { default: "Not likely" }, + upperLabel: { default: "Very likely" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "nps-q1": 10 }, // Promoter + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "nps-q1": 5 }, + finished: true, + }, + { + id: "response-2", + data: {}, // No answer but has time tracking + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "nps-q1": 3 }, + finished: true, + }, + { + id: "response-3", + data: {}, // No answer but has time tracking + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "nps-q1": 2 }, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "nps-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.NPS); + expect(summary[0].responseCount).toBe(3); + + // NPS score = (promoters - detractors) / total * 100 + // Promoters: 1, Detractors: 0, Total: 3 + // (1 - 0) / 3 * 100 = 33.33 + expect(summary[0].score).toBe(33.33); + + // Verify promoters + expect(summary[0].promoters.count).toBe(1); + expect(summary[0].promoters.percentage).toBe(33.33); // 1/3 * 100 + + // Verify dismissed + expect(summary[0].dismissed.count).toBe(2); + expect(summary[0].dismissed.percentage).toBe(66.67); // 2/3 * 100 + }); + + test("getQuestionSummary handles NPS question with no responses", async () => { + const question = { + id: "nps-q1", + type: TSurveyQuestionTypeEnum.NPS, + headline: { default: "How likely are you to recommend us?" }, + required: true, + lowerLabel: { default: "Not likely" }, + upperLabel: { default: "Very likely" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // No responses with NPS data + const responses = [ + { + id: "response-1", + data: { "other-q": "value" }, // No NPS data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "nps-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.NPS); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].score).toBe(0); + + expect(summary[0].promoters.count).toBe(0); + expect(summary[0].promoters.percentage).toBe(0); + + expect(summary[0].passives.count).toBe(0); + expect(summary[0].passives.percentage).toBe(0); + + expect(summary[0].detractors.count).toBe(0); + expect(summary[0].detractors.percentage).toBe(0); + + expect(summary[0].dismissed.count).toBe(0); + expect(summary[0].dismissed.percentage).toBe(0); + }); + + test("getQuestionSummary handles NPS question with invalid values", async () => { + const question = { + id: "nps-q1", + type: TSurveyQuestionTypeEnum.NPS, + headline: { default: "How likely are you to recommend us?" }, + required: true, + lowerLabel: { default: "Not likely" }, + upperLabel: { default: "Very likely" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "nps-q1": "invalid" }, // String instead of number + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "nps-q1": null }, // Null value + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-3", + data: { "nps-q1": 5 }, // Valid detractor + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "nps-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.NPS); + expect(summary[0].responseCount).toBe(1); // Only one valid response + + // Only one valid response is a detractor + expect(summary[0].detractors.count).toBe(1); + expect(summary[0].detractors.percentage).toBe(100); + + // Score should be -100 since all valid responses are detractors + expect(summary[0].score).toBe(-100); + }); +}); + +describe("Rating question type tests", () => { + test("getQuestionSummary correctly processes Rating question with valid responses", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "How would you rate our service?" }, + required: true, + scale: "number", + range: 5, // 1-5 rating + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "rating-q1": 5 }, // Highest rating + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "rating-q1": 4 }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-3", + data: { "rating-q1": 3 }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-4", + data: { "rating-q1": 5 }, // Another highest rating + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Rating); + expect(summary[0].responseCount).toBe(4); + + // Average rating = (5 + 4 + 3 + 5) / 4 = 4.25 + expect(summary[0].average).toBe(4.25); + + // Verify each rating option count and percentage + const rating5 = summary[0].choices.find((c) => c.rating === 5); + expect(rating5.count).toBe(2); + expect(rating5.percentage).toBe(50); // 2/4 * 100 + + const rating4 = summary[0].choices.find((c) => c.rating === 4); + expect(rating4.count).toBe(1); + expect(rating4.percentage).toBe(25); // 1/4 * 100 + + const rating3 = summary[0].choices.find((c) => c.rating === 3); + expect(rating3.count).toBe(1); + expect(rating3.percentage).toBe(25); // 1/4 * 100 + + const rating2 = summary[0].choices.find((c) => c.rating === 2); + expect(rating2.count).toBe(0); + expect(rating2.percentage).toBe(0); + + const rating1 = summary[0].choices.find((c) => c.rating === 1); + expect(rating1.count).toBe(0); + expect(rating1.percentage).toBe(0); + + // Verify dismissed (none in this test) + expect(summary[0].dismissed.count).toBe(0); + }); + + test("getQuestionSummary handles Rating question with dismissed responses", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "How would you rate our service?" }, + required: false, + scale: "number", + range: 5, + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "rating-q1": 5 }, // Valid rating + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "rating-q1": 3 }, + finished: true, + }, + { + id: "response-2", + data: {}, // No answer, but has time tracking + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "rating-q1": 2 }, + finished: true, + }, + { + id: "response-3", + data: {}, // No answer, but has time tracking + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "rating-q1": 4 }, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Rating); + expect(summary[0].responseCount).toBe(1); // Only one valid rating + expect(summary[0].average).toBe(5); // Average of the one valid rating + + // Verify dismissed count + expect(summary[0].dismissed.count).toBe(2); + }); + + test("getQuestionSummary handles Rating question with no responses", async () => { + const question = { + id: "rating-q1", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "How would you rate our service?" }, + required: true, + scale: "number", + range: 5, + lowerLabel: { default: "Poor" }, + upperLabel: { default: "Excellent" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // No responses with rating data + const responses = [ + { + id: "response-1", + data: { "other-q": "value" }, // No rating data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "rating-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Rating); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].average).toBe(0); + + // Verify all ratings have 0 count and percentage + summary[0].choices.forEach((choice) => { + expect(choice.count).toBe(0); + expect(choice.percentage).toBe(0); + }); + + // Verify dismissed is 0 + expect(summary[0].dismissed.count).toBe(0); + }); +}); + +describe("PictureSelection question type tests", () => { + test("getQuestionSummary correctly processes PictureSelection with valid responses", async () => { + const question = { + id: "picture-q1", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: { default: "Select the images you like" }, + required: true, + choices: [ + { id: "img1", imageUrl: "https://example.com/img1.jpg" }, + { id: "img2", imageUrl: "https://example.com/img2.jpg" }, + { id: "img3", imageUrl: "https://example.com/img3.jpg" }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "picture-q1": ["img1", "img3"] }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "picture-q1": ["img2"] }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "picture-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.PictureSelection); + expect(summary[0].responseCount).toBe(2); + expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3 + + // Check individual choice counts + const img1 = summary[0].choices.find((c) => c.id === "img1"); + expect(img1.count).toBe(1); + expect(img1.percentage).toBe(50); + + const img2 = summary[0].choices.find((c) => c.id === "img2"); + expect(img2.count).toBe(1); + expect(img2.percentage).toBe(50); + + const img3 = summary[0].choices.find((c) => c.id === "img3"); + expect(img3.count).toBe(1); + expect(img3.percentage).toBe(50); + }); + + test("getQuestionSummary handles PictureSelection with no valid responses", async () => { + const question = { + id: "picture-q1", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: { default: "Select the images you like" }, + required: true, + choices: [ + { id: "img1", imageUrl: "https://example.com/img1.jpg" }, + { id: "img2", imageUrl: "https://example.com/img2.jpg" }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "picture-q1": "not-an-array" }, // Invalid format + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: {}, // No data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "picture-q1", impressions: 2, dropOffCount: 2, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.PictureSelection); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].selectionCount).toBe(0); + + // All choices should have zero count + summary[0].choices.forEach((choice) => { + expect(choice.count).toBe(0); + expect(choice.percentage).toBe(0); + }); + }); + + test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => { + const question = { + id: "picture-q1", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: { default: "Select the images you like" }, + required: true, + choices: [ + { id: "img1", imageUrl: "https://example.com/img1.jpg" }, + { id: "img2", imageUrl: "https://example.com/img2.jpg" }, + ], + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "picture-q1": ["invalid-id", "img1"] }, // One valid, one invalid ID + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "picture-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.PictureSelection); + expect(summary[0].responseCount).toBe(1); + expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one + + // img1 should be counted + const img1 = summary[0].choices.find((c) => c.id === "img1"); + expect(img1.count).toBe(1); + expect(img1.percentage).toBe(100); + + // img2 should not be counted + const img2 = summary[0].choices.find((c) => c.id === "img2"); + expect(img2.count).toBe(0); + expect(img2.percentage).toBe(0); + + // Invalid ID should not appear in choices + expect(summary[0].choices.find((c) => c.id === "invalid-id")).toBeUndefined(); + }); +}); + +describe("CTA question type tests", () => { + test("getQuestionSummary correctly processes CTA with valid responses", async () => { + const question = { + id: "cta-q1", + type: TSurveyQuestionTypeEnum.CTA, + headline: { default: "Would you like to try our product?" }, + buttonLabel: { default: "Try Now" }, + buttonExternal: false, + buttonUrl: "https://example.com", + required: true, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "cta-q1": "clicked" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "cta-q1": "dismissed" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-3", + data: { "cta-q1": "clicked" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { + questionId: "cta-q1", + impressions: 5, // 5 total impressions (including 2 that didn't respond) + dropOffCount: 0, + dropOffPercentage: 0, + }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.CTA); + expect(summary[0].responseCount).toBe(3); + expect(summary[0].impressionCount).toBe(5); + expect(summary[0].clickCount).toBe(2); + expect(summary[0].skipCount).toBe(1); + + // CTR calculation: clicks / impressions * 100 + expect(summary[0].ctr.count).toBe(2); + expect(summary[0].ctr.percentage).toBe(40); // (2/5)*100 = 40% + }); + + test("getQuestionSummary handles CTA with no responses", async () => { + const question = { + id: "cta-q1", + type: TSurveyQuestionTypeEnum.CTA, + headline: { default: "Would you like to try our product?" }, + buttonLabel: { default: "Try Now" }, + buttonExternal: false, + buttonUrl: "https://example.com", + required: false, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: {}, // No data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { + questionId: "cta-q1", + impressions: 3, // 3 total impressions + dropOffCount: 3, + dropOffPercentage: 100, + }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.CTA); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].impressionCount).toBe(3); + expect(summary[0].clickCount).toBe(0); + expect(summary[0].skipCount).toBe(0); + + expect(summary[0].ctr.count).toBe(0); + expect(summary[0].ctr.percentage).toBe(0); + }); +}); + +describe("Consent question type tests", () => { + test("getQuestionSummary correctly processes Consent with valid responses", async () => { + const question = { + id: "consent-q1", + type: TSurveyQuestionTypeEnum.Consent, + headline: { default: "Do you consent to our terms?" }, + required: true, + label: { default: "I agree to the terms" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "consent-q1": "accepted" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: {}, // Nothing, but time was spent so it's dismissed + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "consent-q1": 5 }, + finished: true, + }, + { + id: "response-3", + data: { "consent-q1": "accepted" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "consent-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Consent); + expect(summary[0].responseCount).toBe(3); + + // 2 accepted / 3 total = 66.67% + expect(summary[0].accepted.count).toBe(2); + expect(summary[0].accepted.percentage).toBe(66.67); + + // 1 dismissed / 3 total = 33.33% + expect(summary[0].dismissed.count).toBe(1); + expect(summary[0].dismissed.percentage).toBe(33.33); + }); + + test("getQuestionSummary handles Consent with no responses", async () => { + const question = { + id: "consent-q1", + type: TSurveyQuestionTypeEnum.Consent, + headline: { default: "Do you consent to our terms?" }, + required: false, + label: { default: "I agree to the terms" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "other-q": "value" }, // No consent data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "consent-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Consent); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].accepted.count).toBe(0); + expect(summary[0].accepted.percentage).toBe(0); + expect(summary[0].dismissed.count).toBe(0); + expect(summary[0].dismissed.percentage).toBe(0); + }); + + test("getQuestionSummary handles Consent with invalid values", async () => { + const question = { + id: "consent-q1", + type: TSurveyQuestionTypeEnum.Consent, + headline: { default: "Do you consent to our terms?" }, + required: true, + label: { default: "I agree to the terms" }, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "consent-q1": "invalid-value" }, // Invalid value + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "consent-q1": 3 }, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "consent-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Consent); + expect(summary[0].responseCount).toBe(1); // Counted as response due to ttc + expect(summary[0].accepted.count).toBe(0); // Not accepted + expect(summary[0].dismissed.count).toBe(1); // Counted as dismissed + }); +}); + +describe("Date question type tests", () => { + test("getQuestionSummary correctly processes Date question with valid responses", async () => { + const question = { + id: "date-q1", + type: TSurveyQuestionTypeEnum.Date, + headline: { default: "When is your birthday?" }, + required: true, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "date-q1": "2023-01-15" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { "date-q1": "1990-05-20" }, + updatedAt: new Date(), + contact: { id: "contact-1", userId: "user-1" }, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "date-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Date); + expect(summary[0].responseCount).toBe(2); + expect(summary[0].samples).toHaveLength(2); + + // Check sample values + expect(summary[0].samples[0].value).toBe("2023-01-15"); + expect(summary[0].samples[1].value).toBe("1990-05-20"); + + // Check contact information is preserved + expect(summary[0].samples[1].contact).toEqual({ id: "contact-1", userId: "user-1" }); + }); + + test("getQuestionSummary handles Date question with no responses", async () => { + const question = { + id: "date-q1", + type: TSurveyQuestionTypeEnum.Date, + headline: { default: "When is your birthday?" }, + required: false, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: {}, // No date data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "date-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Date); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].samples).toHaveLength(0); + }); + + test("getQuestionSummary applies VALUES_LIMIT correctly for Date question", async () => { + const question = { + id: "date-q1", + type: TSurveyQuestionTypeEnum.Date, + headline: { default: "When is your birthday?" }, + required: true, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + // Create 100 responses (more than VALUES_LIMIT which is 50) + const responses = Array.from({ length: 100 }, (_, i) => ({ + id: `response-${i}`, + data: { "date-q1": `2023-01-${(i % 28) + 1}` }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + })); + + const dropOff = [ + { questionId: "date-q1", impressions: 100, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Date); + expect(summary[0].responseCount).toBe(100); + expect(summary[0].samples).toHaveLength(50); // Limited to VALUES_LIMIT (50) + }); +}); + +describe("FileUpload question type tests", () => { + test("getQuestionSummary correctly processes FileUpload question with valid responses", async () => { + const question = { + id: "file-q1", + type: TSurveyQuestionTypeEnum.FileUpload, + headline: { default: "Upload your documents" }, + required: true, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { + "file-q1": ["https://example.com/file1.pdf", "https://example.com/file2.jpg"], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: { + "file-q1": ["https://example.com/file3.docx"], + }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "file-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.FileUpload); + expect(summary[0].responseCount).toBe(2); + expect(summary[0].files).toHaveLength(2); + + // Check file values + expect(summary[0].files[0].value).toEqual([ + "https://example.com/file1.pdf", + "https://example.com/file2.jpg", + ]); + expect(summary[0].files[1].value).toEqual(["https://example.com/file3.docx"]); + }); + + test("getQuestionSummary handles FileUpload question with no responses", async () => { + const question = { + id: "file-q1", + type: TSurveyQuestionTypeEnum.FileUpload, + headline: { default: "Upload your documents" }, + required: false, + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: {}, // No file data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "file-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.FileUpload); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].files).toHaveLength(0); + }); +}); + +describe("Cal question type tests", () => { + test("getQuestionSummary correctly processes Cal with valid responses", async () => { + const question = { + id: "cal-q1", + type: TSurveyQuestionTypeEnum.Cal, + headline: { default: "Book a meeting with us" }, + required: true, + calUserName: "test-user", + calEventSlug: "15min", + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "cal-q1": "booked" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + { + id: "response-2", + data: {}, // Skipped but spent time + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "cal-q1": 10 }, + finished: true, + }, + { + id: "response-3", + data: { "cal-q1": "booked" }, + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ] as any; + + const dropOff = [ + { questionId: "cal-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Cal); + expect(summary[0].responseCount).toBe(3); + + // 2 booked / 3 total = 66.67% + expect(summary[0].booked.count).toBe(2); + expect(summary[0].booked.percentage).toBe(66.67); + + // 1 skipped / 3 total = 33.33% + expect(summary[0].skipped.count).toBe(1); + expect(summary[0].skipped.percentage).toBe(33.33); + }); + + test("getQuestionSummary handles Cal with no responses", async () => { + const question = { + id: "cal-q1", + type: TSurveyQuestionTypeEnum.Cal, + headline: { default: "Book a meeting with us" }, + required: false, + calUserName: "test-user", + calEventSlug: "15min", + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "other-q": "value" }, // No Cal data + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: {}, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "cal-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Cal); + expect(summary[0].responseCount).toBe(0); + expect(summary[0].booked.count).toBe(0); + expect(summary[0].booked.percentage).toBe(0); + expect(summary[0].skipped.count).toBe(0); + expect(summary[0].skipped.percentage).toBe(0); + }); + + test("getQuestionSummary handles Cal with invalid values", async () => { + const question = { + id: "cal-q1", + type: TSurveyQuestionTypeEnum.Cal, + headline: { default: "Book a meeting with us" }, + required: true, + calUserName: "test-user", + calEventSlug: "15min", + }; + + const survey = { + id: "survey-1", + questions: [question], + languages: [], + welcomeCard: { enabled: false }, + } as unknown as TSurvey; + + const responses = [ + { + id: "response-1", + data: { "cal-q1": "invalid-value" }, // Invalid value + updatedAt: new Date(), + contact: null, + contactAttributes: {}, + language: null, + ttc: { "cal-q1": 5 }, + finished: true, + }, + ]; + + const dropOff = [ + { questionId: "cal-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 }, + ] as unknown as TSurveySummary["dropOff"]; + + const summary: any = await getQuestionSummary(survey, responses, dropOff); + + expect(summary).toHaveLength(1); + expect(summary[0].type).toBe(TSurveyQuestionTypeEnum.Cal); + expect(summary[0].responseCount).toBe(1); // Counted as response due to ttc + expect(summary[0].booked.count).toBe(0); + expect(summary[0].skipped.count).toBe(1); // Counted as skipped + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts index 81cc7a8e736e..39994bfc7111 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts @@ -1,20 +1,15 @@ import "server-only"; -import { getInsightsBySurveyIdQuestionId } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/insights"; +import { RESPONSES_PER_PAGE } from "@/lib/constants"; +import { getDisplayCountBySurveyId } from "@/lib/display/service"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { buildWhereClause } from "@/lib/response/utils"; +import { getSurvey } from "@/lib/survey/service"; +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 { cache } from "@formbricks/lib/cache"; -import { RESPONSES_PER_PAGE } from "@formbricks/lib/constants"; -import { displayCache } from "@formbricks/lib/display/cache"; -import { getDisplayCountBySurveyId } from "@formbricks/lib/display/service"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { responseCache } from "@formbricks/lib/response/cache"; -import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; -import { buildWhereClause } from "@formbricks/lib/response/utils"; -import { surveyCache } from "@formbricks/lib/survey/cache"; -import { getSurvey } from "@formbricks/lib/survey/service"; -import { evaluateLogic, performActions } from "@formbricks/lib/surveyLogic/utils"; -import { validateInputs } from "@formbricks/lib/utils/validate"; import { ZId, ZOptionalNumber } from "@formbricks/types/common"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { @@ -317,11 +312,9 @@ export const getQuestionSummary = async ( switch (question.type) { case TSurveyQuestionTypeEnum.OpenText: { let values: TSurveyQuestionSummaryOpenText["samples"] = []; - const insightResponsesIds: string[] = []; responses.forEach((response) => { const answer = response.data[question.id]; if (answer && typeof answer === "string") { - insightResponsesIds.push(response.id); values.push({ id: response.id, updatedAt: response.updatedAt, @@ -331,20 +324,12 @@ export const getQuestionSummary = async ( }); } }); - const insights = await getInsightsBySurveyIdQuestionId( - survey.id, - question.id, - insightResponsesIds, - 50 - ); summary.push({ type: question.type, question, responseCount: values.length, samples: values.slice(0, VALUES_LIMIT), - insights, - insightsEnabled: question.insightsEnabled, }); values = []; @@ -420,7 +405,7 @@ export const getQuestionSummary = async ( } }); - Object.entries(choiceCountMap).map(([label, count]) => { + Object.entries(choiceCountMap).forEach(([label, count]) => { values.push({ value: label, count, @@ -519,7 +504,7 @@ export const getQuestionSummary = async ( } }); - Object.entries(choiceCountMap).map(([label, count]) => { + Object.entries(choiceCountMap).forEach(([label, count]) => { values.push({ rating: parseInt(label), count, @@ -916,66 +901,57 @@ export const getQuestionSummary = async ( }; export const getSurveySummary = reactCache( - async (surveyId: string, filterCriteria?: TResponseFilterCriteria): Promise => - cache( - async () => { - validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]); - - try { - const survey = await getSurvey(surveyId); - if (!survey) { - throw new ResourceNotFoundError("Survey", surveyId); - } - - const batchSize = 5000; - const responseCount = await getResponseCountBySurveyId(surveyId, filterCriteria); + async (surveyId: string, filterCriteria?: TResponseFilterCriteria): Promise => { + validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]); - const hasFilter = Object.keys(filterCriteria ?? {}).length > 0; + try { + const survey = await getSurvey(surveyId); + if (!survey) { + throw new ResourceNotFoundError("Survey", surveyId); + } - const pages = Math.ceil(responseCount / batchSize); + const batchSize = 5000; + const hasFilter = Object.keys(filterCriteria ?? {}).length > 0; - // Create an array of batch fetch promises - const batchPromises = Array.from({ length: pages }, (_, i) => - getResponsesForSummary(surveyId, batchSize, i * batchSize, filterCriteria) - ); + // Use cursor-based pagination instead of count + offset to avoid expensive queries + const responses: TSurveySummaryResponse[] = []; + let cursor: string | undefined = undefined; + let hasMore = true; - // Fetch all batches in parallel - const batchResults = await Promise.all(batchPromises); + while (hasMore) { + const batch = await getResponsesForSummary(surveyId, batchSize, 0, filterCriteria, cursor); + responses.push(...batch); - // Combine all batch results - const responses = batchResults.flat(); - - const responseIds = hasFilter ? responses.map((response) => response.id) : []; + 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 displayCount = await getDisplayCountBySurveyId(surveyId, { - createdAt: filterCriteria?.createdAt, - ...(hasFilter && { responseIds }), - }); + const responseIds = hasFilter ? responses.map((response) => response.id) : []; - const dropOff = getSurveySummaryDropOff(survey, responses, displayCount); - const [meta, questionWiseSummary] = await Promise.all([ - getSurveySummaryMeta(responses, displayCount), - getQuestionSummary(survey, responses, dropOff), - ]); + const displayCount = await getDisplayCountBySurveyId(surveyId, { + createdAt: filterCriteria?.createdAt, + ...(hasFilter && { responseIds }), + }); - return { meta, dropOff, summary: questionWiseSummary }; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } + const dropOff = getSurveySummaryDropOff(survey, responses, displayCount); + const [meta, questionWiseSummary] = await Promise.all([ + getSurveySummaryMeta(responses, displayCount), + getQuestionSummary(survey, responses, dropOff), + ]); - throw error; - } - }, - [`getSurveySummary-${surveyId}-${JSON.stringify(filterCriteria)}`], - { - tags: [ - surveyCache.tag.byId(surveyId), - responseCache.tag.bySurveyId(surveyId), - displayCache.tag.bySurveyId(surveyId), - ], + return { meta, dropOff, summary: questionWiseSummary }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )() + + throw error; + } + } ); export const getResponsesForSummary = reactCache( @@ -983,80 +959,87 @@ export const getResponsesForSummary = reactCache( surveyId: string, limit: number, offset: number, - filterCriteria?: TResponseFilterCriteria - ): Promise => - cache( - async () => { - validateInputs( - [surveyId, ZId], - [limit, ZOptionalNumber], - [offset, ZOptionalNumber], - [filterCriteria, ZResponseFilterCriteria.optional()] - ); + filterCriteria?: TResponseFilterCriteria, + cursor?: string + ): Promise => { + validateInputs( + [surveyId, ZId], + [limit, ZOptionalNumber], + [offset, ZOptionalNumber], + [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 queryLimit = limit ?? RESPONSES_PER_PAGE; - const survey = await getSurvey(surveyId); - if (!survey) return []; - try { - const responses = await prisma.response.findMany({ - where: { - surveyId, - ...buildWhereClause(survey, filterCriteria), - }, + const responses = await prisma.response.findMany({ + where: whereClause, + select: { + id: true, + data: true, + updatedAt: true, + contact: { select: { id: true, - data: true, - updatedAt: true, - contact: { - select: { - id: true, - attributes: { - select: { attributeKey: true, value: true }, - }, - }, + attributes: { + select: { attributeKey: true, value: true }, }, - contactAttributes: true, - language: true, - ttc: true, - finished: true, }, - orderBy: [ - { - createdAt: "desc", - }, - ], - take: queryLimit, - skip: offset, - }); - - const transformedResponses: TSurveySummaryResponse[] = await Promise.all( - responses.map((responsePrisma) => { - return { - ...responsePrisma, - contact: responsePrisma.contact - ? { - id: responsePrisma.contact.id as string, - userId: responsePrisma.contact.attributes.find( - (attribute) => attribute.attributeKey.key === "userId" - )?.value as string, - } - : null, - }; - }) - ); - - return transformedResponses; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } + }, + contactAttributes: true, + language: true, + ttc: true, + finished: true, + }, + orderBy: [ + { + createdAt: "desc", + }, + { + id: "desc", // Secondary sort by ID for consistent pagination + }, + ], + take: queryLimit, + skip: offset, + }); + + const transformedResponses: TSurveySummaryResponse[] = await Promise.all( + responses.map((responsePrisma) => { + return { + ...responsePrisma, + contact: responsePrisma.contact + ? { + id: responsePrisma.contact.id as string, + userId: responsePrisma.contact.attributes.find( + (attribute) => attribute.attributeKey.key === "userId" + )?.value as string, + } + : null, + }; + }) + ); - throw error; - } - }, - [`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`], - { - tags: [responseCache.tag.bySurveyId(surveyId)], + return transformedResponses; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )() + + throw error; + } + } ); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils.test.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils.test.ts new file mode 100644 index 000000000000..41cfaa1e9761 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { constructToastMessage, convertFloatTo2Decimal, convertFloatToNDecimal } from "./utils"; + +describe("Utils Tests", () => { + describe("convertFloatToNDecimal", () => { + test("should round to N decimal places", () => { + expect(convertFloatToNDecimal(3.14159, 2)).toBe(3.14); + expect(convertFloatToNDecimal(3.14159, 3)).toBe(3.142); + expect(convertFloatToNDecimal(3.1, 2)).toBe(3.1); + expect(convertFloatToNDecimal(3, 2)).toBe(3); + expect(convertFloatToNDecimal(0.129, 2)).toBe(0.13); + }); + + test("should default to 2 decimal places if N is not provided", () => { + expect(convertFloatToNDecimal(3.14159)).toBe(3.14); + }); + }); + + describe("convertFloatTo2Decimal", () => { + test("should round to 2 decimal places", () => { + expect(convertFloatTo2Decimal(3.14159)).toBe(3.14); + expect(convertFloatTo2Decimal(3.1)).toBe(3.1); + expect(convertFloatTo2Decimal(3)).toBe(3); + expect(convertFloatTo2Decimal(0.129)).toBe(0.13); + }); + }); + + describe("constructToastMessage", () => { + const mockT = vi.fn((key, params) => `${key} ${JSON.stringify(params)}`) as any; + const mockSurvey = { + id: "survey1", + name: "Test Survey", + type: "app", + environmentId: "env1", + status: "draft", + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Q1" }, + required: false, + } as unknown as TSurveyQuestion, + { + id: "q2", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Q2" }, + required: false, + choices: [{ id: "c1", label: { default: "Choice 1" } }], + }, + { + id: "q3", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Q3" }, + required: false, + rows: [{ id: "r1", label: { default: "Row 1" } }], + columns: [{ id: "col1", label: { default: "Col 1" } }], + }, + ], + triggers: [], + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + autoComplete: null, + singleUse: null, + styling: null, + surveyClosedMessage: null, + displayOption: "displayOnce", + welcomeCard: { enabled: false } as TSurvey["welcomeCard"], + createdAt: new Date(), + updatedAt: new Date(), + languages: [], + } as unknown as TSurvey; + + test("should construct message for matrix question type", () => { + const message = constructToastMessage( + TSurveyQuestionTypeEnum.Matrix, + "is", + mockSurvey, + "q3", + mockT, + "MatrixValue" + ); + expect(mockT).toHaveBeenCalledWith( + "environments.surveys.summary.added_filter_for_responses_where_answer_to_question", + { + questionIdx: 3, + filterComboBoxValue: "MatrixValue", + filterValue: "is", + } + ); + expect(message).toBe( + 'environments.surveys.summary.added_filter_for_responses_where_answer_to_question {"questionIdx":3,"filterComboBoxValue":"MatrixValue","filterValue":"is"}' + ); + }); + + test("should construct message for matrix question type with array filterComboBoxValue", () => { + const message = constructToastMessage(TSurveyQuestionTypeEnum.Matrix, "is", mockSurvey, "q3", mockT, [ + "MatrixValue1", + "MatrixValue2", + ]); + expect(mockT).toHaveBeenCalledWith( + "environments.surveys.summary.added_filter_for_responses_where_answer_to_question", + { + questionIdx: 3, + filterComboBoxValue: "MatrixValue1,MatrixValue2", + filterValue: "is", + } + ); + expect(message).toBe( + 'environments.surveys.summary.added_filter_for_responses_where_answer_to_question {"questionIdx":3,"filterComboBoxValue":"MatrixValue1,MatrixValue2","filterValue":"is"}' + ); + }); + + test("should construct message when filterComboBoxValue is undefined (skipped)", () => { + const message = constructToastMessage( + TSurveyQuestionTypeEnum.OpenText, + "is skipped", + mockSurvey, + "q1", + mockT, + undefined + ); + expect(mockT).toHaveBeenCalledWith( + "environments.surveys.summary.added_filter_for_responses_where_answer_to_question_is_skipped", + { + questionIdx: 1, + } + ); + expect(message).toBe( + 'environments.surveys.summary.added_filter_for_responses_where_answer_to_question_is_skipped {"questionIdx":1}' + ); + }); + + test("should construct message for non-matrix question with string filterComboBoxValue", () => { + const message = constructToastMessage( + TSurveyQuestionTypeEnum.MultipleChoiceSingle, + "is", + mockSurvey, + "q2", + mockT, + "Choice1" + ); + expect(mockT).toHaveBeenCalledWith( + "environments.surveys.summary.added_filter_for_responses_where_answer_to_question", + { + questionIdx: 2, + filterComboBoxValue: "Choice1", + filterValue: "is", + } + ); + expect(message).toBe( + 'environments.surveys.summary.added_filter_for_responses_where_answer_to_question {"questionIdx":2,"filterComboBoxValue":"Choice1","filterValue":"is"}' + ); + }); + + test("should construct message for non-matrix question with array filterComboBoxValue", () => { + const message = constructToastMessage( + TSurveyQuestionTypeEnum.MultipleChoiceMulti, + "includes all of", + mockSurvey, + "q2", // Assuming q2 can be multi for this test case logic + mockT, + ["Choice1", "Choice2"] + ); + expect(mockT).toHaveBeenCalledWith( + "environments.surveys.summary.added_filter_for_responses_where_answer_to_question", + { + questionIdx: 2, + filterComboBoxValue: "Choice1,Choice2", + filterValue: "includes all of", + } + ); + expect(message).toBe( + 'environments.surveys.summary.added_filter_for_responses_where_answer_to_question {"questionIdx":2,"filterComboBoxValue":"Choice1,Choice2","filterValue":"includes all of"}' + ); + }); + + test("should handle questionId not found in survey", () => { + const message = constructToastMessage( + TSurveyQuestionTypeEnum.OpenText, + "is", + mockSurvey, + "qNonExistent", + mockT, + "SomeValue" + ); + // findIndex returns -1, so questionIdx becomes -1 + 1 = 0 + expect(mockT).toHaveBeenCalledWith( + "environments.surveys.summary.added_filter_for_responses_where_answer_to_question", + { + questionIdx: 0, + filterComboBoxValue: "SomeValue", + filterValue: "is", + } + ); + expect(message).toBe( + 'environments.surveys.summary.added_filter_for_responses_where_answer_to_question {"questionIdx":0,"filterComboBoxValue":"SomeValue","filterValue":"is"}' + ); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils.ts index e431076e087e..1b44423e90d4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils.ts @@ -38,12 +38,3 @@ export const constructToastMessage = ( }); } }; - -export const needsInsightsGeneration = (survey: TSurvey): boolean => { - const openTextQuestions = survey.questions.filter((question) => question.type === "openText"); - const questionWithoutInsightsEnabled = openTextQuestions.some( - (question) => question.type === "openText" && typeof question.insightsEnabled === "undefined" - ); - - return openTextQuestions.length > 0 && questionWithoutInsightsEnabled; -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/loading.test.tsx new file mode 100644 index 000000000000..d657b0fb3783 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/loading.test.tsx @@ -0,0 +1,39 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import Loading from "./loading"; + +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: ({ children }) =>
{children}
, +})); + +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: ({ pageTitle }) =>

{pageTitle}

, +})); + +vi.mock("@/modules/ui/components/skeleton-loader", () => ({ + SkeletonLoader: ({ type }) =>
{`Skeleton type: ${type}`}
, +})); + +describe("Loading Component", () => { + afterEach(() => { + cleanup(); + }); + + test("should render the loading state correctly", () => { + render(); + + expect(screen.getByText("common.summary")).toBeInTheDocument(); + expect(screen.getByTestId("skeleton-loader")).toHaveTextContent("Skeleton type: summary"); + + const pulseDivs = screen.getAllByRole("generic", { hidden: true }); // Using generic role as divs don't have implicit roles + // Filter divs that are part of the pulse animation + const animatedDivs = pulseDivs.filter( + (div) => + div.classList.contains("h-9") && + div.classList.contains("w-36") && + div.classList.contains("rounded-full") && + div.classList.contains("bg-slate-200") + ); + expect(animatedDivs.length).toBe(4); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.test.tsx new file mode 100644 index 000000000000..84a5470c704b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.test.tsx @@ -0,0 +1,288 @@ +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 } from "@/lib/constants"; +import { getPublicDomain } from "@/lib/getPublicUrl"; +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"; +import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth"; +import { cleanup, render, screen } from "@testing-library/react"; +import { notFound } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUser } from "@formbricks/types/user"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", + RESPONSES_PER_PAGE: 10, + SESSION_MAX_AGE: 1000, +})); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation", + () => ({ + SurveyAnalysisNavigation: vi.fn(() =>
), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage", + () => ({ + SummaryPage: vi.fn(() =>
), + }) +); + +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA", + () => ({ + SurveyAnalysisCTA: vi.fn(() =>
), + }) +); + +vi.mock("@/lib/getPublicUrl", () => ({ + getPublicDomain: vi.fn(), +})); + +vi.mock("@/lib/response/service", () => ({ + getResponseCountBySurveyId: vi.fn(), +})); + +vi.mock("@/lib/survey/service", () => ({ + getSurvey: vi.fn(), +})); + +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(), +})); + +vi.mock("@/modules/ui/components/page-content-wrapper", () => ({ + PageContentWrapper: vi.fn(({ children }) =>
{children}
), +})); + +vi.mock("@/modules/ui/components/page-header", () => ({ + PageHeader: vi.fn(({ children }) =>
{children}
), +})); + +vi.mock("@/modules/ui/components/id-badge", () => ({ + IdBadge: vi.fn(() =>
), +})); + +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); + +vi.mock("next/navigation", () => ({ + notFound: vi.fn(), + useParams: () => ({ + environmentId: "test-environment-id", + surveyId: "test-survey-id", + }), +})); + +const mockEnvironmentId = "test-environment-id"; +const mockSurveyId = "test-survey-id"; +const mockUserId = "test-user-id"; + +const mockEnvironment = { + id: mockEnvironmentId, + createdAt: new Date(), + updatedAt: new Date(), + type: "development", + appSetupCompleted: false, +} as unknown as TEnvironment; + +const mockSurvey = { + id: mockSurveyId, + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + type: "app", + environmentId: mockEnvironmentId, + status: "draft", + questions: [], + displayOption: "displayOnce", + autoClose: null, + triggers: [], + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + autoComplete: null, + closeOnDate: null, + delay: 0, + displayPercentage: null, + languages: [], + runOnDate: null, + singleUse: null, + surveyClosedMessage: null, + segment: null, + styling: null, + variables: [], + hiddenFields: { enabled: true, fieldIds: [] }, +} as unknown as TSurvey; + +const mockUser = { + id: mockUserId, + name: "Test User", + email: "test@example.com", + emailVerified: new Date(), + imageUrl: "", + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + onboardingCompleted: true, + role: "project_manager", + locale: "en-US", + objective: "other", +} as unknown as TUser; + +const mockSession = { + user: { + id: mockUserId, + name: mockUser.name, + email: mockUser.email, + image: mockUser.imageUrl, + role: mockUser.role, + plan: "free", + status: "active", + objective: "other", + }, + 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({ + session: mockSession, + environment: mockEnvironment, + isReadOnly: false, + } as unknown as TEnvironmentAuth); + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10); + vi.mocked(getPublicDomain).mockReturnValue("http://localhost:3000"); + vi.mocked(getSurveySummary).mockResolvedValue(mockSurveySummary); + vi.mocked(notFound).mockClear(); + }); + + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + test("renders correctly with valid data", async () => { + const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId }); + const jsx = await SurveyPage({ params }); + render({jsx}); + + expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument(); + expect(screen.getByTestId("page-header")).toBeInTheDocument(); + expect(screen.getByTestId("survey-analysis-navigation")).toBeInTheDocument(); + expect(screen.getByTestId("summary-page")).toBeInTheDocument(); + expect(screen.getByTestId("id-badge")).toBeInTheDocument(); + + expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId); + expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId); + expect(vi.mocked(getUser)).toHaveBeenCalledWith(mockUserId); + expect(vi.mocked(getPublicDomain)).toHaveBeenCalled(); + + expect(vi.mocked(SurveyAnalysisNavigation).mock.calls[0][0]).toEqual( + expect.objectContaining({ + environmentId: mockEnvironmentId, + survey: mockSurvey, + activeId: "summary", + }) + ); + + expect(vi.mocked(SummaryPage).mock.calls[0][0]).toEqual( + expect.objectContaining({ + environment: mockEnvironment, + survey: mockSurvey, + surveyId: mockSurveyId, + 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; + const jsx = await SurveyPage({ params }); + render({jsx}); + expect(vi.mocked(notFound)).toHaveBeenCalled(); + }); + + test("throws error if survey is not found", async () => { + vi.mocked(getSurvey).mockResolvedValue(null); + const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId }); + try { + // We need to await the component itself because it's an async component + const SurveyPageComponent = await SurveyPage({ params }); + render({SurveyPageComponent}); + } catch (e: any) { + expect(e.message).toBe("common.survey_not_found"); + } + // Ensure notFound was not called for this specific error + expect(vi.mocked(notFound)).not.toHaveBeenCalled(); + }); + + test("throws error if user is not found", async () => { + vi.mocked(getUser).mockResolvedValue(null); + const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId }); + try { + const SurveyPageComponent = await SurveyPage({ params }); + render({SurveyPageComponent}); + } catch (e: any) { + expect(e.message).toBe("common.user_not_found"); + } + expect(vi.mocked(notFound)).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx index d098eceabf71..ce2fc14e95e3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx @@ -1,31 +1,25 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation"; -import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner"; 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 { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils"; -import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils"; +import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary"; +import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { getPublicDomain } from "@/lib/getPublicUrl"; +import { getSurvey } from "@/lib/survey/service"; +import { getUser } from "@/lib/user/service"; +import { getSegments } from "@/modules/ee/contacts/segments/lib/segments"; +import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; -import { SettingsId } from "@/modules/ui/components/settings-id"; import { getTranslate } from "@/tolgee/server"; import { notFound } from "next/navigation"; -import { - DEFAULT_LOCALE, - DOCUMENTS_PER_PAGE, - MAX_RESPONSES_FOR_INSIGHT_GENERATION, - WEBAPP_URL, -} from "@formbricks/lib/constants"; -import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl"; -import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; -import { getSurvey } from "@formbricks/lib/survey/service"; -import { getUser } from "@formbricks/lib/user/service"; const SurveyPage = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => { const params = await props.params; const t = await getTranslate(); - const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId); + const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId); const surveyId = params.surveyId; @@ -44,18 +38,13 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv if (!user) { throw new Error(t("common.user_not_found")); } + const isContactsEnabled = await getIsContactsEnabled(); + const segments = isContactsEnabled ? await getSegments(environment.id) : []; - const totalResponseCount = await getResponseCountBySurveyId(params.surveyId); + // Fetch initial survey summary data on the server to prevent duplicate API calls during hydration + const initialSurveySummary = await getSurveySummary(surveyId); - // I took this out cause it's cloud only right? - // const { active: isEnterpriseEdition } = await getEnterpriseLicense(); - - const isAIEnabled = await getIsAIEnabled({ - isAIEnabled: organization.isAIEnabled, - billing: organization.billing, - }); - const shouldGenerateInsights = needsInsightsGeneration(survey); - const surveyDomain = getSurveyDomain(); + const publicDomain = getPublicDomain(); return ( @@ -67,37 +56,25 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv survey={survey} isReadOnly={isReadOnly} user={user} - surveyDomain={surveyDomain} + publicDomain={publicDomain} + responseCount={initialSurveySummary?.meta.totalResponses ?? 0} + displayCount={initialSurveySummary?.meta.displayCount ?? 0} + segments={segments} + isContactsEnabled={isContactsEnabled} + isFormbricksCloud={IS_FORMBRICKS_CLOUD} /> }> - {isAIEnabled && shouldGenerateInsights && ( - - )} - + - + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share.ts new file mode 100644 index 000000000000..94bccb0b7db5 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share.ts @@ -0,0 +1,11 @@ +export enum ShareViewType { + ANON_LINKS = "anon-links", + PERSONAL_LINKS = "personal-links", + EMAIL = "email", + WEBPAGE = "webpage", + APP = "app", + WEBSITE_EMBED = "website-embed", + DYNAMIC_POPUP = "dynamic-popup", + SOCIAL_MEDIA = "social-media", + QR_CODE = "qr-code", +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts index 4e1336da47ec..19835b4ebfd8 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions.ts @@ -1,19 +1,22 @@ "use server"; +import { getOrganization } from "@/lib/organization/service"; +import { getResponseDownloadUrl, getResponseFilteringValues } from "@/lib/response/service"; +import { getSurvey, updateSurvey } from "@/lib/survey/service"; +import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions"; import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; +import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission"; import { z } from "zod"; -import { getOrganization } from "@formbricks/lib/organization/service"; -import { getResponseDownloadUrl, getResponseFilteringValues } from "@formbricks/lib/response/service"; -import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; -import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; import { ZId } from "@formbricks/types/common"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { ZResponseFilterCriteria } from "@formbricks/types/responses"; -import { ZSurvey } from "@formbricks/types/surveys/types"; +import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types"; const ZGetResponsesDownloadUrlAction = z.object({ surveyId: ZId, @@ -95,41 +98,60 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise { - const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "projectTeam", - projectId: await getProjectIdFromSurveyId(parsedInput.id), - minPermission: "readWrite", - }, - ], - }); - - const { followUps } = parsedInput; - - if (followUps?.length) { - await checkSurveyFollowUpsPermission(organizationId); +export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action( + withAuditLogging( + "updated", + "survey", + async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurvey }) => { + const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id); + await checkAuthorizationUpdated({ + userId: ctx.user?.id ?? "", + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + projectId: await getProjectIdFromSurveyId(parsedInput.id), + minPermission: "readWrite", + }, + ], + }); + + const { followUps } = parsedInput; + + const oldSurvey = await getSurvey(parsedInput.id); + + if (parsedInput.recaptcha?.enabled) { + await checkSpamProtectionPermission(organizationId); + } + + if (followUps?.length) { + await checkSurveyFollowUpsPermission(organizationId); + } + + if (parsedInput.languages?.length) { + await checkMultiLanguagePermission(organizationId); + } + + // Context for audit log + ctx.auditLoggingCtx.surveyId = parsedInput.id; + ctx.auditLoggingCtx.organizationId = organizationId; + ctx.auditLoggingCtx.oldObject = oldSurvey; + + const newSurvey = await updateSurvey(parsedInput); + + ctx.auditLoggingCtx.newObject = newSurvey; + + return newSurvey; } - - if (parsedInput.languages?.length) { - await checkMultiLanguagePermission(organizationId); - } - - return await updateSurvey(parsedInput); - }); + ) +); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.test.tsx new file mode 100644 index 000000000000..dd34ce26aaa2 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.test.tsx @@ -0,0 +1,257 @@ +import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; +import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { useClickOutside } from "@/lib/utils/hooks/useClickOutside"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { format } from "date-fns"; +import { useParams } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { CustomFilter } from "./CustomFilter"; + +vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({ + useResponseFilter: vi.fn(), +})); + +vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({ + getResponsesDownloadUrlAction: vi.fn(), +})); + +vi.mock("@/app/lib/surveys/surveys", async (importOriginal) => { + const actual = (await importOriginal()) as any; + return { + ...actual, + getFormattedFilters: vi.fn(), + getTodayDate: vi.fn(), + }; +}); + +vi.mock("@/lib/utils/helper", () => ({ + getFormattedErrorMessage: vi.fn(), +})); + +vi.mock("@/lib/utils/hooks/useClickOutside", () => ({ + useClickOutside: vi.fn(), +})); + +vi.mock("@/modules/ui/components/calendar", () => ({ + Calendar: vi.fn( + ({ + onDayClick, + onDayMouseEnter, + onDayMouseLeave, + selected, + defaultMonth, + mode, + numberOfMonths, + classNames, + autoFocus, + }) => ( +
+ Calendar Mock + +
onDayMouseEnter?.(new Date("2024-01-10"))}> + Hover Day +
+
onDayMouseLeave?.()}> + Leave Day +
+
+ Selected: {selected?.from?.toISOString()} - {selected?.to?.toISOString()} +
+
Default Month: {defaultMonth?.toISOString()}
+
Mode: {mode}
+
Number of Months: {numberOfMonths}
+
ClassNames: {JSON.stringify(classNames)}
+
AutoFocus: {String(autoFocus)}
+
+ ) + ), +})); + +vi.mock("next/navigation", () => ({ + useParams: vi.fn(), +})); + +vi.mock("./ResponseFilter", () => ({ + ResponseFilter: vi.fn(() =>
ResponseFilter Mock
), +})); + +const mockSurvey = { + id: "survey-1", + name: "Test Survey", + questions: [], + createdAt: new Date(), + updatedAt: new Date(), + type: "app", + environmentId: "env-1", + status: "inProgress", + displayOption: "displayOnce", + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + autoComplete: null, + surveyClosedMessage: null, + singleUse: null, + displayPercentage: null, + languages: [], + triggers: [], + welcomeCard: { enabled: false } as TSurvey["welcomeCard"], +} as unknown as TSurvey; + +const mockDateToday = new Date("2023-11-20T00:00:00.000Z"); + +const initialMockUseResponseFilterState = () => ({ + selectedFilter: {}, + dateRange: { from: undefined, to: mockDateToday }, + setDateRange: vi.fn(), + resetState: vi.fn(), +}); + +let mockUseResponseFilterState = initialMockUseResponseFilterState(); + +describe("CustomFilter", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseResponseFilterState = initialMockUseResponseFilterState(); // Reset state for each test + + vi.mocked(useResponseFilter).mockImplementation(() => mockUseResponseFilterState as any); + vi.mocked(useParams).mockReturnValue({ environmentId: "test-env", surveyId: "test-survey" }); + vi.mocked(getFormattedFilters).mockReturnValue({}); + vi.mocked(getTodayDate).mockReturnValue(mockDateToday); + vi.mocked(getResponsesDownloadUrlAction).mockResolvedValue({ data: "mock-download-url" }); + vi.mocked(getFormattedErrorMessage).mockReturnValue("Mock error message"); + }); + + test("renders correctly with initial props", () => { + render(); + expect(screen.getByTestId("response-filter-mock")).toBeInTheDocument(); + expect(screen.getByText("environments.surveys.summary.all_time")).toBeInTheDocument(); + expect(screen.getByText("common.download")).toBeInTheDocument(); + }); + + test("opens custom date picker when 'Custom range' is clicked", async () => { + const user = userEvent.setup(); + render(); + const dropdownTrigger = screen.getByText("environments.surveys.summary.all_time").closest("button")!; + // Similar to above, assuming direct clickability. + await user.click(dropdownTrigger); + const customRangeOption = screen.getByText("environments.surveys.summary.custom_range"); + await user.click(customRangeOption); + + expect(screen.getByTestId("calendar-mock")).toBeVisible(); + expect(screen.getByText(`Select first date - ${format(mockDateToday, "dd LLL")}`)).toBeInTheDocument(); + }); + + test("useEffect logic for resetState and firstMountRef (as per current component code)", () => { + // This test verifies the current behavior of the useEffects related to firstMountRef. + // Based on the component's code, resetState() is not expected to be called by these effects, + // and firstMountRef.current is not changed by the first useEffect. + const { rerender } = render(); + expect(mockUseResponseFilterState.resetState).not.toHaveBeenCalled(); + + const newSurvey = { ...mockSurvey, id: "survey-2" }; + rerender(); + expect(mockUseResponseFilterState.resetState).not.toHaveBeenCalled(); + }); + + test("closes date picker when clicking outside", async () => { + const user = userEvent.setup(); + let clickOutsideCallback: Function = () => {}; + vi.mocked(useClickOutside).mockImplementation((_, callback) => { + clickOutsideCallback = callback; + }); + + render(); + const dropdownTrigger = screen.getByText("environments.surveys.summary.all_time").closest("button")!; // Ensure targeting button + await user.click(dropdownTrigger); + const customRangeOption = screen.getByText("environments.surveys.summary.custom_range"); + await user.click(customRangeOption); + expect(screen.getByTestId("calendar-mock")).toBeVisible(); + + clickOutsideCallback(); // Simulate click outside + + await waitFor(() => { + expect(screen.queryByTestId("calendar-mock")).not.toBeInTheDocument(); + }); + }); + + test("downloading all and filtered responses in csv and xlsx formats", async () => { + const user = userEvent.setup(); + + render(); + + // Mock the action to return undefined data to avoid DOM manipulation + vi.mocked(getResponsesDownloadUrlAction).mockResolvedValue({ + data: undefined, + }); + + // Test CSV download + const downloadButton = screen.getByTestId("fb__custom-filter-download-responses-button"); + await user.click(downloadButton); + const downloadAllCsv = screen.getByTestId("fb__custom-filter-download-all-csv"); + await user.click(downloadAllCsv); + + await waitFor(() => { + expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({ + surveyId: "survey-1", + format: "csv", + filterCriteria: {}, + }); + }); + + // Test XLSX download + await user.click(downloadButton); + const downloadAllXlsx = screen.getByTestId("fb__custom-filter-download-all-xlsx"); + await user.click(downloadAllXlsx); + + await waitFor(() => { + expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({ + surveyId: "survey-1", + format: "xlsx", + filterCriteria: {}, + }); + }); + + // Test filtered CSV download + await user.click(downloadButton); + const downloadFilteredCsv = screen.getByTestId("fb__custom-filter-download-filtered-csv"); + await user.click(downloadFilteredCsv); + + await waitFor(() => { + expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({ + surveyId: "survey-1", + format: "csv", + filterCriteria: {}, + }); + }); + + // Test filtered XLSX download + await user.click(downloadButton); + const downloadFilteredXlsx = screen.getByTestId("fb__custom-filter-download-filtered-xlsx"); + await user.click(downloadFilteredXlsx); + + await waitFor(() => { + expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({ + surveyId: "survey-1", + format: "xlsx", + filterCriteria: {}, + }); + }); + + vi.restoreAllMocks(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx index ef7f8871519e..9d8acc8c7470 100755 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx @@ -7,6 +7,7 @@ import { import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { useClickOutside } from "@/lib/utils/hooks/useClickOutside"; import { Calendar } from "@/modules/ui/components/calendar"; import { DropdownMenu, @@ -14,6 +15,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/modules/ui/components/dropdown-menu"; +import { cn } from "@/modules/ui/lib/utils"; import { TFnType, useTranslate } from "@tolgee/react"; import { differenceInDays, @@ -30,11 +32,9 @@ import { subQuarters, subYears, } from "date-fns"; -import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon } from "lucide-react"; -import { useParams } from "next/navigation"; +import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon, Loader2Icon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import toast from "react-hot-toast"; -import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside"; import { TSurvey } from "@formbricks/types/surveys/types"; import { ResponseFilter } from "./ResponseFilter"; @@ -125,8 +125,6 @@ const getDateRangeLabel = (from: Date, to: Date, t: TFnType) => { }; export const CustomFilter = ({ survey }: CustomFilterProps) => { - const params = useParams(); - const isSharingPage = !!params.sharingKey; const { t } = useTranslate(); const { selectedFilter, dateRange, setDateRange, resetState } = useResponseFilter(); const [filterRange, setFilterRange] = useState( @@ -138,6 +136,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => { const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState(false); const [hoveredRange, setHoveredRange] = useState(null); + const [isDownloading, setIsDownloading] = useState(false); const firstMountRef = useRef(true); @@ -239,27 +238,29 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => { setSelectingDate(DateSelected.FROM); }; - const handleDowndloadResponses = async (filter: FilterDownload, filetype: "csv" | "xlsx") => { - try { - const responseFilters = filter === FilterDownload.ALL ? {} : filters; - const responsesDownloadUrlResponse = await getResponsesDownloadUrlAction({ - surveyId: survey.id, - format: filetype, - filterCriteria: responseFilters, - }); - if (responsesDownloadUrlResponse?.data) { - const link = document.createElement("a"); - link.href = responsesDownloadUrlResponse.data; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } else { - const errorMessage = getFormattedErrorMessage(responsesDownloadUrlResponse); - toast.error(errorMessage); - } - } catch (error) { - toast.error("Error downloading responses"); + const handleDownloadResponses = async (filter: FilterDownload, filetype: "csv" | "xlsx") => { + const responseFilters = filter === FilterDownload.ALL ? {} : filters; + setIsDownloading(true); + + const responsesDownloadUrlResponse = await getResponsesDownloadUrlAction({ + surveyId: survey.id, + format: filetype, + filterCriteria: responseFilters, + }); + + if (responsesDownloadUrlResponse?.data) { + const link = document.createElement("a"); + link.href = responsesDownloadUrlResponse.data; + link.download = ""; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } else { + const errorMessage = getFormattedErrorMessage(responsesDownloadUrlResponse); + toast.error(errorMessage); } + + setIsDownloading(false); }; useClickOutside(datePickerRef, () => handleDatePickerClose()); @@ -280,7 +281,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => { ? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${ dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date" }` - : t(filterRange)} + : filterRange} {isFilterDropDownOpen ? ( @@ -295,28 +296,28 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => { setFilterRange(getFilterDropDownLabels(t).ALL_TIME); setDateRange({ from: undefined, to: getTodayDate() }); }}> -

{t(getFilterDropDownLabels(t).ALL_TIME)}

+

{getFilterDropDownLabels(t).ALL_TIME}

{ setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS); setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() }); }}> -

{t(getFilterDropDownLabels(t).LAST_7_DAYS)}

+

{getFilterDropDownLabels(t).LAST_7_DAYS}

{ setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS); setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() }); }}> -

{t(getFilterDropDownLabels(t).LAST_30_DAYS)}

+

{getFilterDropDownLabels(t).LAST_30_DAYS}

{ setFilterRange(getFilterDropDownLabels(t).THIS_MONTH); setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() }); }}> -

{t(getFilterDropDownLabels(t).THIS_MONTH)}

+

{getFilterDropDownLabels(t).THIS_MONTH}

{ @@ -326,14 +327,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => { to: endOfMonth(subMonths(getTodayDate(), 1)), }); }}> -

{t(getFilterDropDownLabels(t).LAST_MONTH)}

+

{getFilterDropDownLabels(t).LAST_MONTH}

{ setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER); setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) }); }}> -

{t(getFilterDropDownLabels(t).THIS_QUARTER)}

+

{getFilterDropDownLabels(t).THIS_QUARTER}

{ @@ -343,7 +344,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => { to: endOfQuarter(subQuarters(getTodayDate(), 1)), }); }}> -

{t(getFilterDropDownLabels(t).LAST_QUARTER)}

+

{getFilterDropDownLabels(t).LAST_QUARTER}

{ @@ -353,14 +354,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => { to: endOfMonth(getTodayDate()), }); }}> -

{t(getFilterDropDownLabels(t).LAST_6_MONTHS)}

+

{getFilterDropDownLabels(t).LAST_6_MONTHS}

{ setFilterRange(getFilterDropDownLabels(t).THIS_YEAR); setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) }); }}> -

{t(getFilterDropDownLabels(t).THIS_YEAR)}

+

{getFilterDropDownLabels(t).THIS_YEAR}

{ @@ -370,7 +371,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => { to: endOfYear(subYears(getTodayDate(), 1)), }); }}> -

{t(getFilterDropDownLabels(t).LAST_YEAR)}

+

{getFilterDropDownLabels(t).LAST_YEAR}

{ @@ -379,56 +380,67 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => { setSelectingDate(DateSelected.FROM); }}>

- {t(getFilterDropDownLabels(t).CUSTOM_RANGE)} + {getFilterDropDownLabels(t).CUSTOM_RANGE}

- {!isSharingPage && ( - { - value && handleDatePickerClose(); - }}> - -
-
- {t("common.download")} + { + value && handleDatePickerClose(); + }}> + +
+
+ {t("common.download")} + {isDownloading ? ( + + ) : ( -
- + )}
-
+ +
+ - - { - handleDowndloadResponses(FilterDownload.ALL, "csv"); - }}> -

{t("environments.surveys.summary.all_responses_csv")}

-
- { - handleDowndloadResponses(FilterDownload.ALL, "xlsx"); - }}> -

{t("environments.surveys.summary.all_responses_excel")}

-
- { - handleDowndloadResponses(FilterDownload.FILTER, "csv"); - }}> -

{t("environments.surveys.summary.current_selection_csv")}

-
- { - handleDowndloadResponses(FilterDownload.FILTER, "xlsx"); - }}> -

- {t("environments.surveys.summary.current_selection_excel")} -

-
-
- - )} + + { + await handleDownloadResponses(FilterDownload.ALL, "csv"); + }}> +

{t("environments.surveys.summary.all_responses_csv")}

+
+ { + await handleDownloadResponses(FilterDownload.ALL, "xlsx"); + }}> +

{t("environments.surveys.summary.all_responses_excel")}

+
+ { + await handleDownloadResponses(FilterDownload.FILTER, "csv"); + }}> +

{t("environments.surveys.summary.filtered_responses_csv")}

+
+ { + await handleDownloadResponses(FilterDownload.FILTER, "xlsx"); + }}> +

{t("environments.surveys.summary.filtered_responses_excel")}

+
+
+
{isDatePickerOpen && (
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.test.tsx new file mode 100644 index 000000000000..c91591c2412c --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.test.tsx @@ -0,0 +1,92 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { QuestionFilterComboBox } from "./QuestionFilterComboBox"; + +describe("QuestionFilterComboBox", () => { + afterEach(() => { + cleanup(); + }); + + const defaultProps = { + filterOptions: ["A", "B"], + filterComboBoxOptions: ["X", "Y"], + filterValue: undefined, + filterComboBoxValue: undefined, + onChangeFilterValue: vi.fn(), + onChangeFilterComboBoxValue: vi.fn(), + handleRemoveMultiSelect: vi.fn(), + disabled: false, + }; + + test("renders select placeholders", () => { + render(); + expect(screen.getAllByText("common.select...").length).toBe(2); + }); + + test("calls onChangeFilterValue when selecting filter", async () => { + render(); + await userEvent.click(screen.getAllByRole("button")[0]); + await userEvent.click(screen.getByText("A")); + expect(defaultProps.onChangeFilterValue).toHaveBeenCalledWith("A"); + }); + + test("calls onChangeFilterComboBoxValue when selecting combo box option", async () => { + render(); + await userEvent.click(screen.getAllByRole("button")[1]); + await userEvent.click(screen.getByText("X")); + expect(defaultProps.onChangeFilterComboBoxValue).toHaveBeenCalledWith("X"); + }); + + test("multi-select removal works", async () => { + const props = { + ...defaultProps, + type: "multipleChoiceMulti", + filterValue: "A", + filterComboBoxValue: ["X", "Y"], + }; + render(); + const removeButtons = screen.getAllByRole("button", { name: /X/i }); + await userEvent.click(removeButtons[0]); + expect(props.handleRemoveMultiSelect).toHaveBeenCalledWith(["Y"]); + }); + + test("disabled state prevents opening", async () => { + render(); + await userEvent.click(screen.getAllByRole("button")[0]); + expect(screen.queryByText("A")).toBeNull(); + }); + + test("handles object options correctly", async () => { + const obj = { default: "Obj1", en: "ObjEN" }; + const props = { + ...defaultProps, + type: "multipleChoiceMulti", + filterValue: "A", + filterComboBoxOptions: [obj], + filterComboBoxValue: [], + } as any; + render(); + await userEvent.click(screen.getAllByRole("button")[1]); + await userEvent.click(screen.getByText("Obj1")); + expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith(["Obj1"]); + }); + + test("combobox is disabled when filterValue is 'Submitted' for NPS questions", async () => { + const props = { ...defaultProps, type: "nps", filterValue: "Submitted" } as any; + render(); + const comboBoxOpenerButton = screen.getAllByRole("button")[1]; + expect(comboBoxOpenerButton).toBeDisabled(); + await userEvent.click(comboBoxOpenerButton); + expect(screen.queryByText("X")).not.toBeInTheDocument(); + }); + + test("combobox is disabled when filterValue is 'Skipped' for rating questions", async () => { + const props = { ...defaultProps, type: "rating", filterValue: "Skipped" } as any; + render(); + const comboBoxOpenerButton = screen.getAllByRole("button")[1]; + expect(comboBoxOpenerButton).toBeDisabled(); + await userEvent.click(comboBoxOpenerButton); + expect(screen.queryByText("X")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx index c9879e634424..44ee4537c419 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx @@ -1,6 +1,8 @@ "use client"; import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { useClickOutside } from "@/lib/utils/hooks/useClickOutside"; import { Command, CommandEmpty, @@ -19,8 +21,6 @@ import { useTranslate } from "@tolgee/react"; import clsx from "clsx"; import { ChevronDown, ChevronUp, X } from "lucide-react"; import * as React from "react"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside"; import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; type QuestionFilterComboBoxProps = { @@ -81,6 +81,39 @@ export const QuestionFilterComboBox = ({ .includes(searchQuery.toLowerCase()) ); + const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? ( +

{filterComboBoxValue}

+ ) : ( +
+ {typeof filterComboBoxValue !== "string" && + filterComboBoxValue?.map((o, index) => ( + + ))} +
+ ); + + const commandItemOnSelect = (o: string) => { + if (!isMultiple) { + onChangeFilterComboBoxValue(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o); + } else { + onChangeFilterComboBoxValue( + Array.isArray(filterComboBoxValue) + ? [...filterComboBoxValue, typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o] + : [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o] + ); + } + if (!isMultiple) { + setOpen(false); + } + }; + return (
{filterOptions && filterOptions?.length <= 1 ? ( @@ -130,43 +163,41 @@ export const QuestionFilterComboBox = ({ )}
!disabled && !isDisabledComboBox && filterValue && setOpen(true)} className={clsx( - "group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm", - disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer" + "group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm" )}> - {filterComboBoxValue && filterComboBoxValue?.length > 0 ? ( - !Array.isArray(filterComboBoxValue) ? ( -

{filterComboBoxValue}

- ) : ( -
- {typeof filterComboBoxValue !== "string" && - filterComboBoxValue?.map((o, index) => ( - - ))} -
- ) + {filterComboBoxValue && filterComboBoxValue.length > 0 ? ( + filterComboBoxItem ) : ( -

{t("common.select")}...

+ )} -
+
+
{open && ( -
+
( { - !isMultiple - ? onChangeFilterComboBoxValue( - typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o - ) - : onChangeFilterComboBoxValue( - Array.isArray(filterComboBoxValue) - ? [ - ...filterComboBoxValue, - typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o, - ] - : [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o] - ); - !isMultiple && setOpen(false); - }} + onSelect={() => commandItemOnSelect(o)} className="cursor-pointer"> {typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.test.tsx new file mode 100644 index 000000000000..4fd1ce3c4c85 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.test.tsx @@ -0,0 +1,126 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { + OptionsType, + QuestionOption, + QuestionOptions, + QuestionsComboBox, + SelectedCommandItem, +} from "./QuestionsComboBox"; + +describe("QuestionsComboBox", () => { + afterEach(() => { + cleanup(); + }); + + const mockOptions: QuestionOptions[] = [ + { + header: OptionsType.QUESTIONS, + option: [{ label: "Q1", type: OptionsType.QUESTIONS, questionType: undefined, id: "1" }], + }, + { + header: OptionsType.TAGS, + option: [{ label: "Tag1", type: OptionsType.TAGS, id: "t1" }], + }, + ]; + + test("renders selected label when closed", () => { + const selected: Partial = { label: "Q1", type: OptionsType.QUESTIONS, id: "1" }; + render( {}} />); + expect(screen.getByText("Q1")).toBeInTheDocument(); + }); + + test("opens dropdown, selects an option, and closes", async () => { + let currentSelected: Partial = {}; + const onChange = vi.fn((option) => { + currentSelected = option; + }); + + const { rerender } = render( + + ); + + // Open the dropdown + await userEvent.click(screen.getByRole("button")); + expect(screen.getByPlaceholderText("common.search...")).toBeInTheDocument(); + + // Select an option + await userEvent.click(screen.getByText("Q1")); + + // Check if onChange was called + expect(onChange).toHaveBeenCalledWith(mockOptions[0].option[0]); + + // Rerender with the new selected value + rerender(); + + // Check if the input is gone and the selected item is displayed + expect(screen.queryByPlaceholderText("common.search...")).toBeNull(); + expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed + }); +}); + +describe("SelectedCommandItem", () => { + test("renders question icon and color for QUESTIONS with questionType", () => { + const { container } = render( + + ); + expect(container.querySelector(".bg-brand-dark")).toBeInTheDocument(); + expect(container.querySelector("svg")).toBeInTheDocument(); + expect(container.textContent).toContain("Q1"); + }); + + test("renders attribute icon and color for ATTRIBUTES", () => { + const { container } = render(); + expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument(); + expect(container.querySelector("svg")).toBeInTheDocument(); + expect(container.textContent).toContain("Attr"); + }); + + test("renders hidden field icon and color for HIDDEN_FIELDS", () => { + const { container } = render(); + expect(container.querySelector(".bg-amber-500")).toBeInTheDocument(); + expect(container.querySelector("svg")).toBeInTheDocument(); + expect(container.textContent).toContain("Hidden"); + }); + + test("renders meta icon and color for META with label", () => { + const { container } = render(); + expect(container.querySelector(".bg-amber-500")).toBeInTheDocument(); + expect(container.querySelector("svg")).toBeInTheDocument(); + expect(container.textContent).toContain("device"); + }); + + test("renders other icon and color for OTHERS with label", () => { + const { container } = render(); + expect(container.querySelector(".bg-amber-500")).toBeInTheDocument(); + expect(container.querySelector("svg")).toBeInTheDocument(); + expect(container.textContent).toContain("Language"); + }); + + test("renders tag icon and color for TAGS", () => { + const { container } = render(); + expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument(); + expect(container.querySelector("svg")).toBeInTheDocument(); + expect(container.textContent).toContain("Tag1"); + }); + + test("renders fallback color and no icon for unknown type", () => { + const { container } = render(); + expect(container.querySelector(".bg-amber-500")).toBeInTheDocument(); + expect(container.querySelector("svg")).not.toBeInTheDocument(); + expect(container.textContent).toContain("Unknown"); + }); + + test("renders fallback for non-string label", () => { + const { container } = render( + + ); + expect(container.textContent).toContain("NonString"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx index 169f310ddc78..d5afde82cba8 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx @@ -1,5 +1,7 @@ "use client"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { useClickOutside } from "@/lib/utils/hooks/useClickOutside"; import { Command, CommandEmpty, @@ -13,14 +15,17 @@ import { useTranslate } from "@tolgee/react"; import clsx from "clsx"; import { AirplayIcon, + ArrowUpFromDotIcon, CheckIcon, ChevronDown, ChevronUp, + ContactIcon, EyeOff, + FlagIcon, GlobeIcon, GridIcon, HashIcon, - HelpCircleIcon, + HomeIcon, ImageIcon, LanguagesIcon, ListIcon, @@ -32,9 +37,7 @@ import { StarIcon, User, } from "lucide-react"; -import * as React from "react"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside"; +import { Fragment, useRef, useState } from "react"; import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; export enum OptionsType { @@ -63,59 +66,61 @@ interface QuestionComboBoxProps { onChangeValue: (option: QuestionOption) => void; } -const SelectedCommandItem = ({ label, questionType, type }: Partial) => { +const questionIcons = { + // questions + [TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon, + [TSurveyQuestionTypeEnum.Rating]: StarIcon, + [TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon, + [TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon, + [TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon, + [TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon, + [TSurveyQuestionTypeEnum.Consent]: CheckIcon, + [TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon, + [TSurveyQuestionTypeEnum.Matrix]: GridIcon, + [TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon, + [TSurveyQuestionTypeEnum.Address]: HomeIcon, + [TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon, + + // attributes + [OptionsType.ATTRIBUTES]: User, + + // hidden fields + [OptionsType.HIDDEN_FIELDS]: EyeOff, + + // meta + device: SmartphoneIcon, + os: AirplayIcon, + browser: GlobeIcon, + source: ArrowUpFromDotIcon, + action: MousePointerClickIcon, + country: FlagIcon, + + // others + Language: LanguagesIcon, + + // tags + [OptionsType.TAGS]: HashIcon, +}; + +const getIcon = (type: string) => { + const IconComponent = questionIcons[type]; + return IconComponent ? : null; +}; + +export const SelectedCommandItem = ({ label, questionType, type }: Partial) => { const getIconType = () => { - switch (type) { - case OptionsType.QUESTIONS: - switch (questionType) { - case TSurveyQuestionTypeEnum.OpenText: - return ; - case TSurveyQuestionTypeEnum.Rating: - return ; - case TSurveyQuestionTypeEnum.CTA: - return ; - case TSurveyQuestionTypeEnum.OpenText: - return ; - case TSurveyQuestionTypeEnum.MultipleChoiceMulti: - return ; - case TSurveyQuestionTypeEnum.MultipleChoiceSingle: - return ; - case TSurveyQuestionTypeEnum.NPS: - return ; - case TSurveyQuestionTypeEnum.Consent: - return ; - case TSurveyQuestionTypeEnum.PictureSelection: - return ; - case TSurveyQuestionTypeEnum.Matrix: - return ; - case TSurveyQuestionTypeEnum.Ranking: - return ; - } - case OptionsType.ATTRIBUTES: - return ; - - case OptionsType.HIDDEN_FIELDS: - return ; - case OptionsType.META: - switch (label) { - case "device": - return ; - case "os": - return ; - case "browser": - return ; - case "source": - return ; - case "action": - return ; - } - case OptionsType.OTHERS: - switch (label) { - case "Language": - return ; - } - case OptionsType.TAGS: - return ; + if (type) { + if (type === OptionsType.QUESTIONS && questionType) { + return getIcon(questionType); + } else if (type === OptionsType.ATTRIBUTES) { + return getIcon(OptionsType.ATTRIBUTES); + } else if (type === OptionsType.HIDDEN_FIELDS) { + return getIcon(OptionsType.HIDDEN_FIELDS); + } else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) { + return getIcon(label); + } else if (type === OptionsType.TAGS) { + return getIcon(OptionsType.TAGS); + } } }; @@ -130,10 +135,16 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial { + if (type !== OptionsType.META) return undefined; + return label === "os" ? "uppercase" : "capitalize"; + }; + return (
{getIconType()} -

+

{typeof label === "string" ? label : getLocalizedValue(label, "default")}

@@ -141,15 +152,15 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial { - const [open, setOpen] = React.useState(false); + const [open, setOpen] = useState(false); const { t } = useTranslate(); - const commandRef = React.useRef(null); - const [inputValue, setInputValue] = React.useState(""); + const commandRef = useRef(null); + const [inputValue, setInputValue] = useState(""); useClickOutside(commandRef, () => setOpen(false)); return ( -
setOpen(true)} className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm"> {!open && selected.hasOwnProperty("label") && ( @@ -174,14 +185,14 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question )}
-
+
{open && ( -
+
{t("common.no_result_found")} {options?.map((data) => ( - <> + {data?.option.length > 0 && ( {data.header}

}> @@ -199,7 +210,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question ))}
)} - +
))}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.test.tsx new file mode 100644 index 000000000000..e02ee4810896 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.test.tsx @@ -0,0 +1,296 @@ +import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; +import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useParams } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { ResponseFilter } from "./ResponseFilter"; + +// Mock dependencies +vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({ + useResponseFilter: vi.fn(), +})); + +vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({ + getSurveyFilterDataAction: vi.fn(), +})); + +vi.mock("@/app/lib/surveys/surveys", () => ({ + generateQuestionAndFilterOptions: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useParams: vi.fn(), +})); + +vi.mock("@formkit/auto-animate/react", () => ({ + useAutoAnimate: () => [[vi.fn()]], +})); + +// Mock the Select components +const mockOnValueChange = vi.fn(); +vi.mock("@/modules/ui/components/select", () => ({ + Select: ({ children, onValueChange, defaultValue }) => { + // Store the onValueChange callback for testing + mockOnValueChange.mockImplementation(onValueChange); + return ( +
+ {children} +
+ ); + }, + SelectTrigger: ({ children, className }) => ( + + ), + SelectValue: () => environments.surveys.filter.complete_and_partial_responses, + SelectContent: ({ children }) =>
{children}
, + SelectItem: ({ value, children, ...props }) => ( +
mockOnValueChange(value)} + onKeyDown={(e) => e.key === "Enter" && mockOnValueChange(value)} + role="option" + tabIndex={0} + {...props}> + {children} +
+ ), +})); + +vi.mock("./QuestionsComboBox", () => ({ + QuestionsComboBox: ({ onChangeValue }) => ( +
+ +
+ ), + OptionsType: { + QUESTIONS: "Questions", + ATTRIBUTES: "Attributes", + TAGS: "Tags", + LANGUAGES: "Languages", + }, +})); + +// Update the mock for QuestionFilterComboBox to always render +vi.mock( + "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox", + () => ({ + QuestionFilterComboBox: () => ( +
+ + +
+ ), + }) +); + +describe("ResponseFilter", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockSelectedFilter = { + filter: [], + responseStatus: "all", + }; + + const mockSelectedOptions = { + questionFilterOptions: [ + { + type: TSurveyQuestionTypeEnum.OpenText, + filterOptions: ["equals", "does not equal"], + filterComboBoxOptions: [], + id: "q1", + }, + ], + questionOptions: [ + { + label: "Questions", + type: "Questions", + option: [ + { id: "q1", label: "Question 1", type: "OpenText", questionType: TSurveyQuestionTypeEnum.OpenText }, + ], + }, + ], + } as any; + + const mockSetSelectedFilter = vi.fn(); + const mockSetSelectedOptions = vi.fn(); + + const mockSurvey = { + id: "survey1", + environmentId: "env1", + name: "Test Survey", + createdAt: new Date(), + updatedAt: new Date(), + status: "draft", + createdBy: "user1", + questions: [], + welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"], + triggers: [], + displayOption: "displayOnce", + } as unknown as TSurvey; + + beforeEach(() => { + vi.mocked(useResponseFilter).mockReturnValue({ + selectedFilter: mockSelectedFilter, + setSelectedFilter: mockSetSelectedFilter, + selectedOptions: mockSelectedOptions, + setSelectedOptions: mockSetSelectedOptions, + } as any); + + vi.mocked(useParams).mockReturnValue({ environmentId: "env1", surveyId: "survey1" }); + + vi.mocked(getSurveyFilterDataAction).mockResolvedValue({ + data: { + attributes: [], + meta: {}, + environmentTags: [], + hiddenFields: [], + } as any, + }); + + vi.mocked(generateQuestionAndFilterOptions).mockReturnValue({ + questionFilterOptions: mockSelectedOptions.questionFilterOptions, + questionOptions: mockSelectedOptions.questionOptions, + }); + }); + + test("renders with default state", () => { + render(); + expect(screen.getByText("Filter")).toBeInTheDocument(); + }); + + test("opens the filter popover when clicked", async () => { + render(); + + await userEvent.click(screen.getByText("Filter")); + + expect( + screen.getByText("environments.surveys.summary.show_all_responses_that_match") + ).toBeInTheDocument(); + expect(screen.getByTestId("select-trigger")).toBeInTheDocument(); + }); + + test("fetches filter data when opened", async () => { + render(); + + await userEvent.click(screen.getByText("Filter")); + + expect(getSurveyFilterDataAction).toHaveBeenCalledWith({ surveyId: "survey1" }); + expect(mockSetSelectedOptions).toHaveBeenCalled(); + }); + + test("handles adding new filter", async () => { + // Start with an empty filter + vi.mocked(useResponseFilter).mockReturnValue({ + selectedFilter: { filter: [], responseStatus: "all" }, + setSelectedFilter: mockSetSelectedFilter, + selectedOptions: mockSelectedOptions, + setSelectedOptions: mockSetSelectedOptions, + } as any); + + render(); + + await userEvent.click(screen.getByText("Filter")); + // Verify there's no filter yet + expect(screen.queryByTestId("questions-combo-box")).not.toBeInTheDocument(); + + // Add a new filter and check that the questions combo box appears + await userEvent.click(screen.getByText("common.add_filter")); + + expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument(); + }); + + test("handles response status filter change to complete", async () => { + render(); + + await userEvent.click(screen.getByText("Filter")); + + // Simulate selecting "complete" by calling the mock function + mockOnValueChange("complete"); + + await userEvent.click(screen.getByText("common.apply_filters")); + + expect(mockSetSelectedFilter).toHaveBeenCalledWith( + expect.objectContaining({ + responseStatus: "complete", + }) + ); + }); + + test("handles response status filter change to partial", async () => { + render(); + + await userEvent.click(screen.getByText("Filter")); + + // Simulate selecting "partial" by calling the mock function + mockOnValueChange("partial"); + + await userEvent.click(screen.getByText("common.apply_filters")); + + expect(mockSetSelectedFilter).toHaveBeenCalledWith( + expect.objectContaining({ + responseStatus: "partial", + }) + ); + }); + + test("handles selecting question and filter options", async () => { + // Setup with a pre-populated filter to ensure the filter components are rendered + const setSelectedFilterMock = vi.fn(); + vi.mocked(useResponseFilter).mockReturnValue({ + selectedFilter: { + filter: [ + { + questionType: { id: "q1", label: "Question 1", type: "OpenText" }, + filterType: { filterComboBoxValue: undefined, filterValue: undefined }, + }, + ], + responseStatus: "all", + }, + setSelectedFilter: setSelectedFilterMock, + selectedOptions: mockSelectedOptions, + setSelectedOptions: mockSetSelectedOptions, + } as any); + + render(); + + await userEvent.click(screen.getByText("Filter")); + + // Verify both combo boxes are rendered + expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument(); + expect(screen.getByTestId("filter-combo-box")).toBeInTheDocument(); + + // Use data-testid to find our buttons instead of text + await userEvent.click(screen.getByText("Select Question")); + await userEvent.click(screen.getByTestId("select-filter-btn")); + await userEvent.click(screen.getByText("common.apply_filters")); + + expect(setSelectedFilterMock).toHaveBeenCalled(); + }); + + test("handles clear all filters", async () => { + render(); + + await userEvent.click(screen.getByText("Filter")); + await userEvent.click(screen.getByText("common.clear_all")); + + expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], responseStatus: "all" }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx index 08b778c456b2..1bd410260600 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx @@ -2,20 +2,24 @@ import { SelectedFilterValue, + TResponseStatus, useResponseFilter, } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox"; import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys"; -import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions"; import { Button } from "@/modules/ui/components/button"; -import { Checkbox } from "@/modules/ui/components/checkbox"; import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/modules/ui/components/select"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useTranslate } from "@tolgee/react"; -import clsx from "clsx"; import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react"; -import { useParams } from "next/navigation"; import React, { useEffect, useState } from "react"; import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox"; @@ -33,10 +37,7 @@ interface ResponseFilterProps { export const ResponseFilter = ({ survey }: ResponseFilterProps) => { const { t } = useTranslate(); - const params = useParams(); const [parent] = useAutoAnimate(); - const sharingKey = params.sharingKey as string; - const isSharingPage = !!sharingKey; const { selectedFilter, setSelectedFilter, selectedOptions, setSelectedOptions } = useResponseFilter(); const [isOpen, setIsOpen] = useState(false); @@ -46,12 +47,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { // Fetch the initial data for the filter and load it into the state const handleInitialData = async () => { if (isOpen) { - const surveyFilterData = isSharingPage - ? await getSurveyFilterDataBySurveySharingKeyAction({ - sharingKey, - environmentId: survey.environmentId, - }) - : await getSurveyFilterDataAction({ surveyId: survey.id }); + const surveyFilterData = await getSurveyFilterDataAction({ surveyId: survey.id }); if (!surveyFilterData?.data) return; @@ -68,7 +64,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { }; handleInitialData(); - }, [isOpen, isSharingPage, setSelectedOptions, sharingKey, survey]); + }, [isOpen, setSelectedOptions, survey]); const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => { if (filterValue.filter[index].questionType) { @@ -82,7 +78,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { )?.filterOptions[0], }, }; - setFilterValue({ filter: [...filterValue.filter], onlyComplete: filterValue.onlyComplete }); + setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus }); } else { // Update the existing value at the specified index filterValue.filter[index].questionType = value; @@ -103,7 +99,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { // keep the filter if questionType is selected and filterComboBoxValue is selected return s.questionType.hasOwnProperty("label") && s.filterType.filterComboBoxValue?.length; }), - onlyComplete: filterValue.onlyComplete, + responseStatus: filterValue.responseStatus, }); }; @@ -130,8 +126,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { }; const handleClearAllFilters = () => { - setFilterValue((filterValue) => ({ ...filterValue, filter: [] })); - setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [] })); + setFilterValue((filterValue) => ({ ...filterValue, filter: [], responseStatus: "all" })); + setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [], responseStatus: "all" })); setIsOpen(false); }; @@ -168,8 +164,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { setFilterValue({ ...filterValue }); }; - const handleCheckOnlyComplete = (checked: boolean) => { - setFilterValue({ ...filterValue, onlyComplete: checked }); + const handleResponseStatusChange = (responseStatus: TResponseStatus) => { + setFilterValue({ ...filterValue, responseStatus }); }; // remove the filter which has already been selected @@ -213,8 +209,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { -
+ className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]" + onOpenAutoFocus={(event) => event.preventDefault()}> +

{t("environments.surveys.summary.show_all_responses_that_match")}

@@ -222,16 +219,24 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { {t("environments.surveys.summary.show_all_responses_where")}

- - { - typeof checked === "boolean" && handleCheckOnlyComplete(checked); +
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton.tsx deleted file mode 100644 index ee8170891e09..000000000000 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton.tsx +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; - -import { - deleteResultShareUrlAction, - generateResultShareUrlAction, - getResultShareUrlAction, -} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions"; -import { getFormattedErrorMessage } from "@/lib/utils/helper"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/modules/ui/components/dropdown-menu"; -import { useTranslate } from "@tolgee/react"; -import { CopyIcon, DownloadIcon, GlobeIcon, LinkIcon } from "lucide-react"; -import { useEffect, useState } from "react"; -import toast from "react-hot-toast"; -import { TSurvey } from "@formbricks/types/surveys/types"; -import { ShareSurveyResults } from "../(analysis)/summary/components/ShareSurveyResults"; - -interface ResultsShareButtonProps { - survey: TSurvey; - webAppUrl: string; -} - -export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProps) => { - const { t } = useTranslate(); - const [showResultsLinkModal, setShowResultsLinkModal] = useState(false); - - const [showPublishModal, setShowPublishModal] = useState(false); - const [surveyUrl, setSurveyUrl] = useState(""); - - const handlePublish = async () => { - const resultShareKeyResponse = await generateResultShareUrlAction({ surveyId: survey.id }); - if (resultShareKeyResponse?.data) { - setSurveyUrl(webAppUrl + "/share/" + resultShareKeyResponse.data); - setShowPublishModal(true); - } else { - const errorMessage = getFormattedErrorMessage(resultShareKeyResponse); - toast.error(errorMessage); - } - }; - - const handleUnpublish = () => { - deleteResultShareUrlAction({ surveyId: survey.id }).then((deleteResultShareUrlResponse) => { - if (deleteResultShareUrlResponse?.data) { - toast.success(t("environments.surveys.results_unpublished_successfully")); - setShowPublishModal(false); - } else { - const errorMessage = getFormattedErrorMessage(deleteResultShareUrlResponse); - toast.error(errorMessage); - } - }); - }; - - useEffect(() => { - const fetchSharingKey = async () => { - const resultShareUrlResponse = await getResultShareUrlAction({ surveyId: survey.id }); - if (resultShareUrlResponse?.data) { - setSurveyUrl(webAppUrl + "/share/" + resultShareUrlResponse.data); - setShowPublishModal(true); - } - }; - - fetchSharingKey(); - }, [survey.id, webAppUrl]); - - const copyUrlToClipboard = () => { - if (typeof window !== "undefined") { - const currentUrl = window.location.href; - navigator.clipboard - .writeText(currentUrl) - .then(() => { - toast.success(t("common.copied_to_clipboard")); - }) - .catch(() => { - toast.error(t("environments.surveys.failed_to_copy_link_to_results")); - }); - } else { - toast.error(t("environments.surveys.failed_to_copy_url")); - } - }; - return ( -
- - -
-
- - {t("environments.surveys.summary.share_results")} - - -
- -
-
- - {survey.resultShareKey ? ( - { - navigator.clipboard.writeText(surveyUrl); - toast.success(t("environments.surveys.summary.link_to_public_results_copied")); - }} - icon={}> -

- {t("environments.surveys.summary.copy_link_to_public_results")} -

-
- ) : ( - { - copyUrlToClipboard(); - }} - icon={}> -

{t("common.copy_link")}

-
- )} - { - setShowResultsLinkModal(true); - }} - icon={}> -

- {survey.resultShareKey - ? t("environments.surveys.summary.unpublish_from_web") - : t("environments.surveys.summary.publish_to_web")} -

-
-
-
- {showResultsLinkModal && ( - - )} -
- ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown.test.tsx new file mode 100644 index 000000000000..126e0b77b113 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown.test.tsx @@ -0,0 +1,181 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { SurveyStatusDropdown } from "./SurveyStatusDropdown"; + +vi.mock("@/lib/utils/helper", () => ({ + getFormattedErrorMessage: vi.fn((error) => error?.message || "An error occurred"), +})); + +vi.mock("@/modules/ui/components/select", () => ({ + Select: vi.fn(({ value, onValueChange, disabled, children }) => ( +
+
{value}
+ {children} + +
+ )), + SelectContent: vi.fn(({ children }) =>
{children}
), + SelectItem: vi.fn(({ value, children }) =>
{children}
), + SelectTrigger: vi.fn(({ children }) =>
{children}
), + SelectValue: vi.fn(({ children }) =>
{children}
), +})); + +vi.mock("@/modules/ui/components/survey-status-indicator", () => ({ + SurveyStatusIndicator: vi.fn(({ status }) => ( +
{`Status: ${status}`}
+ )), +})); + +vi.mock("@/modules/ui/components/tooltip", () => ({ + Tooltip: vi.fn(({ children }) =>
{children}
), + TooltipContent: vi.fn(({ children }) =>
{children}
), + TooltipProvider: vi.fn(({ children }) =>
{children}
), + TooltipTrigger: vi.fn(({ children }) =>
{children}
), +})); + +vi.mock("../actions", () => ({ + updateSurveyAction: vi.fn(), +})); + +const mockEnvironment: TEnvironment = { + id: "env_1", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "proj_1", + type: "production", + appSetupCompleted: true, + productOverwrites: null, + brandLinks: null, + recontactDays: 30, + displayBranding: true, + highlightBorderColor: null, + placement: "bottomRight", + clickOutsideClose: true, + darkOverlay: false, +}; + +const baseSurvey: TSurvey = { + id: "survey_1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + type: "app", + environmentId: "env_1", + status: "draft", + questions: [], + hiddenFields: { enabled: true, fieldIds: [] }, + displayOption: "displayOnce", + recontactDays: null, + autoClose: null, + delay: 0, + displayPercentage: null, + redirectUrl: null, + welcomeCard: { enabled: true } as TSurvey["welcomeCard"], + languages: [], + styling: null, + variables: [], + triggers: [], + numDisplays: 0, + responseRate: 0, + responses: [], + summary: { completedResponses: 0, displays: 0, totalResponses: 0, startsPercentage: 0 }, + isResponseEncryptionEnabled: false, + isSingleUse: false, + segment: null, + surveyClosedMessage: null, + singleUse: null, + verifyEmail: null, + pin: null, + closeOnDate: null, + productOverwrites: null, + analytics: { + numCTA: 0, + numDisplays: 0, + numResponses: 0, + numStarts: 0, + responseRate: 0, + startRate: 0, + totalCompletedResponses: 0, + totalDisplays: 0, + totalResponses: 0, + }, + createdBy: null, + autoComplete: null, + runOnDate: null, + endings: [], +}; + +describe("SurveyStatusDropdown", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders draft status correctly", () => { + render( + + ); + expect(screen.getByText("common.draft")).toBeInTheDocument(); + expect(screen.queryByTestId("select-container")).toBeNull(); + }); + + test("disables select when status is scheduled", () => { + render( + + ); + expect(screen.getByTestId("select-container")).toHaveAttribute("data-disabled", "true"); + expect(screen.getByTestId("tooltip")).toBeInTheDocument(); + expect(screen.getByTestId("tooltip-content")).toHaveTextContent( + "environments.surveys.survey_status_tooltip" + ); + }); + + test("disables select when closeOnDate is in the past", () => { + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 1); + render( + + ); + expect(screen.getByTestId("select-container")).toHaveAttribute("data-disabled", "true"); + }); + + test("renders SurveyStatusIndicator for link survey", () => { + render( + + ); + const actualSelectTrigger = screen.getByTestId("actual-select-trigger"); + expect(within(actualSelectTrigger).getByTestId("survey-status-indicator")).toBeInTheDocument(); + }); + + test("renders SurveyStatusIndicator when appSetupCompleted is true", () => { + render( + + ); + const actualSelectTrigger = screen.getByTestId("actual-select-trigger"); + expect(within(actualSelectTrigger).getByTestId("survey-status-indicator")).toBeInTheDocument(); + }); + + test("does not render SurveyStatusIndicator when appSetupCompleted is false for non-link survey", () => { + render( + + ); + const actualSelectTrigger = screen.getByTestId("actual-select-trigger"); + expect(within(actualSelectTrigger).queryByTestId("survey-status-indicator")).toBeNull(); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown.tsx index 12fbfe6b66b9..880e1b0b9900 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown.tsx @@ -11,6 +11,7 @@ import { import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { useTranslate } from "@tolgee/react"; +import { useRouter } from "next/navigation"; import toast from "react-hot-toast"; import { TEnvironment } from "@formbricks/types/environment"; import { TSurvey } from "@formbricks/types/surveys/types"; @@ -28,6 +29,7 @@ export const SurveyStatusDropdown = ({ survey, }: SurveyStatusDropdownProps) => { const { t } = useTranslate(); + const router = useRouter(); const isCloseOnDateEnabled = survey.closeOnDate !== null; const closeOnDate = survey.closeOnDate ? new Date(survey.closeOnDate) : null; const isStatusChangeDisabled = @@ -47,6 +49,8 @@ export const SurveyStatusDropdown = ({ ? t("common.survey_completed") : "" ); + + router.refresh(); } else { const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse); toast.error(errorMessage); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context.test.tsx new file mode 100644 index 000000000000..b4b9db65e77e --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context.test.tsx @@ -0,0 +1,275 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { SurveyContextWrapper, useSurvey } from "./survey-context"; + +// Mock survey data +const mockSurvey: TSurvey = { + id: "test-survey-id", + createdAt: new Date("2023-01-01T00:00:00.000Z"), + updatedAt: new Date("2023-01-01T00:00:00.000Z"), + name: "Test Survey", + type: "link", + environmentId: "test-env-id", + createdBy: "test-user-id", + status: "draft", + displayOption: "displayOnce", + autoClose: null, + triggers: [], + recontactDays: null, + displayLimit: null, + welcomeCard: { + enabled: false, + headline: { default: "Welcome" }, + html: { default: "" }, + timeToFinish: false, + showResponseCount: false, + buttonLabel: { default: "Start" }, + fileUrl: undefined, + videoUrl: undefined, + }, + questions: [ + { + id: "question-1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "What's your name?" }, + required: true, + inputType: "text", + logic: [], + buttonLabel: { default: "Next" }, + backButtonLabel: { default: "Back" }, + placeholder: { default: "Enter your name" }, + longAnswer: false, + subheader: { default: "" }, + charLimit: { enabled: false, min: 0, max: 255 }, + }, + ], + endings: [ + { + id: "ending-1", + type: "endScreen", + headline: { default: "Thank you!" }, + subheader: { default: "We appreciate your feedback." }, + buttonLabel: { default: "Done" }, + buttonLink: undefined, + imageUrl: undefined, + videoUrl: undefined, + }, + ], + hiddenFields: { + enabled: false, + fieldIds: [], + }, + variables: [], + followUps: [], + delay: 0, + autoComplete: null, + runOnDate: null, + closeOnDate: null, + projectOverwrites: null, + styling: null, + showLanguageSwitch: null, + surveyClosedMessage: null, + segment: null, + singleUse: null, + isVerifyEmailEnabled: false, + recaptcha: null, + isSingleResponsePerEmailEnabled: false, + isBackButtonHidden: false, + pin: null, + displayPercentage: null, + languages: [ + { + language: { + id: "en", + code: "en", + alias: "English", + projectId: "test-project-id", + createdAt: new Date("2023-01-01T00:00:00.000Z"), + updatedAt: new Date("2023-01-01T00:00:00.000Z"), + }, + default: true, + enabled: true, + }, + ], +}; + +// Test component that uses the hook +const TestComponent = () => { + const { survey } = useSurvey(); + return ( +
+
{survey.id}
+
{survey.name}
+
{survey.type}
+
{survey.status}
+
{survey.environmentId}
+
+ ); +}; + +describe("SurveyContext", () => { + afterEach(() => { + cleanup(); + }); + + test("provides survey data to child components", () => { + render( + + + + ); + + expect(screen.getByTestId("survey-id")).toHaveTextContent("test-survey-id"); + expect(screen.getByTestId("survey-name")).toHaveTextContent("Test Survey"); + expect(screen.getByTestId("survey-type")).toHaveTextContent("link"); + expect(screen.getByTestId("survey-status")).toHaveTextContent("draft"); + expect(screen.getByTestId("survey-environment-id")).toHaveTextContent("test-env-id"); + }); + + test("throws error when useSurvey is used outside of provider", () => { + const TestComponentWithoutProvider = () => { + useSurvey(); + return
Should not render
; + }; + + expect(() => { + render(); + }).toThrow("useSurvey must be used within a SurveyContextWrapper"); + }); + + test("updates context value when survey changes", () => { + const updatedSurvey = { + ...mockSurvey, + name: "Updated Survey", + status: "inProgress" as const, + }; + + const { rerender } = render( + + + + ); + + expect(screen.getByTestId("survey-name")).toHaveTextContent("Test Survey"); + expect(screen.getByTestId("survey-status")).toHaveTextContent("draft"); + + rerender( + + + + ); + + expect(screen.getByTestId("survey-name")).toHaveTextContent("Updated Survey"); + expect(screen.getByTestId("survey-status")).toHaveTextContent("inProgress"); + }); + + test("verifies memoization by tracking render counts", () => { + let renderCount = 0; + const renderSpy = vi.fn(() => { + renderCount++; + }); + + const TestComponentWithRenderTracking = () => { + renderSpy(); + const { survey } = useSurvey(); + return ( +
+
{survey.id}
+
{renderCount}
+
+ ); + }; + + const { rerender } = render( + + + + ); + + expect(screen.getByTestId("survey-id")).toHaveTextContent("test-survey-id"); + expect(renderSpy).toHaveBeenCalledTimes(1); + + // Rerender with the same survey object - should not trigger additional renders + // if memoization is working correctly + rerender( + + + + ); + + expect(screen.getByTestId("survey-id")).toHaveTextContent("test-survey-id"); + expect(renderSpy).toHaveBeenCalledTimes(2); // Should only be called once more for the rerender + }); + + test("prevents unnecessary re-renders when survey object is unchanged", () => { + const childRenderSpy = vi.fn(); + + const ChildComponent = () => { + childRenderSpy(); + const { survey } = useSurvey(); + return
{survey.name}
; + }; + + const ParentComponent = ({ survey }: { survey: TSurvey }) => { + return ( + + + + ); + }; + + const { rerender } = render(); + + expect(screen.getByTestId("child-survey-name")).toHaveTextContent("Test Survey"); + expect(childRenderSpy).toHaveBeenCalledTimes(1); + + // Rerender with the same survey object reference + rerender(); + + expect(screen.getByTestId("child-survey-name")).toHaveTextContent("Test Survey"); + expect(childRenderSpy).toHaveBeenCalledTimes(2); // Should only be called once more + + // Rerender with a different survey object should trigger re-render + const updatedSurvey = { ...mockSurvey, name: "Updated Survey" }; + rerender(); + + expect(screen.getByTestId("child-survey-name")).toHaveTextContent("Updated Survey"); + expect(childRenderSpy).toHaveBeenCalledTimes(3); // Should be called again due to prop change + }); + + test("renders children correctly", () => { + const TestChild = () =>
Child Component
; + + render( + + + + ); + + expect(screen.getByTestId("child")).toHaveTextContent("Child Component"); + }); + + test("handles multiple child components", () => { + const TestChild1 = () => { + const { survey } = useSurvey(); + return
{survey.name}
; + }; + + const TestChild2 = () => { + const { survey } = useSurvey(); + return
{survey.type}
; + }; + + render( + + + + + ); + + expect(screen.getByTestId("child-1")).toHaveTextContent("Test Survey"); + expect(screen.getByTestId("child-2")).toHaveTextContent("link"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context.tsx new file mode 100644 index 000000000000..6657ad982a7b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { createContext, useContext, useMemo } from "react"; +import { TSurvey } from "@formbricks/types/surveys/types"; + +export interface SurveyContextType { + survey: TSurvey; +} + +const SurveyContext = createContext(null); +SurveyContext.displayName = "SurveyContext"; + +export const useSurvey = () => { + const context = useContext(SurveyContext); + if (!context) { + throw new Error("useSurvey must be used within a SurveyContextWrapper"); + } + return context; +}; + +// Client wrapper component to be used in server components +interface SurveyContextWrapperProps { + survey: TSurvey; + children: React.ReactNode; +} + +export const SurveyContextWrapper = ({ survey, children }: SurveyContextWrapperProps) => { + const surveyContextValue = useMemo( + () => ({ + survey, + }), + [survey] + ); + + return {children}; +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/layout.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/layout.test.tsx new file mode 100644 index 000000000000..39e2d01103f5 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/layout.test.tsx @@ -0,0 +1,194 @@ +// Import the mocked function to access it in tests +import { getSurvey } from "@/lib/survey/service"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import SurveyLayout from "./layout"; + +// Mock the getSurvey function +vi.mock("@/lib/survey/service", () => ({ + getSurvey: vi.fn(), +})); + +// Mock the SurveyContextWrapper component +vi.mock("./context/survey-context", () => ({ + SurveyContextWrapper: ({ survey, children }: { survey: TSurvey; children: React.ReactNode }) => ( +
+ {children} +
+ ), +})); + +describe("SurveyLayout", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockSurvey: TSurvey = { + id: "survey-123", + name: "Test Survey", + environmentId: "env-123", + status: "inProgress", + type: "link", + createdAt: new Date(), + updatedAt: new Date(), + questions: [], + endings: [], + hiddenFields: { + enabled: false, + }, + variables: [], + welcomeCard: { + enabled: false, + timeToFinish: true, + showResponseCount: false, + }, + displayOption: "displayOnce", + recontactDays: null, + displayLimit: null, + autoClose: null, + runOnDate: null, + closeOnDate: null, + delay: 0, + displayPercentage: null, + autoComplete: null, + isVerifyEmailEnabled: false, + isSingleResponsePerEmailEnabled: false, + isBackButtonHidden: false, + projectOverwrites: { + brandColor: null, + highlightBorderColor: null, + placement: null, + clickOutsideClose: null, + darkOverlay: null, + }, + styling: null, + surveyClosedMessage: null, + singleUse: null, + pin: null, + showLanguageSwitch: false, + recaptcha: null, + languages: [], + triggers: [], + segment: null, + followUps: [], + createdBy: null, + }; + + const mockParams = Promise.resolve({ + surveyId: "survey-123", + environmentId: "env-123", + }); + + test("renders SurveyContextWrapper with survey and children when survey is found", async () => { + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + + render( + await SurveyLayout({ + params: mockParams, + children:
Test Content
, + }) + ); + + expect(getSurvey).toHaveBeenCalledWith("survey-123"); + expect(screen.getByTestId("survey-context-wrapper")).toBeInTheDocument(); + expect(screen.getByTestId("survey-context-wrapper")).toHaveAttribute("data-survey-id", "survey-123"); + expect(screen.getByTestId("test-children")).toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + }); + + test("throws error when survey is not found", async () => { + vi.mocked(getSurvey).mockResolvedValue(null); + + await expect( + SurveyLayout({ + params: mockParams, + children:
Test Content
, + }) + ).rejects.toThrow("Survey not found"); + + expect(getSurvey).toHaveBeenCalledWith("survey-123"); + }); + + test("awaits params before calling getSurvey", async () => { + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + + const delayedParams = new Promise<{ surveyId: string; environmentId: string }>((resolve) => { + setTimeout(() => { + resolve({ + surveyId: "survey-456", + environmentId: "env-456", + }); + }, 10); + }); + + render( + await SurveyLayout({ + params: delayedParams, + children:
Test Content
, + }) + ); + + expect(getSurvey).toHaveBeenCalledWith("survey-456"); + expect(screen.getByTestId("survey-context-wrapper")).toHaveAttribute("data-survey-id", "survey-123"); + }); + + test("calls getSurvey with correct surveyId from params", async () => { + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + + const customParams = Promise.resolve({ + surveyId: "custom-survey-id", + environmentId: "custom-env-id", + }); + + render( + await SurveyLayout({ + params: customParams, + children:
Test Content
, + }) + ); + + expect(getSurvey).toHaveBeenCalledWith("custom-survey-id"); + expect(getSurvey).toHaveBeenCalledTimes(1); + }); + + test("passes children to SurveyContextWrapper", async () => { + vi.mocked(getSurvey).mockResolvedValue(mockSurvey); + + const complexChildren = ( +
+

Survey Title

+

Survey description

+ +
+ ); + + render( + await SurveyLayout({ + params: mockParams, + children: complexChildren, + }) + ); + + expect(screen.getByTestId("complex-children")).toBeInTheDocument(); + expect(screen.getByText("Survey Title")).toBeInTheDocument(); + expect(screen.getByText("Survey description")).toBeInTheDocument(); + expect(screen.getByText("Submit")).toBeInTheDocument(); + }); + + test("handles getSurvey rejection correctly", async () => { + const mockError = new Error("Database connection failed"); + vi.mocked(getSurvey).mockRejectedValue(mockError); + + await expect( + SurveyLayout({ + params: mockParams, + children:
Test Content
, + }) + ).rejects.toThrow("Database connection failed"); + + expect(getSurvey).toHaveBeenCalledWith("survey-123"); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/layout.tsx new file mode 100644 index 000000000000..eec9adafb17f --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/layout.tsx @@ -0,0 +1,21 @@ +import { getSurvey } from "@/lib/survey/service"; +import { SurveyContextWrapper } from "./context/survey-context"; + +interface SurveyLayoutProps { + params: Promise<{ surveyId: string; environmentId: string }>; + children: React.ReactNode; +} + +const SurveyLayout = async ({ params, children }: SurveyLayoutProps) => { + const resolvedParams = await params; + + const survey = await getSurvey(resolvedParams.surveyId); + + if (!survey) { + throw new Error("Survey not found"); + } + + return {children}; +}; + +export default SurveyLayout; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/page.test.tsx new file mode 100644 index 000000000000..26ff9515eeff --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/page.test.tsx @@ -0,0 +1,23 @@ +import { redirect } from "next/navigation"; +import { describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +describe("SurveyPage", () => { + test("should redirect to the survey summary page", async () => { + const params = { + environmentId: "testEnvId", + surveyId: "testSurveyId", + }; + const props = { params }; + + await Page(props); + + expect(vi.mocked(redirect)).toHaveBeenCalledWith( + `/environments/${params.environmentId}/surveys/${params.surveyId}/summary` + ); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/loading.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/loading.test.tsx new file mode 100644 index 000000000000..2e0b7c7eb3f0 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/loading.test.tsx @@ -0,0 +1,15 @@ +import { SurveyListLoading as OriginalSurveyListLoading } from "@/modules/survey/list/loading"; +import { describe, expect, test, vi } from "vitest"; +import SurveyListLoading from "./loading"; + +// Mock the original component to ensure we are testing the re-export +vi.mock("@/modules/survey/list/loading", () => ({ + SurveyListLoading: () =>
Mock SurveyListLoading
, +})); + +describe("SurveyListLoadingPage Re-export", () => { + test("should re-export SurveyListLoading from the correct module", () => { + // Check if the re-exported component is the same as the original (mocked) component + expect(SurveyListLoading).toBe(OriginalSurveyListLoading); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/page.test.tsx new file mode 100644 index 000000000000..05b744bf0868 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/page.test.tsx @@ -0,0 +1,24 @@ +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import SurveysPage, { metadata as layoutMetadata } from "./page"; + +vi.mock("@/modules/survey/list/page", () => ({ + SurveysPage: ({ children }) =>
{children}
, + metadata: { title: "Mocked Surveys Page" }, +})); + +describe("SurveysPage", () => { + afterEach(() => { + cleanup(); + }); + + test("renders SurveysPage", () => { + const { getByTestId } = render(); + expect(getByTestId("surveys-page")).toBeInTheDocument(); + expect(getByTestId("surveys-page")).toHaveTextContent(""); + }); + + test("exports metadata from @/modules/survey/list/page", () => { + expect(layoutMetadata).toEqual({ title: "Mocked Surveys Page" }); + }); +}); diff --git a/apps/web/app/(app)/environments/page.test.tsx b/apps/web/app/(app)/environments/page.test.tsx new file mode 100644 index 000000000000..a4021f70005e --- /dev/null +++ b/apps/web/app/(app)/environments/page.test.tsx @@ -0,0 +1,19 @@ +import { cleanup, render } from "@testing-library/react"; +import { redirect } from "next/navigation"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import Page from "./page"; + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +describe("Page", () => { + afterEach(() => { + cleanup(); + }); + + test("should redirect to /", () => { + render(); + expect(vi.mocked(redirect)).toHaveBeenCalledWith("/"); + }); +}); diff --git a/apps/web/app/(app)/layout.test.tsx b/apps/web/app/(app)/layout.test.tsx index 542ff29df2ea..c62f9cc50418 100644 --- a/apps/web/app/(app)/layout.test.tsx +++ b/apps/web/app/(app)/layout.test.tsx @@ -1,8 +1,8 @@ +import { getUser } from "@/lib/user/service"; import "@testing-library/jest-dom/vitest"; import { cleanup, render, screen } from "@testing-library/react"; import { getServerSession } from "next-auth"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { getUser } from "@formbricks/lib/user/service"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { TUser } from "@formbricks/types/user"; import AppLayout from "./layout"; @@ -10,11 +10,11 @@ vi.mock("next-auth", () => ({ getServerSession: vi.fn(), })); -vi.mock("@formbricks/lib/user/service", () => ({ +vi.mock("@/lib/user/service", () => ({ getUser: vi.fn(), })); -vi.mock("@formbricks/lib/constants", () => ({ +vi.mock("@/lib/constants", () => ({ INTERCOM_SECRET_KEY: "test-secret-key", IS_INTERCOM_CONFIGURED: true, INTERCOM_APP_ID: "test-app-id", @@ -36,14 +36,13 @@ vi.mock("@formbricks/lib/constants", () => ({ IS_POSTHOG_CONFIGURED: true, POSTHOG_API_HOST: "test-posthog-api-host", POSTHOG_API_KEY: "test-posthog-api-key", - FORMBRICKS_API_HOST: "mock-formbricks-api-host", FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id", IS_FORMBRICKS_ENABLED: true, + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: true, })); -vi.mock("@/app/(app)/components/FormbricksClient", () => ({ - FormbricksClient: () =>
, -})); vi.mock("@/app/intercom/IntercomClientWrapper", () => ({ IntercomClientWrapper: () =>
, })); @@ -59,7 +58,7 @@ describe("(app) AppLayout", () => { cleanup(); }); - it("renders child content and all sub-components when user exists", async () => { + test("renders child content and all sub-components when user exists", async () => { vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } }); vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser); @@ -74,17 +73,5 @@ describe("(app) AppLayout", () => { expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument(); expect(screen.getByTestId("toaster-client")).toBeInTheDocument(); expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children"); - expect(screen.getByTestId("formbricks-client")).toBeInTheDocument(); - }); - - it("skips FormbricksClient if no user is present", async () => { - vi.mocked(getServerSession).mockResolvedValueOnce(null); - - const element = await AppLayout({ - children:
Hello from children
, - }); - render(element); - - expect(screen.queryByTestId("formbricks-client")).not.toBeInTheDocument(); }); }); diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 4b011fc12f0a..99339d2d8cc6 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -1,5 +1,6 @@ -import { FormbricksClient } from "@/app/(app)/components/FormbricksClient"; import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper"; +import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@/lib/constants"; +import { getUser } from "@/lib/user/service"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { ClientLogout } from "@/modules/ui/components/client-logout"; import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay"; @@ -7,15 +8,6 @@ import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-cl import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { getServerSession } from "next-auth"; import { Suspense } from "react"; -import { - FORMBRICKS_API_HOST, - FORMBRICKS_ENVIRONMENT_ID, - IS_FORMBRICKS_ENABLED, - IS_POSTHOG_CONFIGURED, - POSTHOG_API_HOST, - POSTHOG_API_KEY, -} from "@formbricks/lib/constants"; -import { getUser } from "@formbricks/lib/user/service"; const AppLayout = async ({ children }) => { const session = await getServerSession(authOptions); @@ -38,15 +30,6 @@ const AppLayout = async ({ children }) => { <> - {user ? ( - - ) : null} {children} diff --git a/apps/web/app/(auth)/auth/forgot-password/page.test.tsx b/apps/web/app/(auth)/auth/forgot-password/page.test.tsx new file mode 100644 index 000000000000..14d4e7819614 --- /dev/null +++ b/apps/web/app/(auth)/auth/forgot-password/page.test.tsx @@ -0,0 +1,35 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import ForgotPasswordPage from "./page"; + +vi.mock("@/modules/auth/forgot-password/page", () => ({ + ForgotPasswordPage: () => ( +
+
+
Forgot Password Form
+
+
+ ), +})); + +describe("ForgotPasswordPage", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the forgot password page", () => { + render(); + expect(screen.getByTestId("forgot-password-page")).toBeInTheDocument(); + }); + + test("renders the form wrapper", () => { + render(); + expect(screen.getByTestId("form-wrapper")).toBeInTheDocument(); + }); + + test("renders the forgot password form", () => { + render(); + expect(screen.getByTestId("forgot-password-form")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(auth)/email-change-without-verification-success/page.test.tsx b/apps/web/app/(auth)/email-change-without-verification-success/page.test.tsx new file mode 100644 index 000000000000..df6aa37986f5 --- /dev/null +++ b/apps/web/app/(auth)/email-change-without-verification-success/page.test.tsx @@ -0,0 +1,20 @@ +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import EmailChangeWithoutVerificationSuccessPage from "./page"; + +vi.mock("@/modules/auth/email-change-without-verification-success/page", () => ({ + EmailChangeWithoutVerificationSuccessPage: ({ children }) => ( +
{children}
+ ), +})); + +describe("EmailChangeWithoutVerificationSuccessPage", () => { + afterEach(() => { + cleanup(); + }); + + test("renders EmailChangeWithoutVerificationSuccessPage", () => { + const { getByTestId } = render(); + expect(getByTestId("email-change-success-page")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(auth)/email-change-without-verification-success/page.tsx b/apps/web/app/(auth)/email-change-without-verification-success/page.tsx new file mode 100644 index 000000000000..1d2fd29b01b6 --- /dev/null +++ b/apps/web/app/(auth)/email-change-without-verification-success/page.tsx @@ -0,0 +1,3 @@ +import { EmailChangeWithoutVerificationSuccessPage } from "@/modules/auth/email-change-without-verification-success/page"; + +export default EmailChangeWithoutVerificationSuccessPage; diff --git a/apps/web/app/(auth)/layout.test.tsx b/apps/web/app/(auth)/layout.test.tsx index dae4f7909813..daeef3c8e1d1 100644 --- a/apps/web/app/(auth)/layout.test.tsx +++ b/apps/web/app/(auth)/layout.test.tsx @@ -1,9 +1,9 @@ import "@testing-library/jest-dom/vitest"; import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import AppLayout from "../(auth)/layout"; -vi.mock("@formbricks/lib/constants", () => ({ +vi.mock("@/lib/constants", () => ({ IS_FORMBRICKS_CLOUD: false, IS_INTERCOM_CONFIGURED: true, INTERCOM_SECRET_KEY: "mock-intercom-secret-key", @@ -18,7 +18,7 @@ vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({ })); describe("(auth) AppLayout", () => { - it("renders the NoMobileOverlay and IntercomClient, plus children", async () => { + test("renders the NoMobileOverlay and IntercomClient, plus children", async () => { const appLayoutElement = await AppLayout({ children:
Hello from children!
, }); diff --git a/apps/web/app/(auth)/verify-email-change/page.tsx b/apps/web/app/(auth)/verify-email-change/page.tsx new file mode 100644 index 000000000000..fb9b6bd63500 --- /dev/null +++ b/apps/web/app/(auth)/verify-email-change/page.tsx @@ -0,0 +1,3 @@ +import { VerifyEmailChangePage } from "@/modules/auth/verify-email-change/page"; + +export default VerifyEmailChangePage; diff --git a/apps/web/app/(redirects)/organizations/[organizationId]/route.ts b/apps/web/app/(redirects)/organizations/[organizationId]/route.ts index 9f81f7cd2dd4..eb0c553ec663 100644 --- a/apps/web/app/(redirects)/organizations/[organizationId]/route.ts +++ b/apps/web/app/(redirects)/organizations/[organizationId]/route.ts @@ -1,12 +1,12 @@ +import { hasOrganizationAccess } from "@/lib/auth"; +import { getEnvironments } from "@/lib/environment/service"; +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getAccessFlags } from "@/lib/membership/utils"; +import { getUserProjects } from "@/lib/project/service"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; import { notFound } from "next/navigation"; -import { hasOrganizationAccess } from "@formbricks/lib/auth"; -import { getEnvironments } from "@formbricks/lib/environment/service"; -import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; -import { getUserProjects } from "@formbricks/lib/project/service"; import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors"; export const GET = async (_: Request, context: { params: Promise<{ organizationId: string }> }) => { diff --git a/apps/web/app/(redirects)/projects/[projectId]/route.ts b/apps/web/app/(redirects)/projects/[projectId]/route.ts index ba4f230426d3..484280799c41 100644 --- a/apps/web/app/(redirects)/projects/[projectId]/route.ts +++ b/apps/web/app/(redirects)/projects/[projectId]/route.ts @@ -1,9 +1,9 @@ +import { hasOrganizationAccess } from "@/lib/auth"; +import { getEnvironments } from "@/lib/environment/service"; +import { getProject } from "@/lib/project/service"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { getServerSession } from "next-auth"; import { notFound, redirect } from "next/navigation"; -import { hasOrganizationAccess } from "@formbricks/lib/auth"; -import { getEnvironments } from "@formbricks/lib/environment/service"; -import { getProject } from "@formbricks/lib/project/service"; import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors"; export const GET = async (_: Request, context: { params: Promise<{ projectId: string }> }) => { diff --git a/apps/web/app/ClientEnvironmentRedirect.test.tsx b/apps/web/app/ClientEnvironmentRedirect.test.tsx new file mode 100644 index 000000000000..dffef885db83 --- /dev/null +++ b/apps/web/app/ClientEnvironmentRedirect.test.tsx @@ -0,0 +1,98 @@ +import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/react"; +import { useRouter } from "next/navigation"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import ClientEnvironmentRedirect from "./ClientEnvironmentRedirect"; + +vi.mock("next/navigation", () => ({ + useRouter: vi.fn(), +})); + +describe("ClientEnvironmentRedirect", () => { + afterEach(() => { + cleanup(); + }); + + test("should redirect to the first environment ID when no last environment exists", () => { + const mockPush = vi.fn(); + vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any); + + // Mock localStorage + const localStorageMock = { + getItem: vi.fn().mockReturnValue(null), + removeItem: vi.fn(), + }; + + Object.defineProperty(window, "localStorage", { + value: localStorageMock, + }); + + render(); + + expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id"); + }); + + test("should redirect to the last environment ID when it exists in localStorage and is valid", () => { + const mockPush = vi.fn(); + vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any); + + // Mock localStorage with a last environment ID + const localStorageMock = { + getItem: vi.fn().mockReturnValue("last-env-id"), + removeItem: vi.fn(), + }; + Object.defineProperty(window, "localStorage", { + value: localStorageMock, + }); + + render(); + + expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS); + expect(mockPush).toHaveBeenCalledWith("/environments/last-env-id"); + }); + + test("should clear invalid environment ID and redirect to default when stored ID is not in user environments", () => { + const mockPush = vi.fn(); + vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any); + + // Mock localStorage with an invalid environment ID + const localStorageMock = { + getItem: vi.fn().mockReturnValue("invalid-env-id"), + removeItem: vi.fn(), + }; + Object.defineProperty(window, "localStorage", { + value: localStorageMock, + }); + + render(); + + expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS); + expect(localStorageMock.removeItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS); + expect(mockPush).toHaveBeenCalledWith("/environments/valid-env-1"); + }); + + test("should update redirect when environment ID prop changes", () => { + const mockPush = vi.fn(); + vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any); + + // Mock localStorage + const localStorageMock = { + getItem: vi.fn().mockReturnValue(null), + removeItem: vi.fn(), + }; + Object.defineProperty(window, "localStorage", { + value: localStorageMock, + }); + + const { rerender } = render(); + expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id"); + + // Clear mock calls + mockPush.mockClear(); + + // Rerender with new environment ID + rerender(); + expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id"); + }); +}); diff --git a/apps/web/app/ClientEnvironmentRedirect.tsx b/apps/web/app/ClientEnvironmentRedirect.tsx index d6a4c50935fc..0c6afbd14754 100644 --- a/apps/web/app/ClientEnvironmentRedirect.tsx +++ b/apps/web/app/ClientEnvironmentRedirect.tsx @@ -1,26 +1,27 @@ "use client"; +import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; -import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage"; interface ClientEnvironmentRedirectProps { - environmentId: string; + userEnvironments: string[]; } -const ClientEnvironmentRedirect = ({ environmentId }: ClientEnvironmentRedirectProps) => { +const ClientEnvironmentRedirect = ({ userEnvironments }: ClientEnvironmentRedirectProps) => { const router = useRouter(); useEffect(() => { const lastEnvironmentId = localStorage.getItem(FORMBRICKS_ENVIRONMENT_ID_LS); - if (lastEnvironmentId) { - // Redirect to the last environment the user was in + if (lastEnvironmentId && userEnvironments.includes(lastEnvironmentId)) { router.push(`/environments/${lastEnvironmentId}`); } else { - router.push(`/environments/${environmentId}`); + // If the last environmentId is not valid, remove it from localStorage and redirect to the provided environmentId + localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS); + router.push(`/environments/${userEnvironments[0]}`); } - }, [environmentId, router]); + }, [userEnvironments, router]); return null; }; diff --git a/apps/web/app/[shortUrlId]/loading.tsx b/apps/web/app/[shortUrlId]/loading.tsx deleted file mode 100644 index f49a67b31cf9..000000000000 --- a/apps/web/app/[shortUrlId]/loading.tsx +++ /dev/null @@ -1,12 +0,0 @@ -const Loading = () => { - return ( -
-
-
-
-
-
- ); -}; - -export default Loading; diff --git a/apps/web/app/[shortUrlId]/page.tsx b/apps/web/app/[shortUrlId]/page.tsx deleted file mode 100644 index 24894cc4ece1..000000000000 --- a/apps/web/app/[shortUrlId]/page.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata"; -import type { Metadata } from "next"; -import { notFound, redirect } from "next/navigation"; -import { getShortUrl } from "@formbricks/lib/shortUrl/service"; -import { logger } from "@formbricks/logger"; -import { TShortUrl, ZShortUrlId } from "@formbricks/types/short-url"; - -export const generateMetadata = async (props): Promise => { - const params = await props.params; - if (!params.shortUrlId) { - notFound(); - } - - if (ZShortUrlId.safeParse(params.shortUrlId).success !== true) { - notFound(); - } - - try { - const shortUrl = await getShortUrl(params.shortUrlId); - - if (!shortUrl) { - notFound(); - } - - const surveyId = shortUrl.url.substring(shortUrl.url.lastIndexOf("/") + 1); - return getMetadataForLinkSurvey(surveyId); - } catch (error) { - notFound(); - } -}; - -const Page = async (props) => { - const params = await props.params; - if (!params.shortUrlId) { - notFound(); - } - - if (ZShortUrlId.safeParse(params.shortUrlId).success !== true) { - // return not found if unable to parse short url id - notFound(); - } - - let shortUrl: TShortUrl | null = null; - - try { - shortUrl = await getShortUrl(params.shortUrlId); - } catch (error) { - logger.error(error, "Could not fetch short url"); - notFound(); - } - - if (shortUrl) { - redirect(shortUrl.url); - } - - notFound(); -}; - -export default Page; diff --git a/apps/web/app/api/(internal)/insights/lib/document.ts b/apps/web/app/api/(internal)/insights/lib/document.ts deleted file mode 100644 index 0b9d6471351b..000000000000 --- a/apps/web/app/api/(internal)/insights/lib/document.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { documentCache } from "@/lib/cache/document"; -import { Prisma } from "@prisma/client"; -import { embed, generateObject } from "ai"; -import { z } from "zod"; -import { prisma } from "@formbricks/database"; -import { embeddingsModel, llmModel } from "@formbricks/lib/aiModels"; -import { validateInputs } from "@formbricks/lib/utils/validate"; -import { - TDocument, - TDocumentCreateInput, - TGenerateDocumentObjectSchema, - ZDocumentCreateInput, - ZGenerateDocumentObjectSchema, -} from "@formbricks/types/documents"; -import { DatabaseError } from "@formbricks/types/errors"; - -export type TCreatedDocument = TDocument & { - isSpam: boolean; - insights: TGenerateDocumentObjectSchema["insights"]; -}; - -export const createDocument = async ( - surveyName: string, - documentInput: TDocumentCreateInput -): Promise => { - validateInputs([surveyName, z.string()], [documentInput, ZDocumentCreateInput]); - - try { - // Generate text embedding - const { embedding } = await embed({ - model: embeddingsModel, - value: documentInput.text, - experimental_telemetry: { isEnabled: true }, - }); - - // generate sentiment and insights - const { object } = await generateObject({ - model: llmModel, - schema: ZGenerateDocumentObjectSchema, - system: `You are an XM researcher. You analyse a survey response (survey name, question headline & user answer) and generate insights from it. The insight title (1-3 words) should concisely answer the question, e.g., "What type of people do you think would most benefit" -> "Developers". You are very objective. For the insights, split the feedback into the smallest parts possible and only use the feedback itself to draw conclusions. You must output at least one insight. Always generate insights and titles in English, regardless of the input language.`, - prompt: `Survey: ${surveyName}\n${documentInput.text}`, - temperature: 0, - experimental_telemetry: { isEnabled: true }, - }); - - const sentiment = object.sentiment; - const isSpam = object.isSpam; - - // create document - const prismaDocument = await prisma.document.create({ - data: { - ...documentInput, - sentiment, - isSpam, - }, - }); - - const document = { - ...prismaDocument, - vector: embedding, - }; - - // update document vector with the embedding - const vectorString = `[${embedding.join(",")}]`; - await prisma.$executeRaw` - UPDATE "Document" - SET "vector" = ${vectorString}::vector(512) - WHERE "id" = ${document.id}; - `; - - documentCache.revalidate({ - id: document.id, - responseId: document.responseId, - questionId: document.questionId, - }); - - return { ...document, insights: object.insights, isSpam }; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - throw error; - } -}; diff --git a/apps/web/app/api/(internal)/insights/lib/insights.ts b/apps/web/app/api/(internal)/insights/lib/insights.ts deleted file mode 100644 index 48df2e03748a..000000000000 --- a/apps/web/app/api/(internal)/insights/lib/insights.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { createDocument } from "@/app/api/(internal)/insights/lib/document"; -import { doesResponseHasAnyOpenTextAnswer } from "@/app/api/(internal)/insights/lib/utils"; -import { documentCache } from "@/lib/cache/document"; -import { insightCache } from "@/lib/cache/insight"; -import { Insight, InsightCategory, Prisma } from "@prisma/client"; -import { embed } from "ai"; -import { prisma } from "@formbricks/database"; -import { embeddingsModel } from "@formbricks/lib/aiModels"; -import { getPromptText } from "@formbricks/lib/utils/ai"; -import { parseRecallInfo } from "@formbricks/lib/utils/recall"; -import { validateInputs } from "@formbricks/lib/utils/validate"; -import { ZId } from "@formbricks/types/common"; -import { TCreatedDocument } from "@formbricks/types/documents"; -import { DatabaseError } from "@formbricks/types/errors"; -import { - TSurvey, - TSurveyQuestionId, - TSurveyQuestionTypeEnum, - ZSurveyQuestions, -} from "@formbricks/types/surveys/types"; -import { TInsightCreateInput, TNearestInsights, ZInsightCreateInput } from "./types"; - -export const generateInsightsForSurveyResponsesConcept = async ( - survey: Pick -): Promise => { - const { id: surveyId, name, environmentId, questions } = survey; - - validateInputs([surveyId, ZId], [environmentId, ZId], [questions, ZSurveyQuestions]); - - try { - const openTextQuestionsWithInsights = questions.filter( - (question) => question.type === TSurveyQuestionTypeEnum.OpenText && question.insightsEnabled - ); - - const openTextQuestionIds = openTextQuestionsWithInsights.map((question) => question.id); - - if (openTextQuestionIds.length === 0) { - return; - } - - // Fetching responses - const batchSize = 200; - let skip = 0; - let rateLimit: number | undefined; - const spillover: { responseId: string; questionId: string; text: string }[] = []; - let allResponsesProcessed = false; - - // Fetch the rate limit once, if not already set - if (rateLimit === undefined) { - const { rawResponse } = await embed({ - model: embeddingsModel, - value: "Test", - experimental_telemetry: { isEnabled: true }, - }); - - const rateLimitHeader = rawResponse?.headers?.["x-ratelimit-remaining-requests"]; - rateLimit = rateLimitHeader ? parseInt(rateLimitHeader, 10) : undefined; - } - - while (!allResponsesProcessed || spillover.length > 0) { - // If there are any spillover documents from the previous iteration, prioritize them - let answersForDocumentCreation = [...spillover]; - spillover.length = 0; // Empty the spillover array after moving contents - - // Fetch new responses only if spillover is empty - if (answersForDocumentCreation.length === 0 && !allResponsesProcessed) { - const responses = await prisma.response.findMany({ - where: { - surveyId, - documents: { - none: {}, - }, - finished: true, - }, - select: { - id: true, - data: true, - variables: true, - contactId: true, - language: true, - }, - take: batchSize, - skip, - }); - - if ( - responses.length === 0 || - (responses.length < batchSize && rateLimit && responses.length < rateLimit) - ) { - allResponsesProcessed = true; // Mark as finished when no more responses are found - } - - const responsesWithOpenTextAnswers = responses.filter((response) => - doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response.data) - ); - - skip += batchSize - responsesWithOpenTextAnswers.length; - - const answersForDocumentCreationPromises = await Promise.all( - responsesWithOpenTextAnswers.map(async (response) => { - const responseEntries = openTextQuestionsWithInsights.map((question) => { - const responseText = response.data[question.id] as string; - if (!responseText) { - return; - } - - const headline = parseRecallInfo( - question.headline[response.language ?? "default"], - response.data, - response.variables - ); - - const text = getPromptText(headline, responseText); - - return { - responseId: response.id, - questionId: question.id, - text, - }; - }); - - return responseEntries; - }) - ); - - const answersForDocumentCreationResult = answersForDocumentCreationPromises.flat(); - answersForDocumentCreationResult.forEach((answer) => { - if (answer) { - answersForDocumentCreation.push(answer); - } - }); - } - - // Process documents only up to the rate limit - if (rateLimit !== undefined && rateLimit < answersForDocumentCreation.length) { - // Push excess documents to the spillover array - spillover.push(...answersForDocumentCreation.slice(rateLimit)); - answersForDocumentCreation = answersForDocumentCreation.slice(0, rateLimit); - } - - const createDocumentPromises = answersForDocumentCreation.map((answer) => { - return createDocument(name, { - environmentId, - surveyId, - responseId: answer.responseId, - questionId: answer.questionId, - text: answer.text, - }); - }); - - const createDocumentResults = await Promise.allSettled(createDocumentPromises); - const fullfilledCreateDocumentResults = createDocumentResults.filter( - (result) => result.status === "fulfilled" - ) as PromiseFulfilledResult[]; - const createdDocuments = fullfilledCreateDocumentResults.filter(Boolean).map((result) => result.value); - - for (const document of createdDocuments) { - if (document) { - const insightPromises: Promise[] = []; - const { insights, isSpam, id, environmentId } = document; - if (!isSpam) { - for (const insight of insights) { - if (typeof insight.title !== "string" || typeof insight.description !== "string") { - throw new Error("Insight title and description must be a string"); - } - - // Create or connect the insight - insightPromises.push(handleInsightAssignments(environmentId, id, insight)); - } - await Promise.allSettled(insightPromises); - } - } - } - - documentCache.revalidate({ - environmentId: environmentId, - surveyId: surveyId, - }); - } - - return; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } -}; - -export const generateInsightsForSurveyResponses = async ( - survey: Pick -): Promise => { - const { id: surveyId, name, environmentId, questions } = survey; - - validateInputs([surveyId, ZId], [environmentId, ZId], [questions, ZSurveyQuestions]); - try { - const openTextQuestionsWithInsights = questions.filter( - (question) => question.type === TSurveyQuestionTypeEnum.OpenText && question.insightsEnabled - ); - - const openTextQuestionIds = openTextQuestionsWithInsights.map((question) => question.id); - - if (openTextQuestionIds.length === 0) { - return; - } - - // Fetching responses - const batchSize = 200; - let skip = 0; - - const totalResponseCount = await prisma.response.count({ - where: { - surveyId, - documents: { - none: {}, - }, - finished: true, - }, - }); - - const pages = Math.ceil(totalResponseCount / batchSize); - - for (let i = 0; i < pages; i++) { - const responses = await prisma.response.findMany({ - where: { - surveyId, - documents: { - none: {}, - }, - finished: true, - }, - select: { - id: true, - data: true, - variables: true, - contactId: true, - language: true, - }, - take: batchSize, - skip, - }); - - const responsesWithOpenTextAnswers = responses.filter((response) => - doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response.data) - ); - - skip += batchSize - responsesWithOpenTextAnswers.length; - - const createDocumentPromises: Promise[] = []; - - for (const response of responsesWithOpenTextAnswers) { - for (const question of openTextQuestionsWithInsights) { - const responseText = response.data[question.id] as string; - if (!responseText) { - continue; - } - - const headline = parseRecallInfo( - question.headline[response.language ?? "default"], - response.data, - response.variables - ); - - const text = getPromptText(headline, responseText); - - const createDocumentPromise = createDocument(name, { - environmentId, - surveyId, - responseId: response.id, - questionId: question.id, - text, - }); - - createDocumentPromises.push(createDocumentPromise); - } - } - - const createdDocuments = (await Promise.all(createDocumentPromises)).filter( - Boolean - ) as TCreatedDocument[]; - - for (const document of createdDocuments) { - if (document) { - const insightPromises: Promise[] = []; - const { insights, isSpam, id, environmentId } = document; - if (!isSpam) { - for (const insight of insights) { - if (typeof insight.title !== "string" || typeof insight.description !== "string") { - throw new Error("Insight title and description must be a string"); - } - - // create or connect the insight - insightPromises.push(handleInsightAssignments(environmentId, id, insight)); - } - await Promise.all(insightPromises); - } - } - } - documentCache.revalidate({ - environmentId: environmentId, - surveyId: surveyId, - }); - } - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } -}; - -export const getQuestionResponseReferenceId = (surveyId: string, questionId: TSurveyQuestionId) => { - return `${surveyId}-${questionId}`; -}; - -export const createInsight = async (insightGroupInput: TInsightCreateInput): Promise => { - validateInputs([insightGroupInput, ZInsightCreateInput]); - - try { - // create document - const { vector, ...data } = insightGroupInput; - const insight = await prisma.insight.create({ - data, - }); - - // update document vector with the embedding - const vectorString = `[${insightGroupInput.vector.join(",")}]`; - await prisma.$executeRaw` - UPDATE "Insight" - SET "vector" = ${vectorString}::vector(512) - WHERE "id" = ${insight.id}; - `; - - insightCache.revalidate({ - id: insight.id, - environmentId: insight.environmentId, - }); - - return insight; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - throw error; - } -}; - -export const handleInsightAssignments = async ( - environmentId: string, - documentId: string, - insight: { - title: string; - description: string; - category: InsightCategory; - } -) => { - try { - // create embedding for insight - const { embedding } = await embed({ - model: embeddingsModel, - value: getInsightVectorText(insight.title, insight.description), - experimental_telemetry: { isEnabled: true }, - }); - // find close insight to merge it with - const nearestInsights = await findNearestInsights(environmentId, embedding, 1, 0.2); - - if (nearestInsights.length > 0) { - // create a documentInsight with this insight - await prisma.documentInsight.create({ - data: { - documentId, - insightId: nearestInsights[0].id, - }, - }); - documentCache.revalidate({ - insightId: nearestInsights[0].id, - }); - } else { - // create new insight and documentInsight - const newInsight = await createInsight({ - environmentId: environmentId, - title: insight.title, - description: insight.description, - category: insight.category ?? "other", - vector: embedding, - }); - // create a documentInsight with this insight - await prisma.documentInsight.create({ - data: { - documentId, - insightId: newInsight.id, - }, - }); - documentCache.revalidate({ - insightId: newInsight.id, - }); - } - } catch (error) { - throw error; - } -}; - -export const findNearestInsights = async ( - environmentId: string, - vector: number[], - limit: number = 5, - threshold: number = 0.5 -): Promise => { - validateInputs([environmentId, ZId]); - // Convert the embedding array to a JSON-like string representation - const vectorString = `[${vector.join(",")}]`; - - // Execute raw SQL query to find nearest neighbors and exclude the vector column - const insights: TNearestInsights[] = await prisma.$queryRaw` - SELECT - id - FROM "Insight" d - WHERE d."environmentId" = ${environmentId} - AND d."vector" <=> ${vectorString}::vector(512) <= ${threshold} - ORDER BY d."vector" <=> ${vectorString}::vector(512) - LIMIT ${limit}; - `; - - return insights; -}; - -export const getInsightVectorText = (title: string, description: string): string => - `${title}: ${description}`; diff --git a/apps/web/app/api/(internal)/insights/lib/types.ts b/apps/web/app/api/(internal)/insights/lib/types.ts deleted file mode 100644 index bde4dd350fc2..000000000000 --- a/apps/web/app/api/(internal)/insights/lib/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Insight } from "@prisma/client"; -import { z } from "zod"; -import { ZInsight } from "@formbricks/database/zod/insights"; - -export const ZInsightCreateInput = ZInsight.pick({ - environmentId: true, - title: true, - description: true, - category: true, -}).extend({ - vector: z.array(z.number()).length(512), -}); - -export type TInsightCreateInput = z.infer; - -export type TNearestInsights = Pick; diff --git a/apps/web/app/api/(internal)/insights/lib/utils.test.ts b/apps/web/app/api/(internal)/insights/lib/utils.test.ts deleted file mode 100644 index f772f17a32e4..000000000000 --- a/apps/web/app/api/(internal)/insights/lib/utils.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { CRON_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; -import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; -import { mockSurveyOutput } from "@formbricks/lib/survey/tests/__mock__/survey.mock"; -import { doesSurveyHasOpenTextQuestion } from "@formbricks/lib/survey/utils"; -import { ResourceNotFoundError } from "@formbricks/types/errors"; -import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; -import { - doesResponseHasAnyOpenTextAnswer, - generateInsightsEnabledForSurveyQuestions, - generateInsightsForSurvey, -} from "./utils"; - -// Mock all dependencies -vi.mock("@formbricks/lib/constants", () => ({ - CRON_SECRET: vi.fn(() => "mocked-cron-secret"), - WEBAPP_URL: "https://mocked-webapp-url.com", -})); - -vi.mock("@formbricks/lib/survey/cache", () => ({ - surveyCache: { - revalidate: vi.fn(), - }, -})); - -vi.mock("@formbricks/lib/survey/service", () => ({ - getSurvey: vi.fn(), - updateSurvey: vi.fn(), -})); - -vi.mock("@formbricks/lib/survey/utils", () => ({ - doesSurveyHasOpenTextQuestion: vi.fn(), -})); - -vi.mock("@formbricks/lib/utils/validate", () => ({ - validateInputs: vi.fn(), -})); - -// Mock global fetch -const mockFetch = vi.fn(); -global.fetch = mockFetch; - -describe("Insights Utils", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe("generateInsightsForSurvey", () => { - test("should call fetch with correct parameters", () => { - const surveyId = "survey-123"; - mockFetch.mockResolvedValueOnce({ ok: true }); - - generateInsightsForSurvey(surveyId); - - expect(mockFetch).toHaveBeenCalledWith(`${WEBAPP_URL}/api/insights`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": CRON_SECRET, - }, - body: JSON.stringify({ - surveyId, - }), - }); - }); - - test("should handle errors and return error object", () => { - const surveyId = "survey-123"; - mockFetch.mockImplementationOnce(() => { - throw new Error("Network error"); - }); - - const result = generateInsightsForSurvey(surveyId); - - expect(result).toEqual({ - ok: false, - error: new Error("Error while generating insights for survey: Network error"), - }); - }); - - test("should throw error if CRON_SECRET is not set", async () => { - // Reset modules to ensure clean state - vi.resetModules(); - - // Mock CRON_SECRET as undefined - vi.doMock("@formbricks/lib/constants", () => ({ - CRON_SECRET: undefined, - WEBAPP_URL: "https://mocked-webapp-url.com", - })); - - // Re-import the utils module to get the mocked CRON_SECRET - const { generateInsightsForSurvey } = await import("./utils"); - - expect(() => generateInsightsForSurvey("survey-123")).toThrow("CRON_SECRET is not set"); - - // Reset modules after test - vi.resetModules(); - }); - }); - - describe("generateInsightsEnabledForSurveyQuestions", () => { - test("should return success=false when survey has no open text questions", async () => { - // Mock data - const surveyId = "survey-123"; - const mockSurvey: TSurvey = { - ...mockSurveyOutput, - type: "link", - segment: null, - displayPercentage: null, - questions: [ - { - id: "cm8cjnse3000009jxf20v91ic", - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: "Question 1" }, - required: true, - choices: [ - { - id: "cm8cjnse3000009jxf20v91ic", - label: { default: "Choice 1" }, - }, - ], - }, - { - id: "cm8cjo19c000109jx6znygc0u", - type: TSurveyQuestionTypeEnum.Rating, - headline: { default: "Question 2" }, - required: true, - scale: "number", - range: 5, - isColorCodingEnabled: false, - }, - ], - }; - - // Setup mocks - vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey); - vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(false); - - // Execute function - const result = await generateInsightsEnabledForSurveyQuestions(surveyId); - - // Verify results - expect(result).toEqual({ success: false }); - expect(updateSurvey).not.toHaveBeenCalled(); - }); - - test("should return success=true when survey is updated with insights enabled", async () => { - vi.clearAllMocks(); - // Mock data - const surveyId = "cm8ckvchx000008lb710n0gdn"; - - // Mock survey with open text questions that have no insightsEnabled property - const mockSurveyWithOpenTextQuestions: TSurvey = { - ...mockSurveyOutput, - id: surveyId, - type: "link", - segment: null, - displayPercentage: null, - questions: [ - { - id: "cm8cjnse3000009jxf20v91ic", - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: "Question 1" }, - required: true, - inputType: "text", - charLimit: {}, - }, - { - id: "cm8cjo19c000109jx6znygc0u", - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: "Question 2" }, - required: true, - inputType: "text", - charLimit: {}, - }, - ], - }; - - // Define the updated survey that should be returned after updateSurvey - const mockUpdatedSurveyWithOpenTextQuestions: TSurvey = { - ...mockSurveyWithOpenTextQuestions, - questions: mockSurveyWithOpenTextQuestions.questions.map((q) => ({ - ...q, - insightsEnabled: true, // Updated property - })), - }; - - // Setup mocks - vi.mocked(getSurvey).mockResolvedValueOnce(mockSurveyWithOpenTextQuestions); - vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true); - vi.mocked(updateSurvey).mockResolvedValueOnce(mockUpdatedSurveyWithOpenTextQuestions); - - // Execute function - const result = await generateInsightsEnabledForSurveyQuestions(surveyId); - - expect(result).toEqual({ - success: true, - survey: mockUpdatedSurveyWithOpenTextQuestions, - }); - }); - - test("should return success=false when all open text questions already have insightsEnabled defined", async () => { - // Mock data - const surveyId = "survey-123"; - const mockSurvey: TSurvey = { - ...mockSurveyOutput, - type: "link", - segment: null, - displayPercentage: null, - questions: [ - { - id: "cm8cjnse3000009jxf20v91ic", - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: "Question 1" }, - required: true, - inputType: "text", - charLimit: {}, - insightsEnabled: true, - }, - { - id: "cm8cjo19c000109jx6znygc0u", - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: "Question 2" }, - required: true, - choices: [ - { - id: "cm8cjnse3000009jxf20v91ic", - label: { default: "Choice 1" }, - }, - ], - }, - ], - }; - - // Setup mocks - vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey); - vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true); - - // Execute function - const result = await generateInsightsEnabledForSurveyQuestions(surveyId); - - // Verify results - expect(result).toEqual({ success: false }); - expect(updateSurvey).not.toHaveBeenCalled(); - }); - - test("should throw ResourceNotFoundError if survey is not found", async () => { - // Setup mocks - vi.mocked(getSurvey).mockResolvedValueOnce(null); - - // Execute and verify function - await expect(generateInsightsEnabledForSurveyQuestions("survey-123")).rejects.toThrow( - new ResourceNotFoundError("Survey", "survey-123") - ); - }); - - test("should throw ResourceNotFoundError if updateSurvey returns null", async () => { - // Mock data - const surveyId = "survey-123"; - const mockSurvey: TSurvey = { - ...mockSurveyOutput, - type: "link", - segment: null, - displayPercentage: null, - questions: [ - { - id: "cm8cjnse3000009jxf20v91ic", - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: "Question 1" }, - required: true, - inputType: "text", - charLimit: {}, - }, - ], - }; - - // Setup mocks - vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey); - vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true); - // Type assertion to handle the null case - vi.mocked(updateSurvey).mockResolvedValueOnce(null as unknown as TSurvey); - - // Execute and verify function - await expect(generateInsightsEnabledForSurveyQuestions(surveyId)).rejects.toThrow( - new ResourceNotFoundError("Survey", surveyId) - ); - }); - - test("should return success=false when no questions have insights enabled after update", async () => { - // Mock data - const surveyId = "survey-123"; - const mockSurvey: TSurvey = { - ...mockSurveyOutput, - type: "link", - segment: null, - displayPercentage: null, - questions: [ - { - id: "cm8cjnse3000009jxf20v91ic", - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: "Question 1" }, - required: true, - inputType: "text", - charLimit: {}, - insightsEnabled: false, - }, - ], - }; - - // Setup mocks - vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey); - vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true); - vi.mocked(updateSurvey).mockResolvedValueOnce(mockSurvey); - - // Execute function - const result = await generateInsightsEnabledForSurveyQuestions(surveyId); - - // Verify results - expect(result).toEqual({ success: false }); - }); - - test("should propagate any errors that occur", async () => { - // Setup mocks - const testError = new Error("Test error"); - vi.mocked(getSurvey).mockRejectedValueOnce(testError); - - // Execute and verify function - await expect(generateInsightsEnabledForSurveyQuestions("survey-123")).rejects.toThrow(testError); - }); - }); - - describe("doesResponseHasAnyOpenTextAnswer", () => { - test("should return true when at least one open text question has an answer", () => { - const openTextQuestionIds = ["q1", "q2", "q3"]; - const response = { - q1: "", - q2: "This is an answer", - q3: "", - q4: "This is not an open text answer", - }; - - const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response); - - expect(result).toBe(true); - }); - - test("should return false when no open text questions have answers", () => { - const openTextQuestionIds = ["q1", "q2", "q3"]; - const response = { - q1: "", - q2: "", - q3: "", - q4: "This is not an open text answer", - }; - - const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response); - - expect(result).toBe(false); - }); - - test("should return false when response does not contain any open text question IDs", () => { - const openTextQuestionIds = ["q1", "q2", "q3"]; - const response = { - q4: "This is not an open text answer", - q5: "Another answer", - }; - - const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response); - - expect(result).toBe(false); - }); - - test("should return false for non-string answers", () => { - const openTextQuestionIds = ["q1", "q2", "q3"]; - const response = { - q1: "", - q2: 123, - q3: true, - } as any; // Use type assertion to handle mixed types in the test - - const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response); - - expect(result).toBe(false); - }); - }); -}); diff --git a/apps/web/app/api/(internal)/insights/lib/utils.ts b/apps/web/app/api/(internal)/insights/lib/utils.ts deleted file mode 100644 index c8feaf1ab22f..000000000000 --- a/apps/web/app/api/(internal)/insights/lib/utils.ts +++ /dev/null @@ -1,101 +0,0 @@ -import "server-only"; -import { CRON_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; -import { surveyCache } from "@formbricks/lib/survey/cache"; -import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; -import { doesSurveyHasOpenTextQuestion } from "@formbricks/lib/survey/utils"; -import { validateInputs } from "@formbricks/lib/utils/validate"; -import { logger } from "@formbricks/logger"; -import { ZId } from "@formbricks/types/common"; -import { ResourceNotFoundError } from "@formbricks/types/errors"; -import { TResponse } from "@formbricks/types/responses"; -import { TSurvey } from "@formbricks/types/surveys/types"; - -export const generateInsightsForSurvey = (surveyId: string) => { - if (!CRON_SECRET) { - throw new Error("CRON_SECRET is not set"); - } - - try { - return fetch(`${WEBAPP_URL}/api/insights`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": CRON_SECRET, - }, - body: JSON.stringify({ - surveyId, - }), - }); - } catch (error) { - return { - ok: false, - error: new Error(`Error while generating insights for survey: ${error.message}`), - }; - } -}; - -export const generateInsightsEnabledForSurveyQuestions = async ( - surveyId: string -): Promise< - | { - success: false; - } - | { - success: true; - survey: Pick; - } -> => { - validateInputs([surveyId, ZId]); - try { - const survey = await getSurvey(surveyId); - - if (!survey) { - throw new ResourceNotFoundError("Survey", surveyId); - } - - if (!doesSurveyHasOpenTextQuestion(survey.questions)) { - return { success: false }; - } - - const openTextQuestions = survey.questions.filter((question) => question.type === "openText"); - - const openTextQuestionsWithoutInsightsEnabled = openTextQuestions.filter( - (question) => question.type === "openText" && typeof question.insightsEnabled === "undefined" - ); - - if (openTextQuestionsWithoutInsightsEnabled.length === 0) { - return { success: false }; - } - - const updatedSurvey = await updateSurvey(survey); - - if (!updatedSurvey) { - throw new ResourceNotFoundError("Survey", surveyId); - } - - const doesSurveyHasInsightsEnabledQuestion = updatedSurvey.questions.some( - (question) => question.type === "openText" && question.insightsEnabled === true - ); - - surveyCache.revalidate({ id: surveyId, environmentId: survey.environmentId }); - - if (doesSurveyHasInsightsEnabledQuestion) { - return { success: true, survey: updatedSurvey }; - } - - return { success: false }; - } catch (error) { - logger.error(error, "Error generating insights for surveys"); - throw error; - } -}; - -export const doesResponseHasAnyOpenTextAnswer = ( - openTextQuestionIds: string[], - response: TResponse["data"] -): boolean => { - return openTextQuestionIds.some((questionId) => { - const answer = response[questionId]; - return typeof answer === "string" && answer.length > 0; - }); -}; diff --git a/apps/web/app/api/(internal)/insights/route.ts b/apps/web/app/api/(internal)/insights/route.ts deleted file mode 100644 index c4a2c8f47da1..000000000000 --- a/apps/web/app/api/(internal)/insights/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -// This function can run for a maximum of 300 seconds -import { generateInsightsForSurveyResponsesConcept } from "@/app/api/(internal)/insights/lib/insights"; -import { responses } from "@/app/lib/api/response"; -import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { headers } from "next/headers"; -import { z } from "zod"; -import { CRON_SECRET } from "@formbricks/lib/constants"; -import { logger } from "@formbricks/logger"; -import { generateInsightsEnabledForSurveyQuestions } from "./lib/utils"; - -export const maxDuration = 300; // This function can run for a maximum of 300 seconds - -const ZGenerateInsightsInput = z.object({ - surveyId: z.string(), -}); - -export const POST = async (request: Request) => { - try { - const requestHeaders = await headers(); - // Check authentication - if (requestHeaders.get("x-api-key") !== CRON_SECRET) { - return responses.notAuthenticatedResponse(); - } - - const jsonInput = await request.json(); - const inputValidation = ZGenerateInsightsInput.safeParse(jsonInput); - - if (!inputValidation.success) { - logger.error({ error: inputValidation.error, url: request.url }, "Error in POST /api/insights"); - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(inputValidation.error), - true - ); - } - - const { surveyId } = inputValidation.data; - - const data = await generateInsightsEnabledForSurveyQuestions(surveyId); - - if (!data.success) { - return responses.successResponse({ message: "No insights enabled questions found" }); - } - - await generateInsightsForSurveyResponsesConcept(data.survey); - - return responses.successResponse({ message: "Insights generated successfully" }); - } catch (error) { - throw error; - } -}; diff --git a/apps/web/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock.ts b/apps/web/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock.ts new file mode 100644 index 000000000000..efaad8de6074 --- /dev/null +++ b/apps/web/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock.ts @@ -0,0 +1,266 @@ +import { TResponse } from "@formbricks/types/responses"; +import { + TSurvey, + TSurveyContactInfoQuestion, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; + +export const mockEndingId1 = "mpkt4n5krsv2ulqetle7b9e7"; +export const mockEndingId2 = "ge0h63htnmgq6kwx1suh9cyi"; + +export const mockResponseEmailFollowUp: TSurvey["followUps"][number] = { + id: "cm9gpuazd0002192z67olbfdt", + createdAt: new Date(), + updatedAt: new Date(), + surveyId: "cm9gptbhg0000192zceq9ayuc", + name: "nice follow up", + trigger: { + type: "response", + properties: null, + }, + action: { + type: "send-email", + properties: { + to: "vjniuob08ggl8dewl0hwed41", + body: '

Hey 👋

Thanks for taking the time to respond, we will be in touch shortly.

Have a great day!

', + from: "noreply@example.com", + replyTo: ["test@user.com"], + subject: "Thanks for your answers!‌‌‍‍‌‌‌‍‌‌‌‍‍‌‌‌‌‌‌‌‍‍‍‌‌‍‌‌‌‍‍‌‍‌‌‌‌‌‌‌‍‌‍‌‌", + attachResponseData: true, + }, + }, +}; + +export const mockEndingFollowUp: TSurvey["followUps"][number] = { + id: "j0g23cue6eih6xs5m0m4cj50", + createdAt: new Date(), + updatedAt: new Date(), + surveyId: "cm9gptbhg0000192zceq9ayuc", + name: "nice follow up", + trigger: { + type: "endings", + properties: { + endingIds: [mockEndingId1], + }, + }, + action: { + type: "send-email", + properties: { + to: "vjniuob08ggl8dewl0hwed41", + body: '

Hey 👋

Thanks for taking the time to respond, we will be in touch shortly.

Have a great day!

', + from: "noreply@example.com", + replyTo: ["test@user.com"], + subject: "Thanks for your answers!‌‌‍‍‌‌‌‍‌‌‌‍‍‌‌‌‌‌‌‌‍‍‍‌‌‍‌‌‌‍‍‌‍‌‌‌‌‌‌‌‍‌‍‌‌", + attachResponseData: true, + }, + }, +}; + +export const mockDirectEmailFollowUp: TSurvey["followUps"][number] = { + id: "yyc5sq1fqofrsyw4viuypeku", + createdAt: new Date(), + updatedAt: new Date(), + surveyId: "cm9gptbhg0000192zceq9ayuc", + name: "nice follow up 1", + trigger: { + type: "response", + properties: null, + }, + action: { + type: "send-email", + properties: { + to: "direct@email.com", + body: '

Hey 👋

Thanks for taking the time to respond, we will be in touch shortly.

Have a great day!

', + from: "noreply@example.com", + replyTo: ["test@user.com"], + subject: "Thanks for your answers!‌‌‍‍‌‌‌‍‌‌‌‍‍‌‌‌‌‌‌‌‍‍‍‌‌‍‌‌‌‍‍‌‍‌‌‌‌‌‌‌‍‌‍‌‌", + attachResponseData: true, + }, + }, +}; + +export const mockFollowUps: TSurvey["followUps"] = [mockDirectEmailFollowUp, mockResponseEmailFollowUp]; + +export const mockSurvey: TSurvey = { + id: "cm9gptbhg0000192zceq9ayuc", + createdAt: new Date(), + updatedAt: new Date(), + name: "Start from scratch‌‌‍‍‌‍‍‌‌‌‌‍‍‍‌‌‌‌‌‌‌‌‍‌‍‌‌", + type: "link", + environmentId: "cm98djl8e000919hpzi6a80zp", + createdBy: "cm98dg3xm000019hpubj39vfi", + status: "inProgress", + welcomeCard: { + html: { + default: "Thanks for providing your feedback - let's go!‌‌‍‍‌‍‍‍‌‌‌‍‍‌‌‌‍‌‌‌‌‌‍‌‍‌‌", + }, + enabled: false, + headline: { + default: "Welcome!‌‌‍‍‌‍‍‍‌‌‌‍‍‌‌‌‌‌‌‌‌‌‍‌‍‌‌", + }, + buttonLabel: { + default: "Next‌‌‍‍‌‍‍‍‌‌‌‍‍‌‌‍‌‌‌‌‌‌‍‌‍‌‌", + }, + timeToFinish: false, + showResponseCount: false, + }, + questions: [ + { + id: "vjniuob08ggl8dewl0hwed41", + type: "openText" as TSurveyQuestionTypeEnum.OpenText, + headline: { + default: "What would you like to know?‌‌‍‍‌‍‍‍‌‌‌‍‍‌‍‍‌‌‌‌‌‌‍‌‍‌‌", + }, + required: true, + charLimit: {}, + inputType: "email", + longAnswer: false, + buttonLabel: { + default: "Next‌‌‍‍‌‍‍‍‌‌‌‍‍‍‌‌‌‌‌‌‌‌‍‌‍‌‌", + }, + placeholder: { + default: "example@email.com", + }, + }, + ], + endings: [ + { + id: "gt1yoaeb5a3istszxqbl08mk", + type: "endScreen", + headline: { + default: "Thank you!‌‌‍‍‌‍‍‍‌‌‌‍‍‌‌‍‍‌‌‌‌‌‍‌‍‌‌", + }, + subheader: { + default: "We appreciate your feedback.‌‌‍‍‌‍‍‍‌‌‌‍‍‌‍‌‌‌‌‌‌‌‍‌‍‌‌", + }, + buttonLink: "https://formbricks.com", + buttonLabel: { + default: "Create your own Survey‌‌‍‍‌‍‍‍‌‌‌‍‍‌‍‌‍‌‌‌‌‌‍‌‍‌‌", + }, + }, + ], + hiddenFields: { + enabled: true, + fieldIds: [], + }, + variables: [], + displayOption: "displayOnce", + recontactDays: null, + displayLimit: null, + autoClose: null, + runOnDate: null, + closeOnDate: null, + delay: 0, + displayPercentage: null, + autoComplete: null, + isVerifyEmailEnabled: false, + isSingleResponsePerEmailEnabled: false, + isBackButtonHidden: false, + recaptcha: null, + projectOverwrites: null, + styling: null, + surveyClosedMessage: null, + singleUse: { + enabled: false, + isEncrypted: true, + }, + pin: null, + showLanguageSwitch: null, + languages: [], + triggers: [], + segment: null, + followUps: mockFollowUps, +}; + +export const mockContactQuestion: TSurveyContactInfoQuestion = { + id: "zyoobxyolyqj17bt1i4ofr37", + type: TSurveyQuestionTypeEnum.ContactInfo, + email: { + show: true, + required: true, + placeholder: { + default: "Email", + }, + }, + phone: { + show: true, + required: true, + placeholder: { + default: "Phone", + }, + }, + company: { + show: true, + required: true, + placeholder: { + default: "Company", + }, + }, + headline: { + default: "Contact Question", + }, + lastName: { + show: true, + required: true, + placeholder: { + default: "Last Name", + }, + }, + required: true, + firstName: { + show: true, + required: true, + placeholder: { + default: "First Name", + }, + }, + buttonLabel: { + default: "Next‌‌‍‍‌‌‌‍‌‌‌‍‍‌‌‌‍‌‌‌‍‍‍‌‌‌‌‌‌‌‌‍‌‍‌‌", + }, + backButtonLabel: { + default: "Back‌‌‍‍‌‌‌‍‌‌‌‍‍‌‌‌‍‌‌‌‍‍‍‌‌‍‌‌‌‌‌‍‌‍‌‌", + }, +}; + +export const mockContactEmailFollowUp: TSurvey["followUps"][number] = { + ...mockResponseEmailFollowUp, + action: { + ...mockResponseEmailFollowUp.action, + properties: { + ...mockResponseEmailFollowUp.action.properties, + to: mockContactQuestion.id, + }, + }, +}; + +export const mockSurveyWithContactQuestion: TSurvey = { + ...mockSurvey, + questions: [mockContactQuestion], + followUps: [mockContactEmailFollowUp], +}; + +export const mockResponse: TResponse = { + id: "response1", + surveyId: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + variables: {}, + language: "en", + data: { + ["vjniuob08ggl8dewl0hwed41"]: "test@example.com", + }, + contact: null, + contactAttributes: {}, + meta: {}, + finished: true, + singleUseId: null, + tags: [], + displayId: null, +}; + +export const mockResponseWithContactQuestion: TResponse = { + ...mockResponse, + data: { + zyoobxyolyqj17bt1i4ofr37: ["test", "user1", "test@user1.com", "99999999999", "sampleCompany"], + }, +}; diff --git a/apps/web/app/api/(internal)/pipeline/lib/documents.ts b/apps/web/app/api/(internal)/pipeline/lib/documents.ts deleted file mode 100644 index 9a0d1ae449e1..000000000000 --- a/apps/web/app/api/(internal)/pipeline/lib/documents.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { handleInsightAssignments } from "@/app/api/(internal)/insights/lib/insights"; -import { documentCache } from "@/lib/cache/document"; -import { Prisma } from "@prisma/client"; -import { embed, generateObject } from "ai"; -import { z } from "zod"; -import { prisma } from "@formbricks/database"; -import { ZInsight } from "@formbricks/database/zod/insights"; -import { embeddingsModel, llmModel } from "@formbricks/lib/aiModels"; -import { validateInputs } from "@formbricks/lib/utils/validate"; -import { - TDocument, - TDocumentCreateInput, - ZDocumentCreateInput, - ZDocumentSentiment, -} from "@formbricks/types/documents"; -import { DatabaseError } from "@formbricks/types/errors"; - -export const createDocumentAndAssignInsight = async ( - surveyName: string, - documentInput: TDocumentCreateInput -): Promise => { - validateInputs([surveyName, z.string()], [documentInput, ZDocumentCreateInput]); - - try { - // Generate text embedding - const { embedding } = await embed({ - model: embeddingsModel, - value: documentInput.text, - experimental_telemetry: { isEnabled: true }, - }); - - // generate sentiment and insights - const { object } = await generateObject({ - model: llmModel, - schema: z.object({ - sentiment: ZDocumentSentiment, - insights: z.array( - z.object({ - title: z.string().describe("insight title, very specific"), - description: z.string().describe("very brief insight description"), - category: ZInsight.shape.category, - }) - ), - isSpam: z.boolean(), - }), - system: `You are an XM researcher. You analyse a survey response (survey name, question headline & user answer) and generate insights from it. The insight title (1-3 words) should concisely answer the question, e.g., "What type of people do you think would most benefit" -> "Developers". You are very objective. For the insights, split the feedback into the smallest parts possible and only use the feedback itself to draw conclusions. You must output at least one insight. Always generate insights and titles in English, regardless of the input language.`, - prompt: `Survey: ${surveyName}\n${documentInput.text}`, - temperature: 0, - experimental_telemetry: { isEnabled: true }, - }); - - const sentiment = object.sentiment; - const isSpam = object.isSpam; - const insights = object.insights; - - // create document - const prismaDocument = await prisma.document.create({ - data: { - ...documentInput, - sentiment, - isSpam, - }, - }); - - const document = { - ...prismaDocument, - vector: embedding, - }; - - // update document vector with the embedding - const vectorString = `[${embedding.join(",")}]`; - await prisma.$executeRaw` - UPDATE "Document" - SET "vector" = ${vectorString}::vector(512) - WHERE "id" = ${document.id}; - `; - - // connect or create the insights - const insightPromises: Promise[] = []; - if (!isSpam) { - for (const insight of insights) { - if (typeof insight.title !== "string" || typeof insight.description !== "string") { - throw new Error("Insight title and description must be a string"); - } - - // create or connect the insight - insightPromises.push(handleInsightAssignments(documentInput.environmentId, document.id, insight)); - } - await Promise.allSettled(insightPromises); - } - - documentCache.revalidate({ - id: document.id, - environmentId: document.environmentId, - surveyId: document.surveyId, - responseId: document.responseId, - questionId: document.questionId, - }); - - return document; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - throw error; - } -}; diff --git a/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.test.ts b/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.test.ts new file mode 100644 index 000000000000..1e0ed34b86b6 --- /dev/null +++ b/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.test.ts @@ -0,0 +1,448 @@ +import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines"; +import { writeData as airtableWriteData } from "@/lib/airtable/service"; +import { writeData as googleSheetWriteData } from "@/lib/googleSheet/service"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { writeData as writeNotionData } from "@/lib/notion/service"; +import { processResponseData } from "@/lib/responses"; +import { writeDataToSlack } from "@/lib/slack/service"; +import { getFormattedDateTimeString } from "@/lib/utils/datetime"; +import { parseRecallInfo } from "@/lib/utils/recall"; +import { truncateText } from "@/lib/utils/strings"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; +import { + TIntegrationAirtable, + TIntegrationAirtableConfig, + TIntegrationAirtableConfigData, + TIntegrationAirtableCredential, +} from "@formbricks/types/integration/airtable"; +import { + TIntegrationGoogleSheets, + TIntegrationGoogleSheetsConfig, + TIntegrationGoogleSheetsConfigData, + TIntegrationGoogleSheetsCredential, +} from "@formbricks/types/integration/google-sheet"; +import { + TIntegrationNotion, + TIntegrationNotionConfigData, + TIntegrationNotionCredential, +} from "@formbricks/types/integration/notion"; +import { + TIntegrationSlack, + TIntegrationSlackConfigData, + TIntegrationSlackCredential, +} from "@formbricks/types/integration/slack"; +import { TResponse, TResponseMeta } from "@formbricks/types/responses"; +import { + TSurvey, + TSurveyOpenTextQuestion, + TSurveyPictureSelectionQuestion, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; +import { handleIntegrations } from "./handleIntegrations"; + +// Mock dependencies +vi.mock("@/lib/airtable/service"); +vi.mock("@/lib/googleSheet/service"); +vi.mock("@/lib/i18n/utils"); +vi.mock("@/lib/notion/service"); +vi.mock("@/lib/responses"); +vi.mock("@/lib/slack/service"); +vi.mock("@/lib/utils/datetime"); +vi.mock("@/lib/utils/recall"); +vi.mock("@/lib/utils/strings"); +vi.mock("@formbricks/logger"); + +// Mock data +const surveyId = "survey1"; +const questionId1 = "q1"; +const questionId2 = "q2"; +const questionId3 = "q3_picture"; +const hiddenFieldId = "hidden1"; +const variableId = "var1"; + +const mockPipelineInput = { + environmentId: "env1", + surveyId: surveyId, + response: { + id: "response1", + createdAt: new Date("2024-01-01T12:00:00Z"), + updatedAt: new Date("2024-01-01T12:00:00Z"), + finished: true, + surveyId: surveyId, + data: { + [questionId1]: "Answer 1", + [questionId2]: ["Choice 1", "Choice 2"], + [questionId3]: ["picChoice1"], + [hiddenFieldId]: "Hidden Value", + }, + meta: { + url: "http://example.com", + source: "web", + userAgent: { + browser: "Chrome", + os: "Mac OS", + device: "Desktop", + }, + country: "USA", + action: "Action Name", + } as TResponseMeta, + personAttributes: {}, + singleUseId: null, + personId: "person1", + tags: [], + variables: { + [variableId]: "Variable Value", + }, + ttc: {}, + } as unknown as TResponse, +} as TPipelineInput; + +const mockSurvey = { + id: surveyId, + name: "Test Survey", + questions: [ + { + id: questionId1, + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Question 1 {{recall:q2}}" }, + required: true, + } as unknown as TSurveyOpenTextQuestion, + { + id: questionId2, + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + headline: { default: "Question 2" }, + required: true, + choices: [ + { id: "choice1", label: { default: "Choice 1" } }, + { id: "choice2", label: { default: "Choice 2" } }, + ], + }, + { + id: questionId3, + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: { default: "Question 3" }, + required: true, + choices: [ + { id: "picChoice1", imageUrl: "http://image.com/1" }, + { id: "picChoice2", imageUrl: "http://image.com/2" }, + ], + } as unknown as TSurveyPictureSelectionQuestion, + ], + hiddenFields: { + enabled: true, + fieldIds: [hiddenFieldId], + }, + variables: [{ id: variableId, name: "Variable 1" } as unknown as TSurvey["variables"][0]], + autoClose: null, + triggers: [], + status: "inProgress", + type: "app", + languages: [], + styling: {}, + segment: null, + recontactDays: null, + autoComplete: null, + closeOnDate: null, + createdAt: new Date(), + updatedAt: new Date(), + displayOption: "displayOnce", + displayPercentage: null, + environmentId: "env1", + singleUse: null, + surveyClosedMessage: null, + pin: null, +} as unknown as TSurvey; + +const mockAirtableIntegration: TIntegrationAirtable = { + id: "int_airtable", + type: "airtable", + environmentId: "env1", + config: { + key: { access_token: "airtable_key" } as TIntegrationAirtableCredential, + data: [ + { + surveyId: surveyId, + questionIds: [questionId1, questionId2], + baseId: "base1", + tableId: "table1", + createdAt: new Date(), + includeHiddenFields: true, + includeMetadata: true, + includeCreatedAt: true, + includeVariables: true, + } as TIntegrationAirtableConfigData, + ], + } as TIntegrationAirtableConfig, +}; + +const mockGoogleSheetsIntegration: TIntegrationGoogleSheets = { + id: "int_gsheets", + type: "googleSheets", + environmentId: "env1", + config: { + key: { refresh_token: "gsheet_key" } as TIntegrationGoogleSheetsCredential, + data: [ + { + surveyId: surveyId, + spreadsheetId: "sheet1", + spreadsheetName: "Sheet Name", + questionIds: [questionId1], + questions: "What is Q1?", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + includeHiddenFields: false, + includeMetadata: false, + includeCreatedAt: false, + includeVariables: false, + } as TIntegrationGoogleSheetsConfigData, + ], + } as TIntegrationGoogleSheetsConfig, +}; + +const mockSlackIntegration: TIntegrationSlack = { + id: "int_slack", + type: "slack", + environmentId: "env1", + config: { + key: { access_token: "slack_key", app_id: "A1" } as TIntegrationSlackCredential, + data: [ + { + surveyId: surveyId, + channelId: "channel1", + channelName: "Channel 1", + questionIds: [questionId1, questionId2, questionId3], + questions: "Q1, Q2, Q3", + createdAt: new Date(), + includeHiddenFields: true, + includeMetadata: true, + includeCreatedAt: true, + includeVariables: true, + } as TIntegrationSlackConfigData, + ], + }, +}; + +const mockNotionIntegration: TIntegrationNotion = { + id: "int_notion", + type: "notion", + environmentId: "env1", + config: { + key: { + access_token: "notion_key", + workspace_name: "ws", + workspace_icon: "", + workspace_id: "w1", + } as TIntegrationNotionCredential, + data: [ + { + surveyId: surveyId, + databaseId: "db1", + databaseName: "DB 1", + mapping: [ + { + question: { id: questionId1, name: "Question 1", type: TSurveyQuestionTypeEnum.OpenText }, + column: { id: "col1", name: "Column 1", type: "rich_text" }, + }, + { + question: { id: questionId3, name: "Question 3", type: TSurveyQuestionTypeEnum.PictureSelection }, + column: { id: "col3", name: "Column 3", type: "url" }, + }, + { + question: { id: "metadata", name: "Metadata", type: "metadata" }, + column: { id: "col_meta", name: "Metadata Col", type: "rich_text" }, + }, + { + question: { id: "createdAt", name: "Created At", type: "createdAt" }, + column: { id: "col_created", name: "Created Col", type: "date" }, + }, + ], + createdAt: new Date(), + } as TIntegrationNotionConfigData, + ], + }, +}; + +describe("handleIntegrations", () => { + beforeEach(() => { + vi.resetAllMocks(); + // Refine mock to explicitly handle string inputs + vi.mocked(processResponseData).mockImplementation((data) => { + if (typeof data === "string") { + return data; // Directly return string inputs + } + // Handle arrays and null/undefined as before + return String(Array.isArray(data) ? data.join(", ") : (data ?? "")); + }); + vi.mocked(getLocalizedValue).mockImplementation((value, _) => value?.default || ""); + vi.mocked(parseRecallInfo).mockImplementation((text, _, __) => text || ""); + vi.mocked(getFormattedDateTimeString).mockReturnValue("2024-01-01 12:00"); + vi.mocked(truncateText).mockImplementation((text, limit) => text.slice(0, limit)); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should call correct handlers for each integration type", async () => { + const integrations = [ + mockAirtableIntegration, + mockGoogleSheetsIntegration, + mockSlackIntegration, + mockNotionIntegration, + ]; + vi.mocked(airtableWriteData).mockResolvedValue(undefined); + vi.mocked(googleSheetWriteData).mockResolvedValue(undefined); + vi.mocked(writeDataToSlack).mockResolvedValue(undefined); + vi.mocked(writeNotionData).mockResolvedValue(undefined); + + await handleIntegrations(integrations, mockPipelineInput, mockSurvey); + + expect(airtableWriteData).toHaveBeenCalledTimes(1); + expect(googleSheetWriteData).toHaveBeenCalledTimes(1); + expect(writeDataToSlack).toHaveBeenCalledTimes(1); + expect(writeNotionData).toHaveBeenCalledTimes(1); + expect(logger.error).not.toHaveBeenCalled(); + }); + + test("should log errors when integration handlers fail", async () => { + const integrations = [mockAirtableIntegration, mockSlackIntegration]; + const airtableError = new Error("Airtable failed"); + const slackError = new Error("Slack failed"); + vi.mocked(airtableWriteData).mockRejectedValue(airtableError); + vi.mocked(writeDataToSlack).mockRejectedValue(slackError); + + await handleIntegrations(integrations, mockPipelineInput, mockSurvey); + + expect(airtableWriteData).toHaveBeenCalledTimes(1); + expect(writeDataToSlack).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith(airtableError, "Error in airtable integration"); + expect(logger.error).toHaveBeenCalledWith(slackError, "Error in slack integration"); + }); + + test("should handle empty integrations array", async () => { + await handleIntegrations([], mockPipelineInput, mockSurvey); + expect(airtableWriteData).not.toHaveBeenCalled(); + expect(googleSheetWriteData).not.toHaveBeenCalled(); + expect(writeDataToSlack).not.toHaveBeenCalled(); + expect(writeNotionData).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + // Test individual handlers by calling the main function with a single integration + describe("Airtable Integration", () => { + test("should call airtableWriteData with correct parameters", async () => { + vi.mocked(airtableWriteData).mockResolvedValue(undefined); + await handleIntegrations([mockAirtableIntegration], mockPipelineInput, mockSurvey); + + expect(airtableWriteData).toHaveBeenCalledTimes(1); + // Adjust expectations for metadata and recalled question + const expectedMetadataString = + "Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name"; + expect(airtableWriteData).toHaveBeenCalledWith( + mockAirtableIntegration.config.key, + mockAirtableIntegration.config.data[0], + [ + [ + "Answer 1", + "Choice 1, Choice 2", + "Hidden Value", + expectedMetadataString, + "Variable Value", + "2024-01-01 12:00", + ], // responses + hidden + meta + var + created + ["Question 1 {{recall:q2}}", "Question 2", hiddenFieldId, "Metadata", "Variable 1", "Created At"], // questions (raw headline for Airtable) + hidden + meta + var + created + ] + ); + }); + + test("should not call airtableWriteData if surveyId does not match", async () => { + const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" }; + await handleIntegrations([mockAirtableIntegration], differentSurveyInput, mockSurvey); + + expect(airtableWriteData).not.toHaveBeenCalled(); + }); + + test("should return error result on failure", async () => { + const error = new Error("Airtable API error"); + vi.mocked(airtableWriteData).mockRejectedValue(error); + await handleIntegrations([mockAirtableIntegration], mockPipelineInput, mockSurvey); + + // Verify error was logged, remove checks on the return value + expect(logger.error).toHaveBeenCalledWith(error, "Error in airtable integration"); + }); + }); + + describe("Google Sheets Integration", () => { + test("should call googleSheetWriteData with correct parameters", async () => { + vi.mocked(googleSheetWriteData).mockResolvedValue(undefined); + await handleIntegrations([mockGoogleSheetsIntegration], mockPipelineInput, mockSurvey); + + expect(googleSheetWriteData).toHaveBeenCalledTimes(1); + // Check that createdAt is converted to Date object + const expectedIntegrationData = structuredClone(mockGoogleSheetsIntegration); + expectedIntegrationData.config.data[0].createdAt = new Date( + mockGoogleSheetsIntegration.config.data[0].createdAt + ); + expect(googleSheetWriteData).toHaveBeenCalledWith( + expectedIntegrationData, + mockGoogleSheetsIntegration.config.data[0].spreadsheetId, + [ + ["Answer 1"], // responses + ["Question 1 {{recall:q2}}"], // questions (raw headline for Google Sheets) + ] + ); + }); + + test("should not call googleSheetWriteData if surveyId does not match", async () => { + const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" }; + await handleIntegrations([mockGoogleSheetsIntegration], differentSurveyInput, mockSurvey); + + expect(googleSheetWriteData).not.toHaveBeenCalled(); + }); + + test("should return error result on failure", async () => { + const error = new Error("Google Sheets API error"); + vi.mocked(googleSheetWriteData).mockRejectedValue(error); + await handleIntegrations([mockGoogleSheetsIntegration], mockPipelineInput, mockSurvey); + + // Verify error was logged, remove checks on the return value + expect(logger.error).toHaveBeenCalledWith(error, "Error in google sheets integration"); + }); + }); + + describe("Slack Integration", () => { + test("should not call writeDataToSlack if surveyId does not match", async () => { + const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" }; + await handleIntegrations([mockSlackIntegration], differentSurveyInput, mockSurvey); + + expect(writeDataToSlack).not.toHaveBeenCalled(); + }); + + test("should return error result on failure", async () => { + const error = new Error("Slack API error"); + vi.mocked(writeDataToSlack).mockRejectedValue(error); + await handleIntegrations([mockSlackIntegration], mockPipelineInput, mockSurvey); + + // Verify error was logged, remove checks on the return value + expect(logger.error).toHaveBeenCalledWith(error, "Error in slack integration"); + }); + }); + + describe("Notion Integration", () => { + test("should not call writeNotionData if surveyId does not match", async () => { + const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" }; + await handleIntegrations([mockNotionIntegration], differentSurveyInput, mockSurvey); + + expect(writeNotionData).not.toHaveBeenCalled(); + }); + + test("should return error result on failure", async () => { + const error = new Error("Notion API error"); + vi.mocked(writeNotionData).mockRejectedValue(error); + await handleIntegrations([mockNotionIntegration], mockPipelineInput, mockSurvey); + + // Verify error was logged, remove checks on the return value + expect(logger.error).toHaveBeenCalledWith(error, "Error in notion integration"); + }); + }); +}); diff --git a/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.ts b/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.ts index 5eea313aaac1..2d11b6389fc2 100644 --- a/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.ts +++ b/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.ts @@ -1,14 +1,14 @@ import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines"; -import { writeData as airtableWriteData } from "@formbricks/lib/airtable/service"; -import { NOTION_RICH_TEXT_LIMIT } from "@formbricks/lib/constants"; -import { writeData } from "@formbricks/lib/googleSheet/service"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { writeData as writeNotionData } from "@formbricks/lib/notion/service"; -import { processResponseData } from "@formbricks/lib/responses"; -import { writeDataToSlack } from "@formbricks/lib/slack/service"; -import { getFormattedDateTimeString } from "@formbricks/lib/utils/datetime"; -import { parseRecallInfo } from "@formbricks/lib/utils/recall"; -import { truncateText } from "@formbricks/lib/utils/strings"; +import { writeData as airtableWriteData } from "@/lib/airtable/service"; +import { NOTION_RICH_TEXT_LIMIT } from "@/lib/constants"; +import { writeData } from "@/lib/googleSheet/service"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { writeData as writeNotionData } from "@/lib/notion/service"; +import { processResponseData } from "@/lib/responses"; +import { writeDataToSlack } from "@/lib/slack/service"; +import { getFormattedDateTimeString } from "@/lib/utils/datetime"; +import { parseRecallInfo } from "@/lib/utils/recall"; +import { truncateText } from "@/lib/utils/strings"; import { logger } from "@formbricks/logger"; import { Result } from "@formbricks/types/error-handlers"; import { TIntegration, TIntegrationType } from "@formbricks/types/integration"; @@ -392,6 +392,19 @@ const getValue = (colType: string, value: string | string[] | Date | number | Re }, ]; } + if (Array.isArray(value)) { + const content = value.join("\n"); + return [ + { + text: { + content: + content.length > NOTION_RICH_TEXT_LIMIT + ? truncateText(content, NOTION_RICH_TEXT_LIMIT) + : content, + }, + }, + ]; + } return [ { text: { diff --git a/apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.ts b/apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.ts deleted file mode 100644 index e2d111511649..000000000000 --- a/apps/web/app/api/(internal)/pipeline/lib/survey-follow-up.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { sendFollowUpEmail } from "@/modules/email"; -import { z } from "zod"; -import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up"; -import { logger } from "@formbricks/logger"; -import { TOrganization } from "@formbricks/types/organizations"; -import { TResponse } from "@formbricks/types/responses"; -import { TSurvey } from "@formbricks/types/surveys/types"; - -type FollowUpResult = { - followUpId: string; - status: "success" | "error" | "skipped"; - error?: string; -}; - -const evaluateFollowUp = async ( - followUpId: string, - followUpAction: TSurveyFollowUpAction, - response: TResponse, - organization: TOrganization -): Promise => { - const { properties } = followUpAction; - const { to, subject, body, replyTo } = properties; - const toValueFromResponse = response.data[to]; - const logoUrl = organization.whitelabel?.logoUrl || ""; - if (!toValueFromResponse) { - throw new Error(`"To" value not found in response data for followup: ${followUpId}`); - } - - if (typeof toValueFromResponse === "string") { - // parse this string to check for an email: - const parsedResult = z.string().email().safeParse(toValueFromResponse); - if (parsedResult.data) { - // send email to this email address - await sendFollowUpEmail(body, subject, parsedResult.data, replyTo, logoUrl); - } else { - throw new Error(`Email address is not valid for followup: ${followUpId}`); - } - } else if (Array.isArray(toValueFromResponse)) { - const emailAddress = toValueFromResponse[2]; - if (!emailAddress) { - throw new Error(`Email address not found in response data for followup: ${followUpId}`); - } - const parsedResult = z.string().email().safeParse(emailAddress); - if (parsedResult.data) { - await sendFollowUpEmail(body, subject, parsedResult.data, replyTo, logoUrl); - } else { - throw new Error(`Email address is not valid for followup: ${followUpId}`); - } - } -}; - -export const sendSurveyFollowUps = async ( - survey: TSurvey, - response: TResponse, - organization: TOrganization -) => { - const followUpPromises = survey.followUps.map(async (followUp): Promise => { - const { trigger } = followUp; - - // Check if we should skip this follow-up based on ending IDs - if (trigger.properties) { - const { endingIds } = trigger.properties; - const { endingId } = response; - - if (!endingId || !endingIds.includes(endingId)) { - return Promise.resolve({ - followUpId: followUp.id, - status: "skipped", - }); - } - } - - return evaluateFollowUp(followUp.id, followUp.action, response, organization) - .then(() => ({ - followUpId: followUp.id, - status: "success" as const, - })) - .catch((error) => ({ - followUpId: followUp.id, - status: "error" as const, - error: error instanceof Error ? error.message : "Something went wrong", - })); - }); - - const followUpResults = await Promise.all(followUpPromises); - - // Log all errors - const errors = followUpResults - .filter((result): result is FollowUpResult & { status: "error" } => result.status === "error") - .map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`); - - if (errors.length > 0) { - logger.error(errors, "Follow-up processing errors"); - } -}; diff --git a/apps/web/app/api/(internal)/pipeline/route.ts b/apps/web/app/api/(internal)/pipeline/route.ts index e98ac5208af1..08ad54628aad 100644 --- a/apps/web/app/api/(internal)/pipeline/route.ts +++ b/apps/web/app/api/(internal)/pipeline/route.ts @@ -1,25 +1,22 @@ -import { createDocumentAndAssignInsight } from "@/app/api/(internal)/pipeline/lib/documents"; -import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up"; import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { webhookCache } from "@/lib/cache/webhook"; -import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils"; +import { CRON_SECRET } from "@/lib/constants"; +import { getIntegrations } from "@/lib/integration/service"; +import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import { getResponseCountBySurveyId } from "@/lib/response/service"; +import { getSurvey, updateSurvey } from "@/lib/survey/service"; +import { convertDatesInObject } from "@/lib/time"; +import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler"; +import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { sendResponseFinishedEmail } from "@/modules/email"; -import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; +import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups"; +import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up"; import { PipelineTriggers, Webhook } from "@prisma/client"; import { headers } from "next/headers"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { CRON_SECRET, IS_AI_CONFIGURED } from "@formbricks/lib/constants"; -import { getIntegrations } from "@formbricks/lib/integration/service"; -import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; -import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; -import { convertDatesInObject } from "@formbricks/lib/time"; -import { getPromptText } from "@formbricks/lib/utils/ai"; -import { parseRecallInfo } from "@formbricks/lib/utils/recall"; import { logger } from "@formbricks/logger"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; import { handleIntegrations } from "./lib/handleIntegrations"; export const POST = async (request: Request) => { @@ -50,26 +47,21 @@ export const POST = async (request: Request) => { const organization = await getOrganizationByEnvironmentId(environmentId); if (!organization) { - throw new Error("Organization not found"); + throw new ResourceNotFoundError("Organization", "Organization not found"); } // Fetch webhooks - const getWebhooksForPipeline = cache( - async (environmentId: string, event: PipelineTriggers, surveyId: string) => { - const webhooks = await prisma.webhook.findMany({ - where: { - environmentId, - triggers: { has: event }, - OR: [{ surveyIds: { has: surveyId } }, { surveyIds: { isEmpty: true } }], - }, - }); - return webhooks; - }, - [`getWebhooksForPipeline-${environmentId}-${event}-${surveyId}`], - { - tags: [webhookCache.tag.byEnvironmentId(environmentId)], - } - ); + const getWebhooksForPipeline = async (environmentId: string, event: PipelineTriggers, surveyId: string) => { + const webhooks = await prisma.webhook.findMany({ + where: { + environmentId, + triggers: { has: event }, + OR: [{ surveyIds: { has: surveyId } }, { surveyIds: { isEmpty: true } }], + }, + }); + return webhooks; + }; + const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId); // Prepare webhook and email promises @@ -167,11 +159,15 @@ export const POST = async (request: Request) => { select: { email: true, locale: true }, }); - // send follow up emails - const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan); - - if (surveyFollowUpsPermission) { - await sendSurveyFollowUps(survey, response, organization); + if (survey.followUps?.length > 0) { + // send follow up emails + const followUpsResult = await sendFollowUpsForResponse(response.id); + if (!followUpsResult.ok) { + const { error: followUpsError } = followUpsResult; + if (followUpsError.code !== FollowUpSendError.FOLLOW_UP_NOT_ALLOWED) { + logger.error({ error: followUpsError }, `Failed to send follow-up emails for survey ${surveyId}`); + } + } } const emailPromises = usersWithNotifications.map((user) => @@ -185,10 +181,33 @@ export const POST = async (request: Request) => { // Update survey status if necessary if (survey.autoComplete && responseCount >= survey.autoComplete) { - await updateSurvey({ - ...survey, - status: "completed", - }); + let logStatus: TAuditStatus = "success"; + + try { + await updateSurvey({ + ...survey, + status: "completed", + }); + } catch (error) { + logStatus = "failure"; + logger.error( + { error, url: request.url, surveyId }, + `Failed to update survey ${surveyId} status to completed` + ); + } finally { + await queueAuditEvent({ + status: logStatus, + action: "updated", + targetType: "survey", + userId: UNKNOWN_DATA, + userType: "system", + targetId: survey.id, + organizationId: organization.id, + newObject: { + status: "completed", + }, + }); + } } // Await webhook and email promises with allSettled to prevent early rejection @@ -198,50 +217,6 @@ export const POST = async (request: Request) => { logger.error({ error: result.reason, url: request.url }, "Promise rejected"); } }); - - // generate embeddings for all open text question responses for all paid plans - const hasSurveyOpenTextQuestions = survey.questions.some((question) => question.type === "openText"); - if (hasSurveyOpenTextQuestions) { - const isAICofigured = IS_AI_CONFIGURED; - if (hasSurveyOpenTextQuestions && isAICofigured) { - const isAIEnabled = await getIsAIEnabled({ - isAIEnabled: organization.isAIEnabled, - billing: organization.billing, - }); - - if (isAIEnabled) { - for (const question of survey.questions) { - if (question.type === "openText" && question.insightsEnabled) { - const isQuestionAnswered = - response.data[question.id] !== undefined && response.data[question.id] !== ""; - if (!isQuestionAnswered) { - continue; - } - - const headline = parseRecallInfo( - question.headline[response.language ?? "default"], - response.data, - response.variables - ); - - const text = getPromptText(headline, response.data[question.id] as string); - // TODO: check if subheadline gives more context and better embeddings - try { - await createDocumentAndAssignInsight(survey.name, { - environmentId, - surveyId, - responseId: response.id, - questionId: question.id, - text, - }); - } catch (e) { - logger.error({ error: e, url: request.url }, "Error creating document and assigning insight"); - } - } - } - } - } - } } else { // Await webhook promises if no emails are sent (with allSettled to prevent early rejection) const results = await Promise.allSettled(webhookPromises); diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts index 80275570a177..b0a1e8403d54 100644 --- a/apps/web/app/api/auth/[...nextauth]/route.ts +++ b/apps/web/app/api/auth/[...nextauth]/route.ts @@ -1,8 +1,140 @@ -import { authOptions } from "@/modules/auth/lib/authOptions"; +import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants"; +import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions"; +import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler"; +import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; +import * as Sentry from "@sentry/nextjs"; import NextAuth from "next-auth"; +import { logger } from "@formbricks/logger"; export const fetchCache = "force-no-store"; -const handler = NextAuth(authOptions); +const handler = async (req: Request, ctx: any) => { + const eventId = req.headers.get("x-request-id") ?? undefined; + + const authOptions = { + ...baseAuthOptions, + callbacks: { + ...baseAuthOptions.callbacks, + async jwt(params: any) { + let result: any = params.token; + let error: any = undefined; + + try { + if (baseAuthOptions.callbacks?.jwt) { + result = await baseAuthOptions.callbacks.jwt(params); + } + } catch (err) { + error = err; + logger.withContext({ eventId, err }).error("JWT callback failed"); + + if (SENTRY_DSN && IS_PRODUCTION) { + Sentry.captureException(err); + } + } + + // Audit JWT operations (token refresh, updates) + if (params.trigger && params.token?.profile?.id) { + const status: TAuditStatus = error ? "failure" : "success"; + const auditLog = { + action: "jwtTokenCreated" as const, + targetType: "user" as const, + userId: params.token.profile.id, + targetId: params.token.profile.id, + organizationId: UNKNOWN_DATA, + status, + userType: "user" as const, + newObject: { trigger: params.trigger, tokenType: "jwt" }, + ...(error ? { eventId } : {}), + }; + + queueAuditEventBackground(auditLog); + } + + if (error) throw error; + return result; + }, + async session(params: any) { + let result: any = params.session; + let error: any = undefined; + + try { + if (baseAuthOptions.callbacks?.session) { + result = await baseAuthOptions.callbacks.session(params); + } + } catch (err) { + error = err; + logger.withContext({ eventId, err }).error("Session callback failed"); + + if (SENTRY_DSN && IS_PRODUCTION) { + Sentry.captureException(err); + } + } + + if (error) throw error; + return result; + }, + async signIn({ user, account, profile, email, credentials }) { + let result: boolean | string = true; + let error: any = undefined; + let authMethod = "unknown"; + + try { + if (baseAuthOptions.callbacks?.signIn) { + result = await baseAuthOptions.callbacks.signIn({ + user, + account, + profile, + email, + credentials, + }); + } + + // Determine authentication method for more detailed logging + if (account?.provider === "credentials") { + authMethod = "password"; + } else if (account?.provider === "token") { + authMethod = "email_verification"; + } else if (account?.provider && account.provider !== "credentials") { + authMethod = "sso"; + } + } catch (err) { + error = err; + result = false; + + logger.withContext({ eventId, err }).error("User sign-in failed"); + + if (SENTRY_DSN && IS_PRODUCTION) { + Sentry.captureException(err); + } + } + + const status: TAuditStatus = result === false ? "failure" : "success"; + const auditLog = { + action: "signedIn" as const, + targetType: "user" as const, + userId: user?.id ?? UNKNOWN_DATA, + targetId: user?.id ?? UNKNOWN_DATA, + organizationId: UNKNOWN_DATA, + status, + userType: "user" as const, + newObject: { + ...user, + authMethod, + provider: account?.provider, + ...(error ? { errorMessage: error.message } : {}), + }, + ...(status === "failure" ? { eventId } : {}), + }; + + queueAuditEventBackground(auditLog); + + if (error) throw error; + return result; + }, + }, + }; + + return NextAuth(authOptions)(req, ctx); +}; export { handler as GET, handler as POST }; diff --git a/apps/web/app/api/cron/ping/route.ts b/apps/web/app/api/cron/ping/route.ts index 43465af7de43..b6ad114982d3 100644 --- a/apps/web/app/api/cron/ping/route.ts +++ b/apps/web/app/api/cron/ping/route.ts @@ -1,9 +1,10 @@ import { responses } from "@/app/lib/api/response"; +import { CRON_SECRET } from "@/lib/constants"; +import { env } from "@/lib/env"; +import { captureTelemetry } from "@/lib/telemetry"; import packageJson from "@/package.json"; import { headers } from "next/headers"; import { prisma } from "@formbricks/database"; -import { CRON_SECRET } from "@formbricks/lib/constants"; -import { captureTelemetry } from "@formbricks/lib/telemetry"; export const POST = async () => { const headersList = await headers(); @@ -13,6 +14,10 @@ export const POST = async () => { return responses.notAuthenticatedResponse(); } + if (env.TELEMETRY_DISABLED === "1") { + return responses.successResponse({}, true); + } + const [surveyCount, responseCount, userCount] = await Promise.all([ prisma.survey.count(), prisma.response.count(), diff --git a/apps/web/app/api/cron/survey-status/route.ts b/apps/web/app/api/cron/survey-status/route.ts index 4faefccfc006..c1a862f6c6fa 100644 --- a/apps/web/app/api/cron/survey-status/route.ts +++ b/apps/web/app/api/cron/survey-status/route.ts @@ -1,8 +1,7 @@ import { responses } from "@/app/lib/api/response"; +import { CRON_SECRET } from "@/lib/constants"; import { headers } from "next/headers"; import { prisma } from "@formbricks/database"; -import { CRON_SECRET } from "@formbricks/lib/constants"; -import { surveyCache } from "@formbricks/lib/survey/cache"; export const POST = async () => { const headersList = await headers(); @@ -66,15 +65,6 @@ export const POST = async () => { }); } - const updatedSurveys = [...surveysToClose, ...scheduledSurveys]; - - for (const survey of updatedSurveys) { - surveyCache.revalidate({ - id: survey.id, - environmentId: survey.environmentId, - }); - } - return responses.successResponse({ message: `Updated ${surveysToClose.length} surveys to completed and ${scheduledSurveys.length} surveys to inProgress.`, }); diff --git a/apps/web/app/api/cron/weekly-summary/lib/notificationResponse.ts b/apps/web/app/api/cron/weekly-summary/lib/notificationResponse.ts deleted file mode 100644 index 69f2caabb712..000000000000 --- a/apps/web/app/api/cron/weekly-summary/lib/notificationResponse.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { convertResponseValue } from "@formbricks/lib/responses"; -import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall"; -import { TSurvey } from "@formbricks/types/surveys/types"; -import { - TWeeklyEmailResponseData, - TWeeklySummaryEnvironmentData, - TWeeklySummaryNotificationDataSurvey, - TWeeklySummaryNotificationResponse, - TWeeklySummarySurveyResponseData, -} from "@formbricks/types/weekly-summary"; - -export const getNotificationResponse = ( - environment: TWeeklySummaryEnvironmentData, - projectName: string -): TWeeklySummaryNotificationResponse => { - const insights = { - totalCompletedResponses: 0, - totalDisplays: 0, - totalResponses: 0, - completionRate: 0, - numLiveSurvey: 0, - }; - - const surveys: TWeeklySummaryNotificationDataSurvey[] = []; - // iterate through the surveys and calculate the overall insights - for (const survey of environment.surveys) { - const parsedSurvey = replaceHeadlineRecall(survey as unknown as TSurvey, "default") as TSurvey & { - responses: TWeeklyEmailResponseData[]; - }; - const surveyData: TWeeklySummaryNotificationDataSurvey = { - id: parsedSurvey.id, - name: parsedSurvey.name, - status: parsedSurvey.status, - responseCount: parsedSurvey.responses.length, - responses: [], - }; - // iterate through the responses and calculate the survey insights - for (const response of parsedSurvey.responses) { - // only take the first 3 responses - if (surveyData.responses.length >= 3) { - break; - } - const surveyResponses: TWeeklySummarySurveyResponseData[] = []; - for (const question of parsedSurvey.questions) { - const headline = question.headline; - const responseValue = convertResponseValue(response.data[question.id], question); - const surveyResponse: TWeeklySummarySurveyResponseData = { - headline: getLocalizedValue(headline, "default"), - responseValue, - questionType: question.type, - }; - surveyResponses.push(surveyResponse); - } - surveyData.responses = surveyResponses; - } - surveys.push(surveyData); - // calculate the overall insights - if (survey.status == "inProgress") { - insights.numLiveSurvey += 1; - } - insights.totalCompletedResponses += survey.responses.filter((r) => r.finished).length; - insights.totalDisplays += survey.displays.length; - insights.totalResponses += survey.responses.length; - insights.completionRate = Math.round((insights.totalCompletedResponses / insights.totalResponses) * 100); - } - // build the notification response needed for the emails - const lastWeekDate = new Date(); - lastWeekDate.setDate(lastWeekDate.getDate() - 7); - return { - environmentId: environment.id, - currentDate: new Date(), - lastWeekDate, - projectName: projectName, - surveys, - insights, - }; -}; diff --git a/apps/web/app/api/cron/weekly-summary/lib/organization.ts b/apps/web/app/api/cron/weekly-summary/lib/organization.ts deleted file mode 100644 index bae8b74bb5b8..000000000000 --- a/apps/web/app/api/cron/weekly-summary/lib/organization.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { prisma } from "@formbricks/database"; - -export const getOrganizationIds = async (): Promise => { - const organizations = await prisma.organization.findMany({ - select: { - id: true, - }, - }); - return organizations.map((organization) => organization.id); -}; diff --git a/apps/web/app/api/cron/weekly-summary/lib/project.ts b/apps/web/app/api/cron/weekly-summary/lib/project.ts deleted file mode 100644 index d8c51561e0a9..000000000000 --- a/apps/web/app/api/cron/weekly-summary/lib/project.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { prisma } from "@formbricks/database"; -import { TWeeklySummaryProjectData } from "@formbricks/types/weekly-summary"; - -export const getProjectsByOrganizationId = async ( - organizationId: string -): Promise => { - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - - return await prisma.project.findMany({ - where: { - organizationId: organizationId, - }, - select: { - id: true, - name: true, - environments: { - where: { - type: "production", - }, - select: { - id: true, - surveys: { - where: { - NOT: { - AND: [ - { status: "completed" }, - { - responses: { - none: { - createdAt: { - gte: sevenDaysAgo, - }, - }, - }, - }, - ], - }, - status: { - not: "draft", - }, - }, - select: { - id: true, - name: true, - questions: true, - status: true, - responses: { - where: { - createdAt: { - gte: sevenDaysAgo, - }, - }, - select: { - id: true, - createdAt: true, - updatedAt: true, - finished: true, - data: true, - }, - orderBy: { - createdAt: "desc", - }, - }, - displays: { - where: { - createdAt: { - gte: sevenDaysAgo, - }, - }, - select: { - id: true, - }, - }, - hiddenFields: true, - }, - }, - attributeKeys: { - select: { - id: true, - createdAt: true, - updatedAt: true, - name: true, - description: true, - type: true, - environmentId: true, - key: true, - isUnique: true, - }, - }, - }, - }, - organization: { - select: { - memberships: { - select: { - user: { - select: { - id: true, - email: true, - notificationSettings: true, - locale: true, - }, - }, - }, - }, - }, - }, - }, - }); -}; diff --git a/apps/web/app/api/cron/weekly-summary/route.ts b/apps/web/app/api/cron/weekly-summary/route.ts deleted file mode 100644 index c5f22cc2c1fc..000000000000 --- a/apps/web/app/api/cron/weekly-summary/route.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { responses } from "@/app/lib/api/response"; -import { sendNoLiveSurveyNotificationEmail, sendWeeklySummaryNotificationEmail } from "@/modules/email"; -import { headers } from "next/headers"; -import { CRON_SECRET } from "@formbricks/lib/constants"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; -import { getNotificationResponse } from "./lib/notificationResponse"; -import { getOrganizationIds } from "./lib/organization"; -import { getProjectsByOrganizationId } from "./lib/project"; - -const BATCH_SIZE = 500; - -export const POST = async (): Promise => { - const headersList = await headers(); - // Check authentication - if (headersList.get("x-api-key") !== CRON_SECRET) { - return responses.notAuthenticatedResponse(); - } - - const emailSendingPromises: Promise[] = []; - - // Fetch all organization IDs - const organizationIds = await getOrganizationIds(); - - // Paginate through organizations - for (let i = 0; i < organizationIds.length; i += BATCH_SIZE) { - const batchedOrganizationIds = organizationIds.slice(i, i + BATCH_SIZE); - // Fetch projects for batched organizations asynchronously - const batchedProjectsPromises = batchedOrganizationIds.map((organizationId) => - getProjectsByOrganizationId(organizationId) - ); - - const batchedProjects = await Promise.all(batchedProjectsPromises); - for (const projects of batchedProjects) { - for (const project of projects) { - const organizationMembers = project.organization.memberships; - const organizationMembersWithNotificationEnabled = organizationMembers.filter( - (member) => - member.user.notificationSettings?.weeklySummary && - member.user.notificationSettings.weeklySummary[project.id] - ); - - if (organizationMembersWithNotificationEnabled.length === 0) continue; - - const notificationResponse = getNotificationResponse(project.environments[0], project.name); - - if (notificationResponse.insights.numLiveSurvey === 0) { - for (const organizationMember of organizationMembersWithNotificationEnabled) { - if (await hasUserEnvironmentAccess(organizationMember.user.id, project.environments[0].id)) { - emailSendingPromises.push( - sendNoLiveSurveyNotificationEmail(organizationMember.user.email, notificationResponse) - ); - } - } - continue; - } - - for (const organizationMember of organizationMembersWithNotificationEnabled) { - if (await hasUserEnvironmentAccess(organizationMember.user.id, project.environments[0].id)) { - emailSendingPromises.push( - sendWeeklySummaryNotificationEmail(organizationMember.user.email, notificationResponse) - ); - } - } - } - } - } - - await Promise.all(emailSendingPromises); - return responses.successResponse({}, true); -}; diff --git a/apps/web/app/api/google-sheet/callback/route.ts b/apps/web/app/api/google-sheet/callback/route.ts index 3220e05c4586..1fa6d45aac3e 100644 --- a/apps/web/app/api/google-sheet/callback/route.ts +++ b/apps/web/app/api/google-sheet/callback/route.ts @@ -1,12 +1,12 @@ import { responses } from "@/app/lib/api/response"; -import { google } from "googleapis"; import { GOOGLE_SHEETS_CLIENT_ID, GOOGLE_SHEETS_CLIENT_SECRET, GOOGLE_SHEETS_REDIRECT_URL, WEBAPP_URL, -} from "@formbricks/lib/constants"; -import { createOrUpdateIntegration } from "@formbricks/lib/integration/service"; +} from "@/lib/constants"; +import { createOrUpdateIntegration } from "@/lib/integration/service"; +import { google } from "googleapis"; export const GET = async (req: Request) => { const url = req.url; diff --git a/apps/web/app/api/google-sheet/route.ts b/apps/web/app/api/google-sheet/route.ts index 72b6310c1f15..aeee2a666bb8 100644 --- a/apps/web/app/api/google-sheet/route.ts +++ b/apps/web/app/api/google-sheet/route.ts @@ -1,14 +1,14 @@ import { responses } from "@/app/lib/api/response"; -import { authOptions } from "@/modules/auth/lib/authOptions"; -import { google } from "googleapis"; -import { getServerSession } from "next-auth"; -import { NextRequest } from "next/server"; import { GOOGLE_SHEETS_CLIENT_ID, GOOGLE_SHEETS_CLIENT_SECRET, GOOGLE_SHEETS_REDIRECT_URL, -} from "@formbricks/lib/constants"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +} from "@/lib/constants"; +import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; +import { authOptions } from "@/modules/auth/lib/authOptions"; +import { google } from "googleapis"; +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; const scopes = [ "https://www.googleapis.com/auth/spreadsheets", diff --git a/apps/web/app/api/v1/auth.test.ts b/apps/web/app/api/v1/auth.test.ts index 6659e5583aa0..82dc5dd7c0dc 100644 --- a/apps/web/app/api/v1/auth.test.ts +++ b/apps/web/app/api/v1/auth.test.ts @@ -1,7 +1,7 @@ import { hashApiKey } from "@/modules/api/v2/management/lib/utils"; import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth"; import { authenticateRequest } from "./auth"; @@ -20,7 +20,7 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({ })); describe("getApiKeyWithPermissions", () => { - it("should return API key data with permissions when valid key is provided", async () => { + test("returns API key data with permissions when valid key is provided", async () => { const mockApiKeyData = { id: "api-key-id", organizationId: "org-id", @@ -51,7 +51,7 @@ describe("getApiKeyWithPermissions", () => { }); }); - it("should return null when API key is not found", async () => { + test("returns null when API key is not found", async () => { vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null); const result = await getApiKeyWithPermissions("invalid-key"); @@ -85,31 +85,31 @@ describe("hasPermission", () => { }, ]; - it("should return true for manage permission with any method", () => { + test("returns true for manage permission with any method", () => { expect(hasPermission(permissions, "env-1", "GET")).toBe(true); expect(hasPermission(permissions, "env-1", "POST")).toBe(true); expect(hasPermission(permissions, "env-1", "DELETE")).toBe(true); }); - it("should handle write permission correctly", () => { + test("handles write permission correctly", () => { expect(hasPermission(permissions, "env-2", "GET")).toBe(true); expect(hasPermission(permissions, "env-2", "POST")).toBe(true); expect(hasPermission(permissions, "env-2", "DELETE")).toBe(false); }); - it("should handle read permission correctly", () => { + test("handles read permission correctly", () => { expect(hasPermission(permissions, "env-3", "GET")).toBe(true); expect(hasPermission(permissions, "env-3", "POST")).toBe(false); expect(hasPermission(permissions, "env-3", "DELETE")).toBe(false); }); - it("should return false for non-existent environment", () => { + test("returns false for non-existent environment", () => { expect(hasPermission(permissions, "env-4", "GET")).toBe(false); }); }); describe("authenticateRequest", () => { - it("should return authentication data for valid API key", async () => { + test("should return authentication data for valid API key", async () => { const request = new Request("http://localhost", { headers: { "x-api-key": "valid-api-key" }, }); @@ -159,13 +159,13 @@ describe("authenticateRequest", () => { }); }); - it("should return null when no API key is provided", async () => { + test("returns null when no API key is provided", async () => { const request = new Request("http://localhost"); const result = await authenticateRequest(request); expect(result).toBeNull(); }); - it("should return null when API key is invalid", async () => { + test("returns null when API key is invalid", async () => { const request = new Request("http://localhost", { headers: { "x-api-key": "invalid-api-key" }, }); diff --git a/apps/web/app/api/v1/auth.ts b/apps/web/app/api/v1/auth.ts index 449f22355cf0..d0c70c9f5242 100644 --- a/apps/web/app/api/v1/auth.ts +++ b/apps/web/app/api/v1/auth.ts @@ -1,10 +1,11 @@ import { responses } from "@/app/lib/api/response"; import { hashApiKey } from "@/modules/api/v2/management/lib/utils"; import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key"; +import { NextRequest } from "next/server"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; -export const authenticateRequest = async (request: Request): Promise => { +export const authenticateRequest = async (request: NextRequest): Promise => { const apiKey = request.headers.get("x-api-key"); if (!apiKey) return null; diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts index 306a488ae569..eb26991a3a4f 100644 --- a/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts @@ -5,193 +5,197 @@ import { getSyncSurveys } from "@/app/api/v1/client/[environmentId]/app/sync/lib import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { contactCache } from "@/lib/cache/contact"; -import { NextRequest, userAgent } from "next/server"; -import { prisma } from "@formbricks/database"; -import { getActionClasses } from "@formbricks/lib/actionClass/service"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; -import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service"; +import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { getActionClasses } from "@/lib/actionClass/service"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { getEnvironment, updateEnvironment } from "@/lib/environment/service"; import { getMonthlyOrganizationResponseCount, getOrganizationByEnvironmentId, -} from "@formbricks/lib/organization/service"; +} from "@/lib/organization/service"; import { capturePosthogEnvironmentEvent, sendPlanLimitsReachedEventToPosthogWeekly, -} from "@formbricks/lib/posthogServer"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; -import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants"; +} from "@/lib/posthogServer"; +import { getProjectByEnvironmentId } from "@/lib/project/service"; +import { COLOR_DEFAULTS } from "@/lib/styling/constants"; +import { NextRequest, userAgent } from "next/server"; +import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; -import { ZJsPeopleUserIdInput } from "@formbricks/types/js"; +import { TJsPeopleUserIdInput, ZJsPeopleUserIdInput } from "@formbricks/types/js"; import { TSurvey } from "@formbricks/types/surveys/types"; -export const OPTIONS = async (): Promise => { - return responses.successResponse({}, true); -}; - -export const GET = async ( - request: NextRequest, - props: { - params: Promise<{ - environmentId: string; - userId: string; - }>; - } -): Promise => { - const params = await props.params; - try { - const { device } = userAgent(request); - - // validate using zod - const inputValidation = ZJsPeopleUserIdInput.safeParse({ - environmentId: params.environmentId, - userId: params.userId, - }); - if (!inputValidation.success) { - return responses.badRequestResponse( +const validateInput = ( + environmentId: string, + userId: string +): { isValid: true; data: TJsPeopleUserIdInput } | { isValid: false; error: Response } => { + const inputValidation = ZJsPeopleUserIdInput.safeParse({ environmentId, userId }); + if (!inputValidation.success) { + return { + isValid: false, + error: responses.badRequestResponse( "Fields are missing or incorrectly formatted", transformErrorToDetails(inputValidation.error), true - ); - } + ), + }; + } + return { isValid: true, data: inputValidation.data }; +}; - const { environmentId, userId } = inputValidation.data; +const checkResponseLimit = async (environmentId: string): Promise => { + if (!IS_FORMBRICKS_CLOUD) return false; - const environment = await getEnvironment(environmentId); - if (!environment) { - throw new Error("Environment does not exist"); - } + const organization = await getOrganizationByEnvironmentId(environmentId); + if (!organization) { + logger.error({ environmentId }, "Organization does not exist"); - const project = await getProjectByEnvironmentId(environmentId); + // fail closed if the organization does not exist + return true; + } - if (!project) { - throw new Error("Project not found"); + const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id); + const monthlyResponseLimit = organization.billing.limits.monthly.responses; + const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit; + + if (isLimitReached) { + try { + await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, { + plan: organization.billing.plan, + limits: { + projects: null, + monthly: { responses: monthlyResponseLimit, miu: null }, + }, + }); + } catch (error) { + logger.error({ error }, `Error sending plan limits reached event to Posthog`); } + } - if (!environment.appSetupCompleted) { - await Promise.all([ - updateEnvironment(environment.id, { appSetupCompleted: true }), - capturePosthogEnvironmentEvent(environmentId, "app setup completed"), - ]); - } + return isLimitReached; +}; - // check organization subscriptions - const organization = await getOrganizationByEnvironmentId(environmentId); +export const OPTIONS = async (): Promise => { + return responses.successResponse({}, true); +}; - if (!organization) { - throw new Error("Organization does not exist"); - } +export const GET = withV1ApiWrapper({ + handler: async ({ + req, + props, + }: { + req: NextRequest; + props: { params: Promise<{ environmentId: string; userId: string }> }; + }) => { + const params = await props.params; + try { + const { device } = userAgent(req); + + // validate using zod + const validation = validateInput(params.environmentId, params.userId); + if (!validation.isValid) { + return { response: validation.error }; + } - // check if response limit is reached - let isAppSurveyResponseLimitReached = false; - if (IS_FORMBRICKS_CLOUD) { - const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id); - const monthlyResponseLimit = organization.billing.limits.monthly.responses; - - isAppSurveyResponseLimitReached = - monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit; - - if (isAppSurveyResponseLimitReached) { - try { - await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, { - plan: organization.billing.plan, - limits: { - projects: null, - monthly: { - responses: monthlyResponseLimit, - miu: null, - }, - }, - }); - } catch (error) { - logger.error({ error, url: request.url }, `Error sending plan limits reached event to Posthog`); - } + const { environmentId, userId } = validation.data; + + const environment = await getEnvironment(environmentId); + if (!environment) { + throw new Error("Environment does not exist"); + } + + const project = await getProjectByEnvironmentId(environmentId); + + if (!project) { + throw new Error("Project not found"); + } + + if (!environment.appSetupCompleted) { + await Promise.all([ + updateEnvironment(environment.id, { appSetupCompleted: true }), + capturePosthogEnvironmentEvent(environmentId, "app setup completed"), + ]); } - } - let contact = await getContactByUserId(environmentId, userId); - if (!contact) { - contact = await prisma.contact.create({ - data: { - attributes: { - create: { - attributeKey: { - connect: { - key_environmentId: { - key: "userId", - environmentId, + // check organization subscriptions and response limits + const isAppSurveyResponseLimitReached = await checkResponseLimit(environmentId); + + let contact = await getContactByUserId(environmentId, userId); + if (!contact) { + contact = await prisma.contact.create({ + data: { + attributes: { + create: { + attributeKey: { + connect: { + key_environmentId: { + key: "userId", + environmentId, + }, }, }, + value: userId, }, - value: userId, }, + environment: { connect: { id: environmentId } }, + }, + select: { + id: true, + attributes: { select: { attributeKey: { select: { key: true } }, value: true } }, }, - environment: { connect: { id: environmentId } }, - }, - select: { - id: true, - attributes: { select: { attributeKey: { select: { key: true } }, value: true } }, - }, - }); - - if (contact) { - contactCache.revalidate({ - userId: contact.attributes.find((attr) => attr.attributeKey.key === "userId")?.value, - id: contact.id, - environmentId, }); } - } - - const contactAttributes = contact.attributes.reduce((acc, attribute) => { - acc[attribute.attributeKey.key] = attribute.value; - return acc; - }, {}) as Record; - - const [surveys, actionClasses] = await Promise.all([ - getSyncSurveys( - environmentId, - contact.id, - contactAttributes, - device.type === "mobile" ? "phone" : "desktop" - ), - getActionClasses(environmentId), - ]); - - if (!project) { - throw new Error("Project not found"); - } - - const updatedProject: any = { - ...project, - brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor, - ...(project.styling.highlightBorderColor?.light && { - highlightBorderColor: project.styling.highlightBorderColor.light, - }), - }; - const language = contactAttributes["language"]; + const contactAttributes = contact.attributes.reduce((acc, attribute) => { + acc[attribute.attributeKey.key] = attribute.value; + return acc; + }, {}) as Record; - // Scenario 1: Multi language and updated trigger action classes supported. - // Use the surveys as they are. - let transformedSurveys: TSurvey[] = surveys; - - // creating state object - let state = { - surveys: !isAppSurveyResponseLimitReached - ? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes)) - : [], - actionClasses, - language, - project: updatedProject, - }; + const [surveys, actionClasses] = await Promise.all([ + getSyncSurveys( + environmentId, + contact.id, + contactAttributes, + device.type === "mobile" ? "phone" : "desktop" + ), + getActionClasses(environmentId), + ]); - return responses.successResponse({ ...state }, true); - } catch (error) { - logger.error( - { error, url: request.url }, - "Error in GET /api/v1/client/[environmentId]/app/sync/[userId]" - ); - return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true); - } -}; + const updatedProject: any = { + ...project, + brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor, + ...(project.styling.highlightBorderColor?.light && { + highlightBorderColor: project.styling.highlightBorderColor.light, + }), + }; + + const language = contactAttributes["language"]; + + // Scenario 1: Multi language and updated trigger action classes supported. + // Use the surveys as they are. + let transformedSurveys: TSurvey[] = surveys; + + // creating state object + let state = { + surveys: !isAppSurveyResponseLimitReached + ? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes)) + : [], + actionClasses, + language, + project: updatedProject, + }; + + return { + response: responses.successResponse({ ...state }, true), + }; + } catch (error) { + logger.error({ error, url: req.url }, "Error in GET /api/v1/client/[environmentId]/app/sync/[userId]"); + return { + response: responses.internalServerErrorResponse( + "Unable to handle the request: " + error.message, + true + ), + }; + } + }, +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/contact.test.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/contact.test.ts new file mode 100644 index 000000000000..0b6ec3fc51d9 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/contact.test.ts @@ -0,0 +1,83 @@ +import { TContact } from "@/modules/ee/contacts/types/contact"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { getContactByUserId } from "./contact"; + +// Mock prisma +vi.mock("@formbricks/database", () => ({ + prisma: { + contact: { + findFirst: vi.fn(), + }, + }, +})); + +const environmentId = "test-environment-id"; +const userId = "test-user-id"; +const contactId = "test-contact-id"; + +const contactMock: Partial & { + attributes: { value: string; attributeKey: { key: string } }[]; +} = { + id: contactId, + attributes: [ + { attributeKey: { key: "userId" }, value: userId }, + { attributeKey: { key: "email" }, value: "test@example.com" }, + ], +}; + +describe("getContactByUserId", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + test("should return contact if found", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(contactMock as any); + + const contact = await getContactByUserId(environmentId, userId); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId, + }, + value: userId, + }, + }, + }, + select: { + id: true, + attributes: { select: { attributeKey: { select: { key: true } }, value: true } }, + }, + }); + expect(contact).toEqual(contactMock); + }); + + test("should return null if contact not found", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(null); + + const contact = await getContactByUserId(environmentId, userId); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId, + }, + value: userId, + }, + }, + }, + select: { + id: true, + attributes: { select: { attributeKey: { select: { key: true } }, value: true } }, + }, + }); + expect(contact).toBeNull(); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/contact.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/contact.ts index 13b58058ddcc..83aaf41a531e 100644 --- a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/contact.ts +++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/contact.ts @@ -1,11 +1,9 @@ import "server-only"; -import { contactCache } from "@/lib/cache/contact"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; export const getContactByUserId = reactCache( - ( + async ( environmentId: string, userId: string ): Promise<{ @@ -16,36 +14,29 @@ export const getContactByUserId = reactCache( }; }[]; id: string; - } | null> => - cache( - async () => { - const contact = await prisma.contact.findFirst({ - where: { - attributes: { - some: { - attributeKey: { - key: "userId", - environmentId, - }, - value: userId, - }, + } | null> => { + const contact = await prisma.contact.findFirst({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId, }, + value: userId, }, - select: { - id: true, - attributes: { select: { attributeKey: { select: { key: true } }, value: true } }, - }, - }); + }, + }, + select: { + id: true, + attributes: { select: { attributeKey: { select: { key: true } }, value: true } }, + }, + }); - if (!contact) { - return null; - } + if (!contact) { + return null; + } - return contact; - }, - [`getContactByUserId-sync-api-${environmentId}-${userId}`], - { - tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)], - } - )() + return contact; + } ); diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/survey.test.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/survey.test.ts new file mode 100644 index 000000000000..9d58c9627014 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/survey.test.ts @@ -0,0 +1,325 @@ +import { getProjectByEnvironmentId } from "@/lib/project/service"; +import { getSurveys } from "@/lib/survey/service"; +import { anySurveyHasFilters } from "@/lib/survey/utils"; +import { diffInDays } from "@/lib/utils/datetime"; +import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments"; +import { Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TProject } from "@formbricks/types/project"; +import { TSegment } from "@formbricks/types/segment"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { getSyncSurveys } from "./survey"; + +vi.mock("@/lib/project/service", () => ({ + getProjectByEnvironmentId: vi.fn(), +})); +vi.mock("@/lib/survey/service", () => ({ + getSurveys: vi.fn(), +})); +vi.mock("@/lib/survey/utils", () => ({ + anySurveyHasFilters: vi.fn(), +})); +vi.mock("@/lib/utils/datetime", () => ({ + diffInDays: vi.fn(), +})); +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), +})); +vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({ + evaluateSegment: vi.fn(), +})); +vi.mock("@formbricks/database", () => ({ + prisma: { + display: { + findMany: vi.fn(), + }, + response: { + findMany: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +const environmentId = "test-env-id"; +const contactId = "test-contact-id"; +const contactAttributes = { userId: "user1", email: "test@example.com" }; +const deviceType = "desktop"; + +const mockProject = { + id: "proj1", + name: "Test Project", + createdAt: new Date(), + updatedAt: new Date(), + organizationId: "org1", + environments: [], + recontactDays: 10, + inAppSurveyBranding: true, + linkSurveyBranding: true, + placement: "bottomRight", + clickOutsideClose: true, + darkOverlay: false, + languages: [], +} as unknown as TProject; + +const baseSurvey: TSurvey = { + id: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey 1", + environmentId: environmentId, + type: "app", + status: "inProgress", + questions: [], + displayOption: "displayOnce", + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + displayPercentage: null, + autoComplete: null, + segment: null, + surveyClosedMessage: null, + singleUse: null, + styling: null, + pin: null, + displayLimit: null, + welcomeCard: { enabled: false } as TSurvey["welcomeCard"], + endings: [], + triggers: [], + languages: [], + variables: [], + hiddenFields: { enabled: false }, + createdBy: null, + isSingleResponsePerEmailEnabled: false, + isVerifyEmailEnabled: false, + projectOverwrites: null, + runOnDate: null, + showLanguageSwitch: false, + isBackButtonHidden: false, + followUps: [], + recaptcha: { enabled: false, threshold: 0.5 }, +}; + +// Helper function to create mock display objects +const createMockDisplay = (id: string, surveyId: string, contactId: string, createdAt?: Date) => ({ + id, + createdAt: createdAt || new Date(), + updatedAt: new Date(), + surveyId, + contactId, + responseId: null, + status: null, +}); + +// Helper function to create mock response objects +const createMockResponse = (id: string, surveyId: string, contactId: string, createdAt?: Date) => ({ + id, + createdAt: createdAt || new Date(), + updatedAt: new Date(), + finished: false, + surveyId, + contactId, + endingId: null, + data: {}, + variables: {}, + ttc: {}, + meta: {}, + contactAttributes: null, + singleUseId: null, + language: null, + displayId: null, +}); + +describe("getSyncSurveys", () => { + beforeEach(() => { + vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject); + vi.mocked(prisma.display.findMany).mockResolvedValue([]); + vi.mocked(prisma.response.findMany).mockResolvedValue([]); + vi.mocked(anySurveyHasFilters).mockReturnValue(false); + vi.mocked(evaluateSegment).mockResolvedValue(true); + vi.mocked(diffInDays).mockReturnValue(100); // Assume enough days passed + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("should throw error if product not found", async () => { + vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null); + await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow( + "Project not found" + ); + }); + + test("should return empty array if no surveys found", async () => { + vi.mocked(getSurveys).mockResolvedValue([]); + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual([]); + }); + + test("should return empty array if no 'app' type surveys in progress", async () => { + const surveys: TSurvey[] = [ + { ...baseSurvey, id: "s1", type: "link", status: "inProgress" }, + { ...baseSurvey, id: "s2", type: "app", status: "paused" }, + ]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual([]); + }); + + test("should filter by displayOption 'displayOnce'", async () => { + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayOnce" }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); // Already displayed + + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual([]); + + vi.mocked(prisma.display.findMany).mockResolvedValue([]); // Not displayed yet + const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result2).toEqual(surveys); + }); + + test("should filter by displayOption 'displayMultiple'", async () => { + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayMultiple" }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]); // Already responded + + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual([]); + + vi.mocked(prisma.response.findMany).mockResolvedValue([]); // Not responded yet + const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result2).toEqual(surveys); + }); + + test("should filter by displayOption 'displaySome'", async () => { + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displaySome", displayLimit: 2 }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + vi.mocked(prisma.display.findMany).mockResolvedValue([ + createMockDisplay("d1", "s1", contactId), + createMockDisplay("d2", "s1", contactId), + ]); // Display limit reached + + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual([]); + + vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); // Within limit + const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result2).toEqual(surveys); + + // Test with response already submitted + vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]); + const result3 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result3).toEqual([]); + }); + + test("should not filter by displayOption 'respondMultiple'", async () => { + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "respondMultiple" }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); + vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]); + + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual(surveys); + }); + + test("should filter by product recontactDays if survey recontactDays is null", async () => { + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", recontactDays: null }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + const displayDate = new Date(); + vi.mocked(prisma.display.findMany).mockResolvedValue([ + createMockDisplay("d1", "s2", contactId, displayDate), // Display for another survey + ]); + + vi.mocked(diffInDays).mockReturnValue(5); // Not enough days passed (product.recontactDays = 10) + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual([]); + expect(diffInDays).toHaveBeenCalledWith(expect.any(Date), displayDate); + + vi.mocked(diffInDays).mockReturnValue(15); // Enough days passed + const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result2).toEqual(surveys); + }); + + test("should return surveys if no segment filters exist", async () => { + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1" }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + vi.mocked(anySurveyHasFilters).mockReturnValue(false); + + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual(surveys); + expect(evaluateSegment).not.toHaveBeenCalled(); + }); + + test("should evaluate segment filters if they exist", async () => { + const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + vi.mocked(anySurveyHasFilters).mockReturnValue(true); + + // Case 1: Segment evaluation matches + vi.mocked(evaluateSegment).mockResolvedValue(true); + const result1 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result1).toEqual(surveys); + expect(evaluateSegment).toHaveBeenCalledWith( + { + attributes: contactAttributes, + deviceType, + environmentId, + contactId, + userId: contactAttributes.userId, + }, + segment.filters + ); + + // Case 2: Segment evaluation does not match + vi.mocked(evaluateSegment).mockResolvedValue(false); + const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result2).toEqual([]); + }); + + test("should handle Prisma errors", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2025", + clientVersion: "test", + }); + vi.mocked(getSurveys).mockRejectedValue(prismaError); + + await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow( + DatabaseError + ); + expect(logger.error).toHaveBeenCalledWith(prismaError); + }); + + test("should handle general errors", async () => { + const generalError = new Error("Something went wrong"); + vi.mocked(getSurveys).mockRejectedValue(generalError); + + await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow( + generalError + ); + }); + + test("should throw ResourceNotFoundError if resolved surveys are null after filtering", async () => { + const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure + const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }]; + vi.mocked(getSurveys).mockResolvedValue(surveys); + vi.mocked(anySurveyHasFilters).mockReturnValue(true); + vi.mocked(evaluateSegment).mockResolvedValue(false); // Ensure all surveys are filtered out + + // This scenario is tricky to force directly as the code checks `if (!surveys)` before returning. + // However, if `Promise.all` somehow resolved to null/undefined (highly unlikely), it should throw. + // We can simulate this by mocking `Promise.all` if needed, but the current code structure makes this hard to test. + // Let's assume the filter logic works correctly and test the intended path. + const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType); + expect(result).toEqual([]); // Expect empty array, not an error in this case. + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/survey.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/survey.ts index 949c0d6ea133..349cda6cfd58 100644 --- a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/survey.ts +++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/survey.ts @@ -1,173 +1,148 @@ import "server-only"; -import { contactCache } from "@/lib/cache/contact"; -import { contactAttributeCache } from "@/lib/cache/contact-attribute"; +import { getProjectByEnvironmentId } from "@/lib/project/service"; +import { getSurveys } from "@/lib/survey/service"; +import { anySurveyHasFilters } from "@/lib/survey/utils"; +import { diffInDays } from "@/lib/utils/datetime"; +import { validateInputs } from "@/lib/utils/validate"; import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { displayCache } from "@formbricks/lib/display/cache"; -import { projectCache } from "@formbricks/lib/project/cache"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; -import { surveyCache } from "@formbricks/lib/survey/cache"; -import { getSurveys } from "@formbricks/lib/survey/service"; -import { anySurveyHasFilters } from "@formbricks/lib/survey/utils"; -import { diffInDays } from "@formbricks/lib/utils/datetime"; -import { validateInputs } from "@formbricks/lib/utils/validate"; import { logger } from "@formbricks/logger"; import { ZId } from "@formbricks/types/common"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TSurvey } from "@formbricks/types/surveys/types"; export const getSyncSurveys = reactCache( - ( + async ( environmentId: string, contactId: string, contactAttributes: Record, deviceType: "phone" | "desktop" = "desktop" - ): Promise => - cache( - async () => { - validateInputs([environmentId, ZId]); - try { - const product = await getProjectByEnvironmentId(environmentId); - - if (!product) { - throw new Error("Product not found"); - } + ): Promise => { + validateInputs([environmentId, ZId]); + try { + const project = await getProjectByEnvironmentId(environmentId); - let surveys = await getSurveys(environmentId); + if (!project) { + throw new Error("Project not found"); + } - // filtered surveys for running and web - surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "app"); + let surveys = await getSurveys(environmentId); - // if no surveys are left, return an empty array - if (surveys.length === 0) { - return []; - } - - const displays = await prisma.display.findMany({ - where: { - contactId, - }, - }); - - const responses = await prisma.response.findMany({ - where: { - contactId, - }, - }); - - // filter surveys that meet the displayOption criteria - surveys = surveys.filter((survey) => { - switch (survey.displayOption) { - case "respondMultiple": - return true; - case "displayOnce": - return displays.filter((display) => display.surveyId === survey.id).length === 0; - case "displayMultiple": - if (!responses) return true; - else { - return responses.filter((response) => response.surveyId === survey.id).length === 0; - } - case "displaySome": - if (survey.displayLimit === null) { - return true; - } - - if ( - responses && - responses.filter((response) => response.surveyId === survey.id).length !== 0 - ) { - return false; - } - - return ( - displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit - ); - default: - throw Error("Invalid displayOption"); - } - }); + // filtered surveys for running and web + surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "app"); - const latestDisplay = displays[0]; + // if no surveys are left, return an empty array + if (surveys.length === 0) { + return []; + } - // filter surveys that meet the recontactDays criteria - surveys = surveys.filter((survey) => { - if (!latestDisplay) { - return true; - } else if (survey.recontactDays !== null) { - const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0]; - if (!lastDisplaySurvey) { - return true; - } - return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays; - } else if (product.recontactDays !== null) { - return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays; - } else { + const displays = await prisma.display.findMany({ + where: { + contactId, + }, + }); + + const responses = await prisma.response.findMany({ + where: { + contactId, + }, + }); + + // filter surveys that meet the displayOption criteria + surveys = surveys.filter((survey) => { + switch (survey.displayOption) { + case "respondMultiple": + return true; + case "displayOnce": + return displays.filter((display) => display.surveyId === survey.id).length === 0; + case "displayMultiple": + if (!responses) return true; + else { + return responses.filter((response) => response.surveyId === survey.id).length === 0; + } + case "displaySome": + if (survey.displayLimit === null) { return true; } - }); - // if no surveys are left, return an empty array - if (surveys.length === 0) { - return []; - } + if (responses && responses.filter((response) => response.surveyId === survey.id).length !== 0) { + return false; + } - // if no surveys have segment filters, return the surveys - if (!anySurveyHasFilters(surveys)) { - return surveys; + return displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit; + default: + throw Error("Invalid displayOption"); + } + }); + + const latestDisplay = displays[0]; + + // filter surveys that meet the recontactDays criteria + surveys = surveys.filter((survey) => { + if (!latestDisplay) { + return true; + } else if (survey.recontactDays !== null) { + const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0]; + if (!lastDisplaySurvey) { + return true; } + return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays; + } else if (project.recontactDays !== null) { + return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= project.recontactDays; + } else { + return true; + } + }); - // the surveys now have segment filters, so we need to evaluate them - const surveyPromises = surveys.map(async (survey) => { - const { segment } = survey; - // if the survey has no segment, or the segment has no filters, we return the survey - if (!segment || !segment.filters?.length) { - return survey; - } + // if no surveys are left, return an empty array + if (surveys.length === 0) { + return []; + } - // Evaluate the segment filters - const result = await evaluateSegment( - { - attributes: contactAttributes ?? {}, - deviceType, - environmentId, - contactId, - userId: String(contactAttributes.userId), - }, - segment.filters - ); - - return result ? survey : null; - }); - - const resolvedSurveys = await Promise.all(surveyPromises); - surveys = resolvedSurveys.filter((survey) => !!survey) as TSurvey[]; - - if (!surveys) { - throw new ResourceNotFoundError("Survey", environmentId); - } - return surveys; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - logger.error(error); - throw new DatabaseError(error.message); - } + // if no surveys have segment filters, return the surveys + if (!anySurveyHasFilters(surveys)) { + return surveys; + } - throw error; + // the surveys now have segment filters, so we need to evaluate them + const surveyPromises = surveys.map(async (survey) => { + const { segment } = survey; + // if the survey has no segment, or the segment has no filters, we return the survey + if (!segment || !segment.filters?.length) { + return survey; } - }, - [`getSyncSurveys-${environmentId}-${contactId}`], - { - tags: [ - contactCache.tag.byEnvironmentId(environmentId), - contactCache.tag.byId(contactId), - displayCache.tag.byContactId(contactId), - surveyCache.tag.byEnvironmentId(environmentId), - projectCache.tag.byEnvironmentId(environmentId), - contactAttributeCache.tag.byContactId(contactId), - ], + + // Evaluate the segment filters + const result = await evaluateSegment( + { + attributes: contactAttributes ?? {}, + deviceType, + environmentId, + contactId, + userId: String(contactAttributes.userId), + }, + segment.filters + ); + + return result ? survey : null; + }); + + const resolvedSurveys = await Promise.all(surveyPromises); + surveys = resolvedSurveys.filter((survey) => !!survey) as TSurvey[]; + + if (!surveys) { + throw new ResourceNotFoundError("Survey", environmentId); } - )() + return surveys; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error); + throw new DatabaseError(error.message); + } + + throw error; + } + } ); diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.test.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.test.ts new file mode 100644 index 000000000000..bb17e265a12c --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.test.ts @@ -0,0 +1,246 @@ +import { parseRecallInfo } from "@/lib/utils/recall"; +import { describe, expect, test, vi } from "vitest"; +import { TAttributes } from "@formbricks/types/attributes"; +import { TLanguage } from "@formbricks/types/project"; +import { + TSurvey, + TSurveyEnding, + TSurveyQuestion, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; +import { replaceAttributeRecall } from "./utils"; + +vi.mock("@/lib/utils/recall", () => ({ + parseRecallInfo: vi.fn((text, attributes) => { + const recallPattern = /recall:([a-zA-Z0-9_-]+)/; + const match = text.match(recallPattern); + if (match && match[1]) { + const recallKey = match[1]; + const attributeValue = attributes[recallKey]; + if (attributeValue !== undefined) { + return text.replace(recallPattern, `parsed-${attributeValue}`); + } + } + return text; // Return original text if no match or attribute not found + }), +})); + +const baseSurvey: TSurvey = { + id: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + environmentId: "env1", + type: "app", + status: "inProgress", + questions: [], + endings: [], + welcomeCard: { enabled: false } as TSurvey["welcomeCard"], + languages: [ + { language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true }, + ], + triggers: [], + recontactDays: null, + displayLimit: null, + singleUse: null, + styling: null, + surveyClosedMessage: null, + hiddenFields: { enabled: false }, + variables: [], + createdBy: null, + isSingleResponsePerEmailEnabled: false, + isVerifyEmailEnabled: false, + projectOverwrites: null, + runOnDate: null, + showLanguageSwitch: false, + isBackButtonHidden: false, + followUps: [], + recaptcha: { enabled: false, threshold: 0.5 }, + displayOption: "displayOnce", + autoClose: null, + closeOnDate: null, + delay: 0, + displayPercentage: null, + autoComplete: null, + segment: null, + pin: null, +}; + +const attributes: TAttributes = { + name: "John Doe", + email: "john.doe@example.com", + plan: "premium", +}; + +describe("replaceAttributeRecall", () => { + test("should replace recall info in question headlines and subheaders", () => { + const surveyWithRecall: TSurvey = { + ...baseSurvey, + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Hello recall:name!" }, + subheader: { default: "Your email is recall:email" }, + required: true, + buttonLabel: { default: "Next" }, + placeholder: { default: "Type here..." }, + longAnswer: false, + logic: [], + } as unknown as TSurveyQuestion, + ], + }; + + const result = replaceAttributeRecall(surveyWithRecall, attributes); + expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!"); + expect(result.questions[0].subheader?.default).toBe("Your email is parsed-john.doe@example.com"); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your email is recall:email", attributes); + }); + + test("should replace recall info in welcome card headline", () => { + const surveyWithRecall: TSurvey = { + ...baseSurvey, + welcomeCard: { + enabled: true, + headline: { default: "Welcome, recall:name!" }, + html: { default: "

Some content

" }, + buttonLabel: { default: "Start" }, + timeToFinish: false, + showResponseCount: false, + }, + }; + + const result = replaceAttributeRecall(surveyWithRecall, attributes); + expect(result.welcomeCard.headline?.default).toBe("Welcome, parsed-John Doe!"); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Welcome, recall:name!", attributes); + }); + + test("should replace recall info in end screen headlines and subheaders", () => { + const surveyWithRecall: TSurvey = { + ...baseSurvey, + endings: [ + { + type: "endScreen", + headline: { default: "Thank you, recall:name!" }, + subheader: { default: "Your plan: recall:plan" }, + buttonLabel: { default: "Finish" }, + buttonLink: "https://example.com", + } as unknown as TSurveyEnding, + ], + }; + + const result = replaceAttributeRecall(surveyWithRecall, attributes); + expect(result.endings[0].type).toBe("endScreen"); + if (result.endings[0].type === "endScreen") { + expect(result.endings[0].headline?.default).toBe("Thank you, parsed-John Doe!"); + expect(result.endings[0].subheader?.default).toBe("Your plan: parsed-premium"); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Thank you, recall:name!", attributes); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your plan: recall:plan", attributes); + } + }); + + test("should handle multiple languages", () => { + const surveyMultiLang: TSurvey = { + ...baseSurvey, + languages: [ + { language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true }, + { language: { id: "lang2", code: "es" } as unknown as TLanguage, default: false, enabled: true }, + ], + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Hello recall:name!", es: "Hola recall:name!" }, + required: true, + buttonLabel: { default: "Next", es: "Siguiente" }, + placeholder: { default: "Type here...", es: "Escribe aquí..." }, + longAnswer: false, + logic: [], + } as unknown as TSurveyQuestion, + ], + }; + + const result = replaceAttributeRecall(surveyMultiLang, attributes); + expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!"); + expect(result.questions[0].headline.es).toBe("Hola parsed-John Doe!"); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hola recall:name!", attributes); + }); + + test("should not replace if recall key is not in attributes", () => { + const surveyWithRecall: TSurvey = { + ...baseSurvey, + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Your company: recall:company" }, + required: true, + buttonLabel: { default: "Next" }, + placeholder: { default: "Type here..." }, + longAnswer: false, + logic: [], + } as unknown as TSurveyQuestion, + ], + }; + + const result = replaceAttributeRecall(surveyWithRecall, attributes); + expect(result.questions[0].headline.default).toBe("Your company: recall:company"); + expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your company: recall:company", attributes); + }); + + test("should handle surveys with no recall information", async () => { + const surveyNoRecall: TSurvey = { + ...baseSurvey, + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Just a regular question" }, + required: true, + buttonLabel: { default: "Next" }, + placeholder: { default: "Type here..." }, + longAnswer: false, + logic: [], + } as unknown as TSurveyQuestion, + ], + welcomeCard: { + enabled: true, + headline: { default: "Welcome!" }, + html: { default: "

Some content

" }, + buttonLabel: { default: "Start" }, + timeToFinish: false, + showResponseCount: false, + }, + endings: [ + { + type: "endScreen", + headline: { default: "Thank you!" }, + buttonLabel: { default: "Finish" }, + } as unknown as TSurveyEnding, + ], + }; + const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo"); + + const result = replaceAttributeRecall(surveyNoRecall, attributes); + expect(result).toEqual(surveyNoRecall); // Should be unchanged + expect(parseRecallInfoSpy).not.toHaveBeenCalled(); + parseRecallInfoSpy.mockRestore(); + }); + + test("should handle surveys with empty questions, endings, or disabled welcome card", async () => { + const surveyEmpty: TSurvey = { + ...baseSurvey, + questions: [], + endings: [], + welcomeCard: { enabled: false } as TSurvey["welcomeCard"], + }; + const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo"); + + const result = replaceAttributeRecall(surveyEmpty, attributes); + expect(result).toEqual(surveyEmpty); + expect(parseRecallInfoSpy).not.toHaveBeenCalled(); + parseRecallInfoSpy.mockRestore(); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.ts index 5c389cc48d4f..f48c6187c553 100644 --- a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.ts +++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.ts @@ -1,4 +1,4 @@ -import { parseRecallInfo } from "@formbricks/lib/utils/recall"; +import { parseRecallInfo } from "@/lib/utils/recall"; import { TAttributes } from "@formbricks/types/attributes"; import { TSurvey } from "@formbricks/types/surveys/types"; diff --git a/apps/web/app/api/v1/client/[environmentId]/displays/lib/contact.ts b/apps/web/app/api/v1/client/[environmentId]/displays/lib/contact.ts index 4dd2c85ff5fd..fa20cb3c068c 100644 --- a/apps/web/app/api/v1/client/[environmentId]/displays/lib/contact.ts +++ b/apps/web/app/api/v1/client/[environmentId]/displays/lib/contact.ts @@ -1,41 +1,32 @@ -import { contactCache } from "@/lib/cache/contact"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; export const getContactByUserId = reactCache( - ( + async ( environmentId: string, userId: string ): Promise<{ id: string; - } | null> => - cache( - async () => { - const contact = await prisma.contact.findFirst({ - where: { - attributes: { - some: { - attributeKey: { - key: "userId", - environmentId, - }, - value: userId, - }, + } | null> => { + const contact = await prisma.contact.findFirst({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId, }, + value: userId, }, - select: { id: true }, - }); + }, + }, + select: { id: true }, + }); - if (!contact) { - return null; - } + if (!contact) { + return null; + } - return contact; - }, - [`getContactByUserIdForDisplaysApi-${environmentId}-${userId}`], - { - tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)], - } - )() + return contact; + } ); diff --git a/apps/web/app/api/v1/client/[environmentId]/displays/lib/display.test.ts b/apps/web/app/api/v1/client/[environmentId]/displays/lib/display.test.ts new file mode 100644 index 000000000000..7ad046cf1ef1 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/displays/lib/display.test.ts @@ -0,0 +1,222 @@ +import { validateInputs } from "@/lib/utils/validate"; +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { TDisplayCreateInput } from "@formbricks/types/displays"; +import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors"; +import { getContactByUserId } from "./contact"; +import { createDisplay } from "./display"; + +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn((inputs) => inputs.map((input) => input[0])), // Pass through validation for testing +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + contact: { + create: vi.fn(), + }, + display: { + create: vi.fn(), + }, + survey: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("./contact", () => ({ + getContactByUserId: vi.fn(), +})); + +const environmentId = "test-env-id"; +const surveyId = "test-survey-id"; +const userId = "test-user-id"; +const contactId = "test-contact-id"; +const displayId = "test-display-id"; + +const displayInput: TDisplayCreateInput = { + environmentId, + surveyId, + userId, +}; + +const displayInputWithoutUserId: TDisplayCreateInput = { + environmentId, + surveyId, +}; + +const mockContact = { + id: contactId, + environmentId, + userId, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockDisplay = { + id: displayId, + contactId, + surveyId, + responseId: null, + status: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockDisplayWithoutContact = { + id: displayId, + contactId: null, + surveyId, + responseId: null, + status: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockSurvey = { + id: surveyId, + name: "Test Survey", + environmentId, +} as any; + +describe("createDisplay", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.survey.findUnique).mockResolvedValue(mockSurvey); + }); + + test("should create a display with existing contact successfully", async () => { + vi.mocked(getContactByUserId).mockResolvedValue(mockContact); + vi.mocked(prisma.display.create).mockResolvedValue(mockDisplay); + + const result = await createDisplay(displayInput); + + expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]); + expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId); + expect(prisma.contact.create).not.toHaveBeenCalled(); + expect(prisma.display.create).toHaveBeenCalledWith({ + data: { + survey: { connect: { id: surveyId } }, + contact: { connect: { id: contactId } }, + }, + select: { id: true, contactId: true, surveyId: true }, + }); + expect(result).toEqual(mockDisplay); + }); + + test("should create a display and new contact when contact does not exist", async () => { + vi.mocked(getContactByUserId).mockResolvedValue(null); + vi.mocked(prisma.contact.create).mockResolvedValue(mockContact); + vi.mocked(prisma.display.create).mockResolvedValue(mockDisplay); + + const result = await createDisplay(displayInput); + + expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]); + expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId); + expect(prisma.contact.create).toHaveBeenCalledWith({ + data: { + environment: { connect: { id: environmentId } }, + attributes: { + create: { + attributeKey: { + connect: { key_environmentId: { key: "userId", environmentId } }, + }, + value: userId, + }, + }, + }, + }); + expect(prisma.display.create).toHaveBeenCalledWith({ + data: { + survey: { connect: { id: surveyId } }, + contact: { connect: { id: contactId } }, + }, + select: { id: true, contactId: true, surveyId: true }, + }); + expect(result).toEqual(mockDisplay); + }); + + test("should create a display without contact when userId is not provided", async () => { + vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); + + const result = await createDisplay(displayInputWithoutUserId); + + expect(validateInputs).toHaveBeenCalledWith([displayInputWithoutUserId, expect.any(Object)]); + expect(getContactByUserId).not.toHaveBeenCalled(); + expect(prisma.contact.create).not.toHaveBeenCalled(); + expect(prisma.display.create).toHaveBeenCalledWith({ + data: { + survey: { connect: { id: surveyId } }, + }, + select: { id: true, contactId: true, surveyId: true }, + }); + expect(result).toEqual(mockDisplayWithoutContact); + }); + + test("should throw ValidationError if validation fails", async () => { + const validationError = new ValidationError("Validation failed"); + vi.mocked(validateInputs).mockImplementation(() => { + throw validationError; + }); + + await expect(createDisplay(displayInput)).rejects.toThrow(ValidationError); + expect(getContactByUserId).not.toHaveBeenCalled(); + expect(prisma.display.create).not.toHaveBeenCalled(); + }); + + test("should throw InvalidInputError when survey does not exist (RelatedRecordDoesNotExist)", async () => { + vi.mocked(getContactByUserId).mockResolvedValue(mockContact); + vi.mocked(prisma.survey.findUnique).mockResolvedValue(null); + + await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId)); + expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId); + expect(prisma.survey.findUnique).toHaveBeenCalledWith({ + where: { id: surveyId, environmentId }, + }); + expect(prisma.display.create).not.toHaveBeenCalled(); + }); + + test("should throw DatabaseError on other Prisma known request errors", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "2.0.0", + }); + vi.mocked(getContactByUserId).mockResolvedValue(mockContact); + vi.mocked(prisma.display.create).mockRejectedValue(prismaError); + + await expect(createDisplay(displayInput)).rejects.toThrow(DatabaseError); + expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId); + expect(prisma.display.create).toHaveBeenCalled(); + }); + + test("should throw original error on other errors during display creation", async () => { + const genericError = new Error("Something went wrong"); + vi.mocked(getContactByUserId).mockResolvedValue(mockContact); + vi.mocked(prisma.display.create).mockRejectedValue(genericError); + + await expect(createDisplay(displayInput)).rejects.toThrow(genericError); + expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId); + expect(prisma.display.create).toHaveBeenCalled(); + }); + + test("should throw error if getContactByUserId fails", async () => { + const contactError = new Error("Failed to get contact"); + vi.mocked(getContactByUserId).mockRejectedValue(contactError); + + await expect(createDisplay(displayInput)).rejects.toThrow(contactError); + expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId); + expect(prisma.display.create).not.toHaveBeenCalled(); + }); + + test("should throw error if contact creation fails", async () => { + const contactCreateError = new Error("Failed to create contact"); + vi.mocked(getContactByUserId).mockResolvedValue(null); + vi.mocked(prisma.contact.create).mockRejectedValue(contactCreateError); + + await expect(createDisplay(displayInput)).rejects.toThrow(contactCreateError); + expect(getContactByUserId).toHaveBeenCalledWith(environmentId, userId); + expect(prisma.contact.create).toHaveBeenCalled(); + expect(prisma.display.create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/displays/lib/display.ts b/apps/web/app/api/v1/client/[environmentId]/displays/lib/display.ts index 9756cff825b1..070495f647ec 100644 --- a/apps/web/app/api/v1/client/[environmentId]/displays/lib/display.ts +++ b/apps/web/app/api/v1/client/[environmentId]/displays/lib/display.ts @@ -1,9 +1,8 @@ +import { validateInputs } from "@/lib/utils/validate"; import { Prisma } from "@prisma/client"; import { prisma } from "@formbricks/database"; -import { displayCache } from "@formbricks/lib/display/cache"; -import { validateInputs } from "@formbricks/lib/utils/validate"; import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays"; -import { DatabaseError } from "@formbricks/types/errors"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { getContactByUserId } from "./contact"; export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<{ id: string }> => { @@ -32,6 +31,16 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise< } } + const survey = await prisma.survey.findUnique({ + where: { + id: surveyId, + environmentId, + }, + }); + if (!survey) { + throw new ResourceNotFoundError("Survey", surveyId); + } + const display = await prisma.display.create({ data: { survey: { @@ -51,14 +60,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise< select: { id: true, contactId: true, surveyId: true }, }); - displayCache.revalidate({ - id: display.id, - contactId: display.contactId, - surveyId: display.surveyId, - userId, - environmentId, - }); - return display; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/apps/web/app/api/v1/client/[environmentId]/displays/route.ts b/apps/web/app/api/v1/client/[environmentId]/displays/route.ts index 478ea470412d..c46528bc62f7 100644 --- a/apps/web/app/api/v1/client/[environmentId]/displays/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/displays/route.ts @@ -1,10 +1,12 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; -import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer"; +import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; import { ZDisplayCreateInput } from "@formbricks/types/displays"; -import { InvalidInputError } from "@formbricks/types/errors"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; import { createDisplay } from "./lib/display"; interface Context { @@ -14,43 +16,64 @@ interface Context { } export const OPTIONS = async (): Promise => { - return responses.successResponse({}, true); + return responses.successResponse( + {}, + true, + // Cache CORS preflight responses for 1 hour (conservative approach) + // Balances performance gains with flexibility for CORS policy changes + "public, s-maxage=3600, max-age=3600" + ); }; -export const POST = async (request: Request, context: Context): Promise => { - const params = await context.params; - const jsonInput = await request.json(); - const inputValidation = ZDisplayCreateInput.safeParse({ - ...jsonInput, - environmentId: params.environmentId, - }); +export const POST = withV1ApiWrapper({ + handler: async ({ req, props }: { req: NextRequest; props: Context }) => { + const params = await props.params; + const jsonInput = await req.json(); + const inputValidation = ZDisplayCreateInput.safeParse({ + ...jsonInput, + environmentId: params.environmentId, + }); - if (!inputValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(inputValidation.error), - true - ); - } + if (!inputValidation.success) { + return { + response: responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(inputValidation.error), + true + ), + }; + } - if (inputValidation.data.userId) { - const isContactsEnabled = await getIsContactsEnabled(); - if (!isContactsEnabled) { - return responses.forbiddenResponse("User identification is only available for enterprise users.", true); + if (inputValidation.data.userId) { + const isContactsEnabled = await getIsContactsEnabled(); + if (!isContactsEnabled) { + return { + response: responses.forbiddenResponse( + "User identification is only available for enterprise users.", + true + ), + }; + } } - } - try { - const response = await createDisplay(inputValidation.data); + try { + const response = await createDisplay(inputValidation.data); - await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created"); - return responses.successResponse(response, true); - } catch (error) { - if (error instanceof InvalidInputError) { - return responses.badRequestResponse(error.message); - } else { - logger.error({ error, url: request.url }, "Error in POST /api/v1/client/[environmentId]/displays"); - return responses.internalServerErrorResponse(error.message); + await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created"); + return { + response: responses.successResponse(response, true), + }; + } catch (error) { + if (error instanceof ResourceNotFoundError) { + return { + response: responses.notFoundResponse("Survey", inputValidation.data.surveyId), + }; + } else { + logger.error({ error, url: req.url }, "Error in POST /api/v1/client/[environmentId]/displays"); + return { + response: responses.internalServerErrorResponse("Something went wrong. Please try again."), + }; + } } - } -}; + }, +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/actionClass.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/actionClass.ts deleted file mode 100644 index 5fd53071c31e..000000000000 --- a/apps/web/app/api/v1/client/[environmentId]/environment/lib/actionClass.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { cache as reactCache } from "react"; -import { prisma } from "@formbricks/database"; -import { actionClassCache } from "@formbricks/lib/actionClass/cache"; -import { cache } from "@formbricks/lib/cache"; -import { validateInputs } from "@formbricks/lib/utils/validate"; -import { ZId } from "@formbricks/types/common"; -import { DatabaseError } from "@formbricks/types/errors"; -import { TJsEnvironmentStateActionClass } from "@formbricks/types/js"; - -export const getActionClassesForEnvironmentState = reactCache( - async (environmentId: string): Promise => - cache( - async () => { - validateInputs([environmentId, ZId]); - - try { - return await prisma.actionClass.findMany({ - where: { - environmentId: environmentId, - }, - select: { - id: true, - type: true, - name: true, - key: true, - noCodeConfig: true, - }, - }); - } catch (error) { - throw new DatabaseError(`Database error when fetching actions for environment ${environmentId}`); - } - }, - [`getActionClassesForEnvironmentState-${environmentId}`], - { - tags: [actionClassCache.tag.byEnvironmentId(environmentId)], - } - )() -); diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts new file mode 100644 index 000000000000..29913f2a5398 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts @@ -0,0 +1,202 @@ +import "server-only"; +import { validateInputs } from "@/lib/utils/validate"; +import { transformPrismaSurvey } from "@/modules/survey/lib/utils"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { ZId } from "@formbricks/types/common"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { + TJsEnvironmentStateActionClass, + TJsEnvironmentStateProject, + TJsEnvironmentStateSurvey, +} from "@formbricks/types/js"; + +/** + * Optimized data fetcher for environment state + * Uses a single Prisma query with strategic includes to minimize database calls + * Critical for performance on high-frequency endpoint serving hundreds of thousands of SDK clients + */ +export interface EnvironmentStateData { + environment: { + id: string; + type: string; + appSetupCompleted: boolean; + project: TJsEnvironmentStateProject; + }; + organization: { + id: string; + billing: any; + }; + surveys: TJsEnvironmentStateSurvey[]; + actionClasses: TJsEnvironmentStateActionClass[]; +} + +/** + * Single optimized query that fetches all required data + * Replaces multiple separate service calls with one efficient database operation + */ +export const getEnvironmentStateData = async (environmentId: string): Promise => { + validateInputs([environmentId, ZId]); + + try { + // Single query that fetches everything needed for environment state + // Uses strategic includes and selects to minimize data transfer + const environmentData = await prisma.environment.findUnique({ + where: { id: environmentId }, + select: { + id: true, + type: true, + appSetupCompleted: true, + // Project data (optimized select) + project: { + select: { + id: true, + recontactDays: true, + clickOutsideClose: true, + darkOverlay: true, + placement: true, + inAppSurveyBranding: true, + styling: true, + // Organization data (nested select for efficiency) + organization: { + select: { + id: true, + billing: true, + }, + }, + }, + }, + // Action classes (optimized for environment state) + actionClasses: { + select: { + id: true, + type: true, + name: true, + key: true, + noCodeConfig: true, + }, + }, + // Surveys (optimized for app surveys only) + surveys: { + where: { + type: "app", + status: "inProgress", + }, + orderBy: { + createdAt: "desc", + }, + take: 30, // Limit for performance + select: { + id: true, + welcomeCard: true, + name: true, + questions: true, + variables: true, + type: true, + showLanguageSwitch: true, + languages: { + select: { + default: true, + enabled: true, + language: { + select: { + id: true, + code: true, + alias: true, + createdAt: true, + updatedAt: true, + projectId: true, + }, + }, + }, + }, + endings: true, + autoClose: true, + styling: true, + status: true, + recaptcha: true, + segment: { + include: { + surveys: { + select: { + id: true, + }, + }, + }, + }, + recontactDays: true, + displayLimit: true, + displayOption: true, + hiddenFields: true, + isBackButtonHidden: true, + triggers: { + select: { + actionClass: { + select: { + name: true, + }, + }, + }, + }, + displayPercentage: true, + delay: true, + projectOverwrites: true, + }, + }, + }, + }); + + if (!environmentData) { + throw new ResourceNotFoundError("environment", environmentId); + } + + if (!environmentData.project) { + throw new ResourceNotFoundError("project", null); + } + + if (!environmentData.project.organization) { + throw new ResourceNotFoundError("organization", null); + } + + // Transform surveys using existing utility + const transformedSurveys = environmentData.surveys.map((survey) => + transformPrismaSurvey(survey) + ); + + return { + environment: { + id: environmentData.id, + type: environmentData.type, + appSetupCompleted: environmentData.appSetupCompleted, + project: { + id: environmentData.project.id, + recontactDays: environmentData.project.recontactDays, + clickOutsideClose: environmentData.project.clickOutsideClose, + darkOverlay: environmentData.project.darkOverlay, + placement: environmentData.project.placement, + inAppSurveyBranding: environmentData.project.inAppSurveyBranding, + styling: environmentData.project.styling, + }, + }, + organization: { + id: environmentData.project.organization.id, + billing: environmentData.project.organization.billing, + }, + surveys: transformedSurveys, + actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[], + }; + } catch (error) { + if (error instanceof ResourceNotFoundError) { + throw error; + } + + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Database error in getEnvironmentStateData"); + throw new DatabaseError(`Database error when fetching environment state for ${environmentId}`); + } + + logger.error(error, "Unexpected error in getEnvironmentStateData"); + throw error; + } +}; diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.test.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.test.ts new file mode 100644 index 000000000000..893e8d793946 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.test.ts @@ -0,0 +1,279 @@ +import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service"; +import { + capturePosthogEnvironmentEvent, + sendPlanLimitsReachedEventToPosthogWeekly, +} from "@/lib/posthogServer"; +import { withCache } from "@/modules/cache/lib/withCache"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { TActionClass } from "@formbricks/types/action-classes"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/types/js"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { EnvironmentStateData, getEnvironmentStateData } from "./data"; +import { getEnvironmentState } from "./environmentState"; + +// Mock dependencies +vi.mock("@/lib/organization/service"); +vi.mock("@/lib/posthogServer"); +vi.mock("@/modules/cache/lib/withCache"); + +vi.mock("@formbricks/database", () => ({ + prisma: { + environment: { + update: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); +vi.mock("./data"); +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: true, + RECAPTCHA_SITE_KEY: "mock_recaptcha_site_key", + RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key", + IS_RECAPTCHA_CONFIGURED: true, + IS_PRODUCTION: true, + IS_POSTHOG_CONFIGURED: false, + ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key", +})); + +const environmentId = "test-environment-id"; + +const mockProject: TJsEnvironmentStateProject = { + id: "test-project-id", + recontactDays: 30, + inAppSurveyBranding: true, + placement: "bottomRight", + clickOutsideClose: true, + darkOverlay: false, + styling: { + allowStyleOverwrite: false, + }, +}; + +const mockOrganization: TOrganization = { + id: "test-org-id", + name: "Test Organization", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + plan: "free", + stripeCustomerId: null, + period: "monthly", + limits: { + projects: 1, + monthly: { + responses: 100, + miu: 1000, + }, + }, + periodStart: new Date(), + }, + isAIEnabled: false, +}; + +const mockSurveys: TSurvey[] = [ + { + id: "survey-app-inProgress", + createdAt: new Date(), + updatedAt: new Date(), + name: "App Survey In Progress", + environmentId: environmentId, + type: "app", + status: "inProgress", + displayLimit: null, + endings: [], + followUps: [], + isBackButtonHidden: false, + isSingleResponsePerEmailEnabled: false, + isVerifyEmailEnabled: false, + projectOverwrites: null, + runOnDate: null, + showLanguageSwitch: false, + questions: [], + displayOption: "displayOnce", + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + displayPercentage: null, + autoComplete: null, + singleUse: null, + triggers: [], + languages: [], + pin: null, + segment: null, + styling: null, + surveyClosedMessage: null, + hiddenFields: { enabled: false }, + welcomeCard: { enabled: false, showResponseCount: false, timeToFinish: false }, + variables: [], + createdBy: null, + recaptcha: { enabled: false, threshold: 0.5 }, + }, +]; + +const mockActionClasses: TActionClass[] = [ + { + id: "action-1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Action 1", + description: null, + type: "code", + noCodeConfig: null, + environmentId: environmentId, + key: "action1", + }, +]; + +const mockEnvironmentStateData: EnvironmentStateData = { + environment: { + id: environmentId, + type: "production", + appSetupCompleted: true, + project: mockProject, + }, + organization: { + id: mockOrganization.id, + billing: mockOrganization.billing, + }, + surveys: mockSurveys, + actionClasses: mockActionClasses, +}; + +describe("getEnvironmentState", () => { + beforeEach(() => { + vi.resetAllMocks(); + + // Mock withCache to simply execute the function without caching for tests + vi.mocked(withCache).mockImplementation((fn) => fn); + + // Default mocks for successful retrieval + vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData); + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("should return the correct environment state", async () => { + const result = await getEnvironmentState(environmentId); + + const expectedData: TJsEnvironmentState["data"] = { + recaptchaSiteKey: "mock_recaptcha_site_key", + surveys: mockSurveys, + actionClasses: mockActionClasses, + project: mockProject, + }; + + expect(result.data).toEqual(expectedData); + expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId); + expect(prisma.environment.update).not.toHaveBeenCalled(); + expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled(); + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id); + expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled(); + }); + + test("should throw ResourceNotFoundError if environment not found", async () => { + vi.mocked(getEnvironmentStateData).mockRejectedValue( + new ResourceNotFoundError("environment", environmentId) + ); + await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError); + }); + + test("should throw ResourceNotFoundError if organization not found", async () => { + vi.mocked(getEnvironmentStateData).mockRejectedValue(new ResourceNotFoundError("organization", null)); + await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError); + }); + + test("should throw ResourceNotFoundError if project not found", async () => { + vi.mocked(getEnvironmentStateData).mockRejectedValue(new ResourceNotFoundError("project", null)); + await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError); + }); + + test("should update environment and capture event if app setup not completed", async () => { + const incompleteEnvironmentData = { + ...mockEnvironmentStateData, + environment: { + ...mockEnvironmentStateData.environment, + appSetupCompleted: false, + }, + }; + vi.mocked(getEnvironmentStateData).mockResolvedValue(incompleteEnvironmentData); + + const result = await getEnvironmentState(environmentId); + + expect(prisma.environment.update).toHaveBeenCalledWith({ + where: { id: environmentId }, + data: { appSetupCompleted: true }, + }); + expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed"); + expect(result.data).toBeDefined(); + }); + + test("should return empty surveys if monthly response limit reached (Cloud)", async () => { + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100); // Exactly at limit + + const result = await getEnvironmentState(environmentId); + + expect(result.data.surveys).toEqual([]); + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id); + expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, { + plan: mockOrganization.billing.plan, + limits: { + projects: null, + monthly: { + miu: null, + responses: mockOrganization.billing.limits.monthly.responses, + }, + }, + }); + }); + + test("should return surveys if monthly response limit not reached (Cloud)", async () => { + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(99); // Below limit + + const result = await getEnvironmentState(environmentId); + + expect(result.data.surveys).toEqual(mockSurveys); + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id); + expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled(); + }); + + test("should handle error when sending Posthog limit reached event", async () => { + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100); + const posthogError = new Error("Posthog failed"); + vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError); + + const result = await getEnvironmentState(environmentId); + + expect(result.data.surveys).toEqual([]); + expect(logger.error).toHaveBeenCalledWith( + posthogError, + "Error sending plan limits reached event to Posthog" + ); + }); + + test("should include recaptchaSiteKey if recaptcha variables are set", async () => { + const result = await getEnvironmentState(environmentId); + + expect(result.data.recaptchaSiteKey).toBe("mock_recaptcha_site_key"); + }); + + test("should use withCache for caching with correct cache key and TTL", () => { + getEnvironmentState(environmentId); + + expect(withCache).toHaveBeenCalledWith(expect.any(Function), { + key: `fb:env:${environmentId}:state`, + ttl: 5 * 60 * 1000, // 5 minutes in milliseconds + }); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts index b269fbb99192..37dba3c236c6 100644 --- a/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts +++ b/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts @@ -1,128 +1,92 @@ -import { prisma } from "@formbricks/database"; -import { actionClassCache } from "@formbricks/lib/actionClass/cache"; -import { cache } from "@formbricks/lib/cache"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; -import { environmentCache } from "@formbricks/lib/environment/cache"; -import { getEnvironment } from "@formbricks/lib/environment/service"; -import { organizationCache } from "@formbricks/lib/organization/cache"; -import { - getMonthlyOrganizationResponseCount, - getOrganizationByEnvironmentId, -} from "@formbricks/lib/organization/service"; +import "server-only"; +import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants"; +import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service"; import { capturePosthogEnvironmentEvent, sendPlanLimitsReachedEventToPosthogWeekly, -} from "@formbricks/lib/posthogServer"; -import { projectCache } from "@formbricks/lib/project/cache"; -import { surveyCache } from "@formbricks/lib/survey/cache"; +} from "@/lib/posthogServer"; +import { createCacheKey } from "@/modules/cache/lib/cacheKeys"; +import { withCache } from "@/modules/cache/lib/withCache"; +import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; -import { ResourceNotFoundError } from "@formbricks/types/errors"; import { TJsEnvironmentState } from "@formbricks/types/js"; -import { getActionClassesForEnvironmentState } from "./actionClass"; -import { getProjectForEnvironmentState } from "./project"; -import { getSurveysForEnvironmentState } from "./survey"; +import { getEnvironmentStateData } from "./data"; /** + * Optimized environment state fetcher using new caching approach + * Uses withCache for Redis-backed caching with graceful fallback + * Single database query via optimized data service * - * @param environmentId + * @param environmentId - The environment ID to fetch state for * @returns The environment state - * @throws ResourceNotFoundError if the environment or organization does not exist + * @throws ResourceNotFoundError if environment, organization, or project not found */ export const getEnvironmentState = async ( environmentId: string -): Promise<{ data: TJsEnvironmentState["data"]; revalidateEnvironment?: boolean }> => - cache( +): Promise<{ data: TJsEnvironmentState["data"] }> => { + // Use withCache for efficient Redis caching with automatic fallback + const getCachedEnvironmentState = withCache( async () => { - let revalidateEnvironment = false; - const [environment, organization, project] = await Promise.all([ - getEnvironment(environmentId), - getOrganizationByEnvironmentId(environmentId), - getProjectForEnvironmentState(environmentId), - ]); - - if (!environment) { - throw new ResourceNotFoundError("environment", environmentId); - } - - if (!organization) { - throw new ResourceNotFoundError("organization", null); - } - - if (!project) { - throw new ResourceNotFoundError("project", null); - } + // Single optimized database call replacing multiple service calls + const { environment, organization, surveys, actionClasses } = + await getEnvironmentStateData(environmentId); + // Handle app setup completion update if needed + // This is a one-time setup flag that can tolerate TTL-based cache expiration if (!environment.appSetupCompleted) { await Promise.all([ prisma.environment.update({ - where: { - id: environmentId, - }, + where: { id: environmentId }, data: { appSetupCompleted: true }, }), capturePosthogEnvironmentEvent(environmentId, "app setup completed"), ]); - - revalidateEnvironment = true; } - // check if MAU limit is reached + // Check monthly response limits for Formbricks Cloud let isMonthlyResponsesLimitReached = false; - if (IS_FORMBRICKS_CLOUD) { const monthlyResponseLimit = organization.billing.limits.monthly.responses; - const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id); isMonthlyResponsesLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit; - } - if (isMonthlyResponsesLimitReached) { - try { - await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, { - plan: organization.billing.plan, - limits: { - projects: null, - monthly: { - miu: null, - responses: organization.billing.limits.monthly.responses, + // Send plan limits event if needed + if (isMonthlyResponsesLimitReached) { + try { + await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, { + plan: organization.billing.plan, + limits: { + projects: null, + monthly: { + miu: null, + responses: organization.billing.limits.monthly.responses, + }, }, - }, - }); - } catch (err) { - logger.error(err, "Error sending plan limits reached event to Posthog"); + }); + } catch (err) { + logger.error(err, "Error sending plan limits reached event to Posthog"); + } } } - const [surveys, actionClasses] = await Promise.all([ - getSurveysForEnvironmentState(environmentId), - getActionClassesForEnvironmentState(environmentId), - ]); - - const filteredSurveys = surveys.filter( - (survey) => survey.type === "app" && survey.status === "inProgress" - ); - + // Build the response data const data: TJsEnvironmentState["data"] = { - surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [], + surveys: !isMonthlyResponsesLimitReached ? surveys : [], actionClasses, - project: project, + project: environment.project, + ...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}), }; - return { - data, - revalidateEnvironment, - }; + return { data }; }, - [`environmentState-${environmentId}`], { - ...(IS_FORMBRICKS_CLOUD && { revalidate: 24 * 60 * 60 }), - tags: [ - environmentCache.tag.byId(environmentId), - organizationCache.tag.byEnvironmentId(environmentId), - projectCache.tag.byEnvironmentId(environmentId), - surveyCache.tag.byEnvironmentId(environmentId), - actionClassCache.tag.byEnvironmentId(environmentId), - ], + // Use enterprise-grade cache key pattern + key: createCacheKey.environment.state(environmentId), + // This is a temporary fix for the invalidation issues, will be changed later with a proper solution + ttl: 5 * 60 * 1000, // 5 minutes in milliseconds } - )(); + ); + + return getCachedEnvironmentState(); +}; diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/project.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/project.ts deleted file mode 100644 index 65da56f019eb..000000000000 --- a/apps/web/app/api/v1/client/[environmentId]/environment/lib/project.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Prisma } from "@prisma/client"; -import { cache as reactCache } from "react"; -import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { projectCache } from "@formbricks/lib/project/cache"; -import { validateInputs } from "@formbricks/lib/utils/validate"; -import { logger } from "@formbricks/logger"; -import { ZId } from "@formbricks/types/common"; -import { DatabaseError } from "@formbricks/types/errors"; -import { TJsEnvironmentStateProject } from "@formbricks/types/js"; - -export const getProjectForEnvironmentState = reactCache( - async (environmentId: string): Promise => - cache( - async () => { - validateInputs([environmentId, ZId]); - - try { - return await prisma.project.findFirst({ - where: { - environments: { - some: { - id: environmentId, - }, - }, - }, - select: { - id: true, - recontactDays: true, - clickOutsideClose: true, - darkOverlay: true, - placement: true, - inAppSurveyBranding: true, - styling: true, - }, - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - logger.error(error, "Error getting project for environment state"); - throw new DatabaseError(error.message); - } - throw error; - } - }, - [`getProjectForEnvironmentState-${environmentId}`], - { - tags: [projectCache.tag.byEnvironmentId(environmentId)], - } - )() -); diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/survey.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/survey.ts deleted file mode 100644 index f3761e3aa074..000000000000 --- a/apps/web/app/api/v1/client/[environmentId]/environment/lib/survey.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { transformPrismaSurvey } from "@/modules/survey/lib/utils"; -import { Prisma } from "@prisma/client"; -import { cache as reactCache } from "react"; -import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { surveyCache } from "@formbricks/lib/survey/cache"; -import { validateInputs } from "@formbricks/lib/utils/validate"; -import { logger } from "@formbricks/logger"; -import { ZId } from "@formbricks/types/common"; -import { DatabaseError } from "@formbricks/types/errors"; -import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; - -export const getSurveysForEnvironmentState = reactCache( - async (environmentId: string): Promise => - cache( - async () => { - validateInputs([environmentId, ZId]); - - try { - const surveysPrisma = await prisma.survey.findMany({ - where: { - environmentId, - }, - select: { - id: true, - welcomeCard: true, - name: true, - questions: true, - variables: true, - type: true, - showLanguageSwitch: true, - languages: { - select: { - default: true, - enabled: true, - language: { - select: { - id: true, - code: true, - alias: true, - createdAt: true, - updatedAt: true, - projectId: true, - }, - }, - }, - }, - endings: true, - autoClose: true, - styling: true, - status: true, - segment: { - include: { - surveys: { - select: { - id: true, - }, - }, - }, - }, - recontactDays: true, - displayLimit: true, - displayOption: true, - hiddenFields: true, - isBackButtonHidden: true, - triggers: { - select: { - actionClass: { - select: { - name: true, - }, - }, - }, - }, - displayPercentage: true, - delay: true, - projectOverwrites: true, - }, - }); - - return surveysPrisma.map((survey) => transformPrismaSurvey(survey)); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - logger.error(error, "Error getting surveys for environment state"); - throw new DatabaseError(error.message); - } - throw error; - } - }, - [`getSurveysForEnvironmentState-${environmentId}`], - { - tags: [surveyCache.tag.byEnvironmentId(environmentId)], - } - )() -); diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/route.ts b/apps/web/app/api/v1/client/[environmentId]/environment/route.ts index 0f993485956c..0af490e9c765 100644 --- a/apps/web/app/api/v1/client/[environmentId]/environment/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/environment/route.ts @@ -1,72 +1,85 @@ import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState"; import { responses } from "@/app/lib/api/response"; -import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { NextRequest } from "next/server"; -import { environmentCache } from "@formbricks/lib/environment/cache"; import { logger } from "@formbricks/logger"; import { ResourceNotFoundError } from "@formbricks/types/errors"; -import { ZJsSyncInput } from "@formbricks/types/js"; export const OPTIONS = async (): Promise => { - return responses.successResponse({}, true); + return responses.successResponse( + {}, + true, + // Cache CORS preflight responses for 1 hour (balanced approach) + // Allows for reasonable flexibility while still providing good performance + // max-age=3600: 1hr browser cache + // s-maxage=3600: 1hr Cloudflare cache + "public, s-maxage=3600, max-age=3600" + ); }; -export const GET = async ( - request: NextRequest, - props: { - params: Promise<{ - environmentId: string; - }>; - } -): Promise => { - const params = await props.params; - - try { - // validate using zod - const inputValidation = ZJsSyncInput.safeParse({ - environmentId: params.environmentId, - }); - - if (!inputValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(inputValidation.error), - true - ); - } +export const GET = withV1ApiWrapper({ + handler: async ({ + req, + props, + }: { + req: NextRequest; + props: { params: Promise<{ environmentId: string }> }; + }) => { + const params = await props.params; try { - const environmentState = await getEnvironmentState(params.environmentId); - const { data, revalidateEnvironment } = environmentState; - - if (revalidateEnvironment) { - environmentCache.revalidate({ - id: inputValidation.data.environmentId, - projectId: data.project.id, - }); + // Simple validation for environmentId (faster than Zod for high-frequency endpoint) + if (typeof params.environmentId !== "string") { + return { + response: responses.badRequestResponse("Environment ID is required", undefined, true), + }; } - return responses.successResponse( - { - data, - expiresAt: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes - }, - true, - "public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600" - ); + // Use optimized environment state fetcher with new caching approach + const environmentState = await getEnvironmentState(params.environmentId); + const { data } = environmentState; + + return { + response: responses.successResponse( + { + data, + expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck + }, + true, + // Optimized cache headers for Cloudflare CDN and browser caching + // max-age=3600: 1hr browser cache (per guidelines) + // s-maxage=1800: 30min Cloudflare cache (per guidelines) + // stale-while-revalidate=1800: 30min stale serving during revalidation + // stale-if-error=3600: 1hr stale serving on origin errors + "public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600" + ), + }; } catch (err) { if (err instanceof ResourceNotFoundError) { - return responses.notFoundResponse(err.resourceType, err.resourceId); + logger.warn( + { + environmentId: params.environmentId, + resourceType: err.resourceType, + resourceId: err.resourceId, + }, + "Resource not found in environment endpoint" + ); + return { + response: responses.notFoundResponse(err.resourceType, err.resourceId), + }; } logger.error( - { error: err, url: request.url }, + { + error: err, + url: req.url, + environmentId: params.environmentId, + }, "Error in GET /api/v1/client/[environmentId]/environment" ); - return responses.internalServerErrorResponse(err.message, true); + return { + response: responses.internalServerErrorResponse(err.message, true), + }; } - } catch (error) { - logger.error({ error, url: request.url }, "Error in GET /api/v1/client/[environmentId]/environment"); - return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true); - } -}; + }, +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts index bc54dcb4d782..03ccaecb89f4 100644 --- a/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/responses/[responseId]/route.ts @@ -1,8 +1,12 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { sendToPipeline } from "@/app/lib/pipelines"; -import { updateResponse } from "@formbricks/lib/response/service"; -import { getSurvey } from "@formbricks/lib/survey/service"; +import { validateFileUploads } from "@/lib/fileValidation"; +import { getResponse, updateResponse } from "@/lib/response/service"; +import { getSurvey } from "@/lib/survey/service"; +import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question"; +import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { ZResponseUpdateInput } from "@formbricks/types/responses"; @@ -11,84 +15,149 @@ export const OPTIONS = async (): Promise => { return responses.successResponse({}, true); }; -export const PUT = async ( - request: Request, - props: { params: Promise<{ responseId: string }> } -): Promise => { - const params = await props.params; - const { responseId } = params; - - if (!responseId) { - return responses.badRequestResponse("Response ID is missing", undefined, true); +const handleDatabaseError = (error: Error, url: string, endpoint: string, responseId: string): Response => { + if (error instanceof ResourceNotFoundError) { + return responses.notFoundResponse("Response", responseId, true); + } + if (error instanceof InvalidInputError) { + return responses.badRequestResponse(error.message, undefined, true); + } + if (error instanceof DatabaseError) { + logger.error({ error, url }, `Error in ${endpoint}`); + return responses.internalServerErrorResponse(error.message, true); } + return responses.internalServerErrorResponse("Unknown error occurred", true); +}; - const responseUpdate = await request.json(); +export const PUT = withV1ApiWrapper({ + handler: async ({ + req, + props, + }: { + req: NextRequest; + props: { params: Promise<{ responseId: string }> }; + }) => { + const params = await props.params; + const { responseId } = params; - const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate); + if (!responseId) { + return { + response: responses.badRequestResponse("Response ID is missing", undefined, true), + }; + } - if (!inputValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(inputValidation.error), - true - ); - } + const responseUpdate = await req.json(); + const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate); - // update response - let response; - try { - response = await updateResponse(responseId, inputValidation.data); - } catch (error) { - if (error instanceof ResourceNotFoundError) { - return responses.notFoundResponse("Response", responseId, true); + if (!inputValidation.success) { + return { + response: responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(inputValidation.error), + true + ), + }; } - if (error instanceof InvalidInputError) { - return responses.badRequestResponse(error.message); + + let response; + try { + response = await getResponse(responseId); + } catch (error) { + const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]"; + return { + response: handleDatabaseError(error, req.url, endpoint, responseId), + }; } - if (error instanceof DatabaseError) { - logger.error( - { error, url: request.url }, - "Error in PUT /api/v1/client/[environmentId]/responses/[responseId]" - ); - return responses.internalServerErrorResponse(error.message); + + if (response.finished) { + return { + response: responses.badRequestResponse("Response is already finished", undefined, true), + }; } - } - // get survey to get environmentId - let survey; - try { - survey = await getSurvey(response.surveyId); - } catch (error) { - if (error instanceof InvalidInputError) { - return responses.badRequestResponse(error.message); + // get survey to get environmentId + let survey; + try { + survey = await getSurvey(response.surveyId); + } catch (error) { + const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]"; + return { + response: handleDatabaseError(error, req.url, endpoint, responseId), + }; } - if (error instanceof DatabaseError) { - logger.error( - { error, url: request.url }, - "Error in PUT /api/v1/client/[environmentId]/responses/[responseId]" - ); - return responses.internalServerErrorResponse(error.message); + + if (!validateFileUploads(inputValidation.data.data, survey.questions)) { + return { + response: responses.badRequestResponse("Invalid file upload response", undefined, true), + }; } - } - // send response update to pipeline - // don't await to not block the response - sendToPipeline({ - event: "responseUpdated", - environmentId: survey.environmentId, - surveyId: survey.id, - response, - }); + // Validate response data for "other" options exceeding character limit + const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({ + responseData: inputValidation.data.data, + surveyQuestions: survey.questions, + responseLanguage: inputValidation.data.language, + }); + + if (otherResponseInvalidQuestionId) { + return { + response: responses.badRequestResponse( + `Response exceeds character limit`, + { + questionId: otherResponseInvalidQuestionId, + }, + true + ), + }; + } + + // update response + let updatedResponse; + try { + updatedResponse = await updateResponse(responseId, inputValidation.data); + } catch (error) { + if (error instanceof ResourceNotFoundError) { + return { + response: responses.notFoundResponse("Response", responseId, true), + }; + } + if (error instanceof InvalidInputError) { + return { + response: responses.badRequestResponse(error.message), + }; + } + if (error instanceof DatabaseError) { + logger.error( + { error, url: req.url }, + "Error in PUT /api/v1/client/[environmentId]/responses/[responseId]" + ); + return { + response: responses.internalServerErrorResponse(error.message), + }; + } + } - if (response.finished) { - // send response to pipeline + // send response update to pipeline // don't await to not block the response sendToPipeline({ - event: "responseFinished", + event: "responseUpdated", environmentId: survey.environmentId, surveyId: survey.id, - response: response, + response: updatedResponse, }); - } - return responses.successResponse({}, true); -}; + + if (updatedResponse.finished) { + // send response to pipeline + // don't await to not block the response + sendToPipeline({ + event: "responseFinished", + environmentId: survey.environmentId, + surveyId: survey.id, + response: updatedResponse, + }); + } + return { + response: responses.successResponse({}, true), + }; + }, +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/lib/contact.test.ts b/apps/web/app/api/v1/client/[environmentId]/responses/lib/contact.test.ts new file mode 100644 index 000000000000..e7737c9e36ec --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/responses/lib/contact.test.ts @@ -0,0 +1,150 @@ +import { Prisma } from "@prisma/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { getContact, getContactByUserId } from "./contact"; + +// Mock prisma +vi.mock("@formbricks/database", () => ({ + prisma: { + contact: { + findUnique: vi.fn(), + findFirst: vi.fn(), + }, + }, +})); + +// Mock react cache +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { + ...actual, + cache: vi.fn((fn) => fn), // Mock react's cache to just return the function + }; +}); + +const mockContactId = "test-contact-id"; +const mockEnvironmentId = "test-env-id"; +const mockUserId = "test-user-id"; + +describe("Contact API Lib", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("getContact", () => { + test("should return contact if found", async () => { + const mockContactData = { id: mockContactId }; + vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContactData); + + const contact = await getContact(mockContactId); + + expect(prisma.contact.findUnique).toHaveBeenCalledWith({ + where: { id: mockContactId }, + select: { id: true }, + }); + expect(contact).toEqual(mockContactData); + }); + + test("should return null if contact not found", async () => { + vi.mocked(prisma.contact.findUnique).mockResolvedValue(null); + + const contact = await getContact(mockContactId); + + expect(prisma.contact.findUnique).toHaveBeenCalledWith({ + where: { id: mockContactId }, + select: { id: true }, + }); + expect(contact).toBeNull(); + }); + + test("should throw DatabaseError on Prisma error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2025", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.contact.findUnique).mockRejectedValue(prismaError); + + await expect(getContact(mockContactId)).rejects.toThrow(DatabaseError); + expect(prisma.contact.findUnique).toHaveBeenCalledWith({ + where: { id: mockContactId }, + select: { id: true }, + }); + }); + }); + + describe("getContactByUserId", () => { + test("should return contact with formatted attributes if found", async () => { + const mockContactData = { + id: mockContactId, + attributes: [ + { attributeKey: { key: "userId" }, value: mockUserId }, + { attributeKey: { key: "email" }, value: "test@example.com" }, + ], + }; + vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactData); + + const contact = await getContactByUserId(mockEnvironmentId, mockUserId); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId: mockEnvironmentId, + }, + value: mockUserId, + }, + }, + }, + select: { + id: true, + attributes: { + select: { + attributeKey: { select: { key: true } }, + value: true, + }, + }, + }, + }); + expect(contact).toEqual({ + id: mockContactId, + attributes: { + userId: mockUserId, + email: "test@example.com", + }, + }); + }); + + test("should return null if contact not found by userId", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(null); + + const contact = await getContactByUserId(mockEnvironmentId, mockUserId); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId: mockEnvironmentId, + }, + value: mockUserId, + }, + }, + }, + select: { + id: true, + attributes: { + select: { + attributeKey: { select: { key: true } }, + value: true, + }, + }, + }, + }); + expect(contact).toBeNull(); + }); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/lib/contact.ts b/apps/web/app/api/v1/client/[environmentId]/responses/lib/contact.ts index e34b987b05ef..8b435eb19695 100644 --- a/apps/web/app/api/v1/client/[environmentId]/responses/lib/contact.ts +++ b/apps/web/app/api/v1/client/[environmentId]/responses/lib/contact.ts @@ -1,84 +1,67 @@ -import { contactCache } from "@/lib/cache/contact"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; import { TContactAttributes } from "@formbricks/types/contact-attribute"; import { DatabaseError } from "@formbricks/types/errors"; -export const getContact = reactCache((contactId: string) => - cache( - async () => { - try { - const contact = await prisma.contact.findUnique({ - where: { id: contactId }, - select: { id: true }, - }); +export const getContact = reactCache(async (contactId: string) => { + try { + const contact = await prisma.contact.findUnique({ + where: { id: contactId }, + select: { id: true }, + }); - return contact; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - } - }, - [`getContact-responses-api-${contactId}`], - { - tags: [contactCache.tag.byId(contactId)], + return contact; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )() -); + } +}); export const getContactByUserId = reactCache( - ( + async ( environmentId: string, userId: string ): Promise<{ id: string; attributes: TContactAttributes; - } | null> => - cache( - async () => { - const contact = await prisma.contact.findFirst({ - where: { - attributes: { - some: { - attributeKey: { - key: "userId", - environmentId, - }, - value: userId, - }, + } | null> => { + const contact = await prisma.contact.findFirst({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId, }, + value: userId, }, + }, + }, + select: { + id: true, + attributes: { select: { - id: true, - attributes: { - select: { - attributeKey: { select: { key: true } }, - value: true, - }, - }, + attributeKey: { select: { key: true } }, + value: true, }, - }); + }, + }, + }); - if (!contact) { - return null; - } + if (!contact) { + return null; + } - const contactAttributes = contact.attributes.reduce((acc, attr) => { - acc[attr.attributeKey.key] = attr.value; - return acc; - }, {}) as TContactAttributes; + const contactAttributes = contact.attributes.reduce((acc, attr) => { + acc[attr.attributeKey.key] = attr.value; + return acc; + }, {}) as TContactAttributes; - return { - id: contact.id, - attributes: contactAttributes, - }; - }, - [`getContactByUserIdForResponsesApi-${environmentId}-${userId}`], - { - tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)], - } - )() + return { + id: contact.id, + attributes: contactAttributes, + }; + } ); diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.test.ts b/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.test.ts new file mode 100644 index 000000000000..be80b05d2e99 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.test.ts @@ -0,0 +1,188 @@ +import { + getMonthlyOrganizationResponseCount, + getOrganizationByEnvironmentId, +} from "@/lib/organization/service"; +import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer"; +import { calculateTtcTotal } from "@/lib/response/utils"; +import { Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TResponseInput } from "@formbricks/types/responses"; +import { createResponse } from "./response"; + +let mockIsFormbricksCloud = false; + +vi.mock("@/lib/constants", () => ({ + get IS_FORMBRICKS_CLOUD() { + return mockIsFormbricksCloud; + }, +})); + +vi.mock("@/lib/organization/service", () => ({ + getMonthlyOrganizationResponseCount: vi.fn(), + getOrganizationByEnvironmentId: vi.fn(), +})); + +vi.mock("@/lib/posthogServer", () => ({ + sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(), +})); + +vi.mock("@/lib/response/utils", () => ({ + calculateTtcTotal: vi.fn((ttc) => ttc), +})); + +vi.mock("@/lib/telemetry", () => ({ + captureTelemetry: vi.fn(), +})); + +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + response: { + create: vi.fn(), + }, + }, +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +vi.mock("./contact", () => ({ + getContactByUserId: vi.fn(), +})); + +const environmentId = "test-environment-id"; +const surveyId = "test-survey-id"; +const organizationId = "test-organization-id"; +const responseId = "test-response-id"; + +const mockOrganization = { + id: organizationId, + name: "Test Org", + billing: { + limits: { monthly: { responses: 100 } }, + plan: "free", + }, +}; + +const mockResponseInput: TResponseInput = { + environmentId, + surveyId, + userId: null, + finished: false, + data: { question1: "answer1" }, + meta: { source: "web" }, + ttc: { question1: 1000 }, +}; + +const mockResponsePrisma = { + id: responseId, + createdAt: new Date(), + updatedAt: new Date(), + surveyId, + finished: false, + data: { question1: "answer1" }, + meta: { source: "web" }, + ttc: { question1: 1000 }, + variables: {}, + contactAttributes: {}, + singleUseId: null, + language: null, + displayId: null, + tags: [], +}; + +describe("createResponse", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any); + vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma as any); + vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ttc); + }); + + afterEach(() => { + mockIsFormbricksCloud = false; + }); + + test("should handle finished response and calculate TTC", async () => { + const finishedInput = { ...mockResponseInput, finished: true }; + await createResponse(finishedInput); + expect(calculateTtcTotal).toHaveBeenCalledWith(mockResponseInput.ttc); + expect(prisma.response.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ finished: true }), + }) + ); + }); + + test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => { + mockIsFormbricksCloud = true; + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); + + await createResponse(mockResponseInput); + + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); + expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled(); + }); + + test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => { + mockIsFormbricksCloud = true; + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100); + + await createResponse(mockResponseInput); + + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); + expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, { + plan: "free", + limits: { + projects: null, + monthly: { + responses: 100, + miu: null, + }, + }, + }); + }); + + test("should throw ResourceNotFoundError if organization not found", async () => { + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); + await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError); + }); + + test("should throw DatabaseError on Prisma known request error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2002", + clientVersion: "test", + }); + vi.mocked(prisma.response.create).mockRejectedValue(prismaError); + await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError); + }); + + test("should throw original error on other Prisma errors", async () => { + const genericError = new Error("Generic database error"); + vi.mocked(prisma.response.create).mockRejectedValue(genericError); + await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError); + }); + + test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => { + mockIsFormbricksCloud = true; + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100); + const posthogError = new Error("PostHog error"); + vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError); + + await createResponse(mockResponseInput); + + expect(logger.error).toHaveBeenCalledWith( + posthogError, + "Error sending plan limits reached event to Posthog" + ); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.ts b/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.ts index d961371381a4..16035acdce74 100644 --- a/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.ts +++ b/apps/web/app/api/v1/client/[environmentId]/responses/lib/response.ts @@ -1,17 +1,15 @@ import "server-only"; -import { Prisma } from "@prisma/client"; -import { prisma } from "@formbricks/database"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getMonthlyOrganizationResponseCount, getOrganizationByEnvironmentId, -} from "@formbricks/lib/organization/service"; -import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer"; -import { responseCache } from "@formbricks/lib/response/cache"; -import { calculateTtcTotal } from "@formbricks/lib/response/utils"; -import { responseNoteCache } from "@formbricks/lib/responseNote/cache"; -import { captureTelemetry } from "@formbricks/lib/telemetry"; -import { validateInputs } from "@formbricks/lib/utils/validate"; +} from "@/lib/organization/service"; +import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer"; +import { calculateTtcTotal } from "@/lib/response/utils"; +import { captureTelemetry } from "@/lib/telemetry"; +import { validateInputs } from "@/lib/utils/validate"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; import { TContactAttributes } from "@formbricks/types/contact-attribute"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; @@ -33,6 +31,7 @@ export const responseSelection = { singleUseId: true, language: true, displayId: true, + endingId: true, contact: { select: { id: true, @@ -54,22 +53,6 @@ export const responseSelection = { }, }, }, - notes: { - select: { - id: true, - createdAt: true, - updatedAt: true, - text: true, - user: { - select: { - id: true, - name: true, - }, - }, - isResolved: true, - isEdited: true, - }, - }, } satisfies Prisma.ResponseSelect; export const createResponse = async (responseInput: TResponseInput): Promise => { @@ -148,19 +131,6 @@ export const createResponse = async (responseInput: TResponseInput): Promise tagPrisma.tag), }; - responseCache.revalidate({ - environmentId, - id: response.id, - contactId: contact?.id, - ...(singleUseId && { singleUseId }), - userId: userId ?? undefined, - surveyId, - }); - - responseNoteCache.revalidate({ - responseId: response.id, - }); - if (IS_FORMBRICKS_CLOUD) { const responsesCount = await getMonthlyOrganizationResponseCount(organization.id); const responsesLimit = organization.billing.limits.monthly.responses; diff --git a/apps/web/app/api/v1/client/[environmentId]/responses/route.ts b/apps/web/app/api/v1/client/[environmentId]/responses/route.ts index b49186bd7868..77e5de1875ab 100644 --- a/apps/web/app/api/v1/client/[environmentId]/responses/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/responses/route.ts @@ -1,11 +1,14 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { sendToPipeline } from "@/app/lib/pipelines"; +import { validateFileUploads } from "@/lib/fileValidation"; +import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer"; +import { getSurvey } from "@/lib/survey/service"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { headers } from "next/headers"; +import { NextRequest } from "next/server"; import { UAParser } from "ua-parser-js"; -import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer"; -import { getSurvey } from "@formbricks/lib/survey/service"; import { logger } from "@formbricks/logger"; import { ZId } from "@formbricks/types/common"; import { InvalidInputError } from "@formbricks/types/errors"; @@ -19,120 +22,159 @@ interface Context { } export const OPTIONS = async (): Promise => { - return responses.successResponse({}, true); + return responses.successResponse( + {}, + true, + // Cache CORS preflight responses for 1 hour (conservative approach) + // Balances performance gains with flexibility for CORS policy changes + "public, s-maxage=3600, max-age=3600" + ); }; -export const POST = async (request: Request, context: Context): Promise => { - const params = await context.params; - const requestHeaders = await headers(); - let responseInput; - try { - responseInput = await request.json(); - } catch (error) { - return responses.badRequestResponse("Invalid JSON in request body", { error: error.message }, true); - } - - const { environmentId } = params; - const environmentIdValidation = ZId.safeParse(environmentId); - const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId }); - - if (!environmentIdValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(environmentIdValidation.error), - true - ); - } - - if (!responseInputValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(responseInputValidation.error), - true - ); - } - - const userAgent = request.headers.get("user-agent") || undefined; - const agent = new UAParser(userAgent); - - const country = - requestHeaders.get("CF-IPCountry") || - requestHeaders.get("X-Vercel-IP-Country") || - requestHeaders.get("CloudFront-Viewer-Country") || - undefined; - - const responseInputData = responseInputValidation.data; - - if (responseInputData.userId) { - const isContactsEnabled = await getIsContactsEnabled(); - if (!isContactsEnabled) { - return responses.forbiddenResponse("User identification is only available for enterprise users.", true); +export const POST = withV1ApiWrapper({ + handler: async ({ req, props }: { req: NextRequest; props: Context }) => { + const params = await props.params; + const requestHeaders = await headers(); + let responseInput; + try { + responseInput = await req.json(); + } catch (error) { + return { + response: responses.badRequestResponse( + "Invalid JSON in request body", + { error: error.message }, + true + ), + }; } - } - - // get and check survey - const survey = await getSurvey(responseInputData.surveyId); - if (!survey) { - return responses.notFoundResponse("Survey", responseInputData.surveyId, true); - } - if (survey.environmentId !== environmentId) { - return responses.badRequestResponse( - "Survey is part of another environment", - { - "survey.environmentId": survey.environmentId, - environmentId, - }, - true - ); - } - - let response: TResponse; - try { - const meta: TResponseInput["meta"] = { - source: responseInputData?.meta?.source, - url: responseInputData?.meta?.url, - userAgent: { - browser: agent.getBrowser().name, - device: agent.getDevice().type || "desktop", - os: agent.getOS().name, - }, - country: country, - action: responseInputData?.meta?.action, - }; - response = await createResponse({ - ...responseInputData, - meta, - }); - } catch (error) { - if (error instanceof InvalidInputError) { - return responses.badRequestResponse(error.message); - } else { - logger.error({ error, url: request.url }, "Error creating response"); - return responses.internalServerErrorResponse(error.message); + const { environmentId } = params; + const environmentIdValidation = ZId.safeParse(environmentId); + const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId }); + + if (!environmentIdValidation.success) { + return { + response: responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(environmentIdValidation.error), + true + ), + }; + } + + if (!responseInputValidation.success) { + return { + response: responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(responseInputValidation.error), + true + ), + }; + } + + const userAgent = req.headers.get("user-agent") || undefined; + const agent = new UAParser(userAgent); + + const country = + requestHeaders.get("CF-IPCountry") || + requestHeaders.get("X-Vercel-IP-Country") || + requestHeaders.get("CloudFront-Viewer-Country") || + undefined; + + const responseInputData = responseInputValidation.data; + + if (responseInputData.userId) { + const isContactsEnabled = await getIsContactsEnabled(); + if (!isContactsEnabled) { + return { + response: responses.forbiddenResponse( + "User identification is only available for enterprise users.", + true + ), + }; + } + } + + // get and check survey + const survey = await getSurvey(responseInputData.surveyId); + if (!survey) { + return { + response: responses.notFoundResponse("Survey", responseInputData.surveyId, true), + }; + } + if (survey.environmentId !== environmentId) { + return { + response: responses.badRequestResponse( + "Survey is part of another environment", + { + "survey.environmentId": survey.environmentId, + environmentId, + }, + true + ), + }; } - } - sendToPipeline({ - event: "responseCreated", - environmentId: survey.environmentId, - surveyId: response.surveyId, - response: response, - }); + if (!validateFileUploads(responseInputData.data, survey.questions)) { + return { + response: responses.badRequestResponse("Invalid file upload response"), + }; + } + + let response: TResponse; + try { + const meta: TResponseInput["meta"] = { + source: responseInputData?.meta?.source, + url: responseInputData?.meta?.url, + userAgent: { + browser: agent.getBrowser().name, + device: agent.getDevice().type || "desktop", + os: agent.getOS().name, + }, + country: country, + action: responseInputData?.meta?.action, + }; + + response = await createResponse({ + ...responseInputData, + meta, + }); + } catch (error) { + if (error instanceof InvalidInputError) { + return { + response: responses.badRequestResponse(error.message), + }; + } else { + logger.error({ error, url: req.url }, "Error creating response"); + return { + response: responses.internalServerErrorResponse(error.message), + }; + } + } - if (responseInput.finished) { sendToPipeline({ - event: "responseFinished", + event: "responseCreated", environmentId: survey.environmentId, surveyId: response.surveyId, response: response, }); - } - await capturePosthogEnvironmentEvent(survey.environmentId, "response created", { - surveyId: response.surveyId, - surveyType: survey.type, - }); + if (responseInput.finished) { + sendToPipeline({ + event: "responseFinished", + environmentId: survey.environmentId, + surveyId: response.surveyId, + response: response, + }); + } - return responses.successResponse({ id: response.id }, true); -}; + await capturePosthogEnvironmentEvent(survey.environmentId, "response created", { + surveyId: response.surveyId, + surveyType: survey.type, + }); + + return { + response: responses.successResponse({ id: response.id }, true), + }; + }, +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.test.ts b/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.test.ts new file mode 100644 index 000000000000..cad4f776af3d --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.test.ts @@ -0,0 +1,103 @@ +import { getUploadSignedUrl } from "@/lib/storage/service"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { uploadPrivateFile } from "./uploadPrivateFile"; + +vi.mock("@/lib/storage/service", () => ({ + getUploadSignedUrl: vi.fn(), +})); + +describe("uploadPrivateFile", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + test("should return a success response with signed URL details when getUploadSignedUrl successfully generates a signed URL", async () => { + const mockSignedUrlResponse = { + signedUrl: "mocked-signed-url", + presignedFields: { field1: "value1" }, + fileUrl: "mocked-file-url", + }; + + vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse); + + const fileName = "test-file.txt"; + const environmentId = "test-env-id"; + const fileType = "text/plain"; + + const result = await uploadPrivateFile(fileName, environmentId, fileType); + const resultData = await result.json(); + + expect(getUploadSignedUrl).toHaveBeenCalledWith(fileName, environmentId, fileType, "private", false); + + expect(resultData).toEqual({ + data: mockSignedUrlResponse, + }); + }); + + test("should return a success response when isBiggerFileUploadAllowed is true and getUploadSignedUrl successfully generates a signed URL", async () => { + const mockSignedUrlResponse = { + signedUrl: "mocked-signed-url", + presignedFields: { field1: "value1" }, + fileUrl: "mocked-file-url", + }; + + vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse); + + const fileName = "test-file.txt"; + const environmentId = "test-env-id"; + const fileType = "text/plain"; + const isBiggerFileUploadAllowed = true; + + const result = await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed); + const resultData = await result.json(); + + expect(getUploadSignedUrl).toHaveBeenCalledWith( + fileName, + environmentId, + fileType, + "private", + isBiggerFileUploadAllowed + ); + + expect(resultData).toEqual({ + data: mockSignedUrlResponse, + }); + }); + + test("should return an internal server error response when getUploadSignedUrl throws an error", async () => { + vi.mocked(getUploadSignedUrl).mockRejectedValue(new Error("S3 unavailable")); + + const fileName = "test-file.txt"; + const environmentId = "test-env-id"; + const fileType = "text/plain"; + + const result = await uploadPrivateFile(fileName, environmentId, fileType); + + expect(result.status).toBe(500); + const resultData = await result.json(); + expect(resultData).toEqual({ + code: "internal_server_error", + details: {}, + message: "Internal server error", + }); + }); + + test("should return an internal server error response when fileName has no extension", async () => { + vi.mocked(getUploadSignedUrl).mockRejectedValue(new Error("File extension not found")); + + const fileName = "test-file"; + const environmentId = "test-env-id"; + const fileType = "text/plain"; + + const result = await uploadPrivateFile(fileName, environmentId, fileType); + const resultData = await result.json(); + + expect(getUploadSignedUrl).toHaveBeenCalledWith(fileName, environmentId, fileType, "private", false); + expect(result.status).toBe(500); + expect(resultData).toEqual({ + code: "internal_server_error", + details: {}, + message: "Internal server error", + }); + }); +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.ts b/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.ts index d884b5527ddb..0db11e8932ee 100644 --- a/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.ts +++ b/apps/web/app/api/v1/client/[environmentId]/storage/lib/uploadPrivateFile.ts @@ -1,5 +1,5 @@ import { responses } from "@/app/lib/api/response"; -import { getUploadSignedUrl } from "@formbricks/lib/storage/service"; +import { getUploadSignedUrl } from "@/lib/storage/service"; export const uploadPrivateFile = async ( fileName: string, diff --git a/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts b/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts index 36bbfd3bb896..ab4499185e80 100644 --- a/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/storage/local/route.ts @@ -2,13 +2,15 @@ // body -> should be a valid file object (buffer) // method -> PUT (to be the same as the signedUrl method) import { responses } from "@/app/lib/api/response"; +import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants"; +import { validateLocalSignedUrl } from "@/lib/crypto"; +import { validateFile } from "@/lib/fileValidation"; +import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import { putFileToLocalStorage } from "@/lib/storage/service"; +import { getSurvey } from "@/lib/survey/service"; import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils"; import { NextRequest } from "next/server"; -import { ENCRYPTION_KEY, UPLOADS_DIR } from "@formbricks/lib/constants"; -import { validateLocalSignedUrl } from "@formbricks/lib/crypto"; -import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { putFileToLocalStorage } from "@formbricks/lib/storage/service"; -import { getSurvey } from "@formbricks/lib/survey/service"; import { logger } from "@formbricks/logger"; interface Context { @@ -18,121 +20,159 @@ interface Context { } export const OPTIONS = async (): Promise => { - return Response.json( + return responses.successResponse( {}, - { - headers: { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - }, - } + true, + // Cache CORS preflight responses for 1 hour (conservative approach) + // Balances performance gains with flexibility for CORS policy changes + "public, s-maxage=3600, max-age=3600" ); }; -export const POST = async (req: NextRequest, context: Context): Promise => { - if (!ENCRYPTION_KEY) { - return responses.internalServerErrorResponse("Encryption key is not set"); - } - const params = await context.params; - const environmentId = params.environmentId; - - const accessType = "private"; // private files are accessible only by authorized users - - const jsonInput = await req.json(); - const fileType = jsonInput.fileType as string; - const encodedFileName = jsonInput.fileName as string; - const surveyId = jsonInput.surveyId as string; - const signedSignature = jsonInput.signature as string; - const signedUuid = jsonInput.uuid as string; - const signedTimestamp = jsonInput.timestamp as string; - - if (!fileType) { - return responses.badRequestResponse("contentType is required"); - } - - if (!encodedFileName) { - return responses.badRequestResponse("fileName is required"); - } - - if (!surveyId) { - return responses.badRequestResponse("surveyId is required"); - } - - if (!signedSignature) { - return responses.unauthorizedResponse(); - } - - if (!signedUuid) { - return responses.unauthorizedResponse(); - } - - if (!signedTimestamp) { - return responses.unauthorizedResponse(); - } - - const [survey, organization] = await Promise.all([ - getSurvey(surveyId), - getOrganizationByEnvironmentId(environmentId), - ]); - - if (!survey) { - return responses.notFoundResponse("Survey", surveyId); - } - - if (!organization) { - return responses.notFoundResponse("OrganizationByEnvironmentId", environmentId); - } - - const fileName = decodeURIComponent(encodedFileName); - - // validate signature - - const validated = validateLocalSignedUrl( - signedUuid, - fileName, - environmentId, - fileType, - Number(signedTimestamp), - signedSignature, - ENCRYPTION_KEY - ); +export const POST = withV1ApiWrapper({ + handler: async ({ req, props }: { req: NextRequest; props: Context }) => { + if (!ENCRYPTION_KEY) { + return { + response: responses.internalServerErrorResponse("Encryption key is not set"), + }; + } + const params = await props.params; + const environmentId = params.environmentId; + + const accessType = "private"; // private files are accessible only by authorized users + + const jsonInput = await req.json(); + const fileType = jsonInput.fileType as string; + const encodedFileName = jsonInput.fileName as string; + const surveyId = jsonInput.surveyId as string; + const signedSignature = jsonInput.signature as string; + const signedUuid = jsonInput.uuid as string; + const signedTimestamp = jsonInput.timestamp as string; + + if (!fileType) { + return { + response: responses.badRequestResponse("contentType is required"), + }; + } + + if (!encodedFileName) { + return { + response: responses.badRequestResponse("fileName is required"), + }; + } - if (!validated) { - return responses.unauthorizedResponse(); - } + if (!surveyId) { + return { + response: responses.badRequestResponse("surveyId is required"), + }; + } + + if (!signedSignature) { + return { + response: responses.unauthorizedResponse(), + }; + } - const base64String = jsonInput.fileBase64String as string; + if (!signedUuid) { + return { + response: responses.unauthorizedResponse(), + }; + } - const buffer = Buffer.from(base64String.split(",")[1], "base64"); - const file = new Blob([buffer], { type: fileType }); + if (!signedTimestamp) { + return { + response: responses.unauthorizedResponse(), + }; + } - if (!file) { - return responses.badRequestResponse("fileBuffer is required"); - } + const [survey, organization] = await Promise.all([ + getSurvey(surveyId), + getOrganizationByEnvironmentId(environmentId), + ]); - try { - const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan); - const bytes = await file.arrayBuffer(); - const fileBuffer = Buffer.from(bytes); + if (!survey) { + return { + response: responses.notFoundResponse("Survey", surveyId), + }; + } - await putFileToLocalStorage( + if (!organization) { + return { + response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId), + }; + } + + const fileName = decodeURIComponent(encodedFileName); + + // Perform server-side file validation again + // This is crucial as attackers could bypass the initial validation and directly call this endpoint + const fileValidation = validateFile(fileName, fileType); + if (!fileValidation.valid) { + return { + response: responses.badRequestResponse(fileValidation.error ?? "Invalid file", { + fileName, + fileType, + }), + }; + } + + // validate signature + const validated = validateLocalSignedUrl( + signedUuid, fileName, - fileBuffer, - accessType, environmentId, - UPLOADS_DIR, - isBiggerFileUploadAllowed + fileType, + Number(signedTimestamp), + signedSignature, + ENCRYPTION_KEY ); - return responses.successResponse({ - message: "File uploaded successfully", - }); - } catch (err) { - logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload"); - if (err.name === "FileTooLargeError") { - return responses.badRequestResponse(err.message); + if (!validated) { + return { + response: responses.unauthorizedResponse(), + }; } - return responses.internalServerErrorResponse("File upload failed"); - } -}; + + const base64String = jsonInput.fileBase64String as string; + + const buffer = Buffer.from(base64String.split(",")[1], "base64"); + const file = new Blob([buffer], { type: fileType }); + + if (!file) { + return { + response: responses.badRequestResponse("fileBuffer is required"), + }; + } + + try { + const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan); + const bytes = await file.arrayBuffer(); + const fileBuffer = Buffer.from(bytes); + + await putFileToLocalStorage( + fileName, + fileBuffer, + accessType, + environmentId, + UPLOADS_DIR, + isBiggerFileUploadAllowed + ); + + return { + response: responses.successResponse({ + message: "File uploaded successfully", + }), + }; + } catch (err) { + logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload"); + if (err.name === "FileTooLargeError") { + return { + response: responses.badRequestResponse(err.message), + }; + } + return { + response: responses.internalServerErrorResponse("File upload failed"), + }; + } + }, +}); diff --git a/apps/web/app/api/v1/client/[environmentId]/storage/route.ts b/apps/web/app/api/v1/client/[environmentId]/storage/route.ts index d449db85f6e9..5718dee2636f 100644 --- a/apps/web/app/api/v1/client/[environmentId]/storage/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/storage/route.ts @@ -1,9 +1,11 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { validateFile } from "@/lib/fileValidation"; +import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import { getSurvey } from "@/lib/survey/service"; import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils"; import { NextRequest } from "next/server"; -import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { getSurvey } from "@formbricks/lib/survey/service"; import { ZUploadFileRequest } from "@formbricks/types/storage"; import { uploadPrivateFile } from "./lib/uploadPrivateFile"; @@ -14,7 +16,13 @@ interface Context { } export const OPTIONS = async (): Promise => { - return responses.successResponse({}, true); + return responses.successResponse( + {}, + true, + // Cache CORS preflight responses for 1 hour (conservative approach) + // Balances performance gains with flexibility for CORS policy changes + "public, s-maxage=3600, max-age=3600" + ); }; // api endpoint for uploading private files @@ -23,41 +31,62 @@ export const OPTIONS = async (): Promise => { // use this to let users upload files to a survey for example // this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage -export const POST = async (req: NextRequest, context: Context): Promise => { - const params = await context.params; - const environmentId = params.environmentId; +export const POST = withV1ApiWrapper({ + handler: async ({ req, props }: { req: NextRequest; props: Context }) => { + const params = await props.params; + const environmentId = params.environmentId; - const jsonInput = await req.json(); + const jsonInput = await req.json(); + const inputValidation = ZUploadFileRequest.safeParse({ + ...jsonInput, + environmentId, + }); - const inputValidation = ZUploadFileRequest.safeParse({ - ...jsonInput, - environmentId, - }); + if (!inputValidation.success) { + return { + response: responses.badRequestResponse( + "Invalid request", + transformErrorToDetails(inputValidation.error), + true + ), + }; + } - if (!inputValidation.success) { - return responses.badRequestResponse( - "Invalid request", - transformErrorToDetails(inputValidation.error), - true - ); - } + const { fileName, fileType, surveyId } = inputValidation.data; - const { fileName, fileType, surveyId } = inputValidation.data; + // Perform server-side file validation + const fileValidation = validateFile(fileName, fileType); + if (!fileValidation.valid) { + return { + response: responses.badRequestResponse( + fileValidation.error ?? "Invalid file", + { fileName, fileType }, + true + ), + }; + } - const [survey, organization] = await Promise.all([ - getSurvey(surveyId), - getOrganizationByEnvironmentId(environmentId), - ]); + const [survey, organization] = await Promise.all([ + getSurvey(surveyId), + getOrganizationByEnvironmentId(environmentId), + ]); - if (!survey) { - return responses.notFoundResponse("Survey", surveyId); - } + if (!survey) { + return { + response: responses.notFoundResponse("Survey", surveyId), + }; + } - if (!organization) { - return responses.notFoundResponse("OrganizationByEnvironmentId", environmentId); - } + if (!organization) { + return { + response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId), + }; + } - const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan); + const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan); - return await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed); -}; + return { + response: await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed), + }; + }, +}); diff --git a/apps/web/app/api/v1/client/og/route.tsx b/apps/web/app/api/v1/client/og/route.tsx new file mode 100644 index 000000000000..092a01663438 --- /dev/null +++ b/apps/web/app/api/v1/client/og/route.tsx @@ -0,0 +1,151 @@ +import { ImageResponse } from "@vercel/og"; +import { NextRequest } from "next/server"; + +export const GET = async (req: NextRequest) => { + let name = req.nextUrl.searchParams.get("name"); + let brandColor = req.nextUrl.searchParams.get("brandColor"); + + return new ImageResponse( + ( +
+
+
+
+
+
+
+

+ {name} +

+
+
+
+
+
+
+
+
+ Begin! +
+
+
+
+
+
+ ), + { + width: 800, + height: 400, + headers: { + "Cache-Control": "public, s-maxage=600, max-age=1800, stale-while-revalidate=600, stale-if-error=600", + }, + } + ); +}; diff --git a/apps/web/app/api/v1/integrations/airtable/callback/route.ts b/apps/web/app/api/v1/integrations/airtable/callback/route.ts index df441e79dfac..60d32500fc2b 100644 --- a/apps/web/app/api/v1/integrations/airtable/callback/route.ts +++ b/apps/web/app/api/v1/integrations/airtable/callback/route.ts @@ -1,12 +1,11 @@ import { responses } from "@/app/lib/api/response"; -import { authOptions } from "@/modules/auth/lib/authOptions"; -import { getServerSession } from "next-auth"; +import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { fetchAirtableAuthToken } from "@/lib/airtable/service"; +import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants"; +import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; +import { createOrUpdateIntegration } from "@/lib/integration/service"; import { NextRequest } from "next/server"; import * as z from "zod"; -import { fetchAirtableAuthToken } from "@formbricks/lib/airtable/service"; -import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; -import { createOrUpdateIntegration } from "@formbricks/lib/integration/service"; import { logger } from "@formbricks/logger"; const getEmail = async (token: string) => { @@ -21,65 +20,83 @@ const getEmail = async (token: string) => { return z.string().parse(res_?.email); }; -export const GET = async (req: NextRequest) => { - const url = req.url; - const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters - const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter - const code = queryParams.get("code"); - const session = await getServerSession(authOptions); +export const GET = withV1ApiWrapper({ + handler: async ({ + req, + authentication, + }: { + req: NextRequest; + authentication: NonNullable; + }) => { + const url = req.url; + const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters + const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter + const code = queryParams.get("code"); - if (!environmentId) { - return responses.badRequestResponse("Invalid environmentId"); - } + if (!environmentId) { + return { + response: responses.badRequestResponse("Invalid environmentId"), + }; + } - if (!session) { - return responses.notAuthenticatedResponse(); - } + if (!code) { + return { + response: responses.badRequestResponse("`code` is missing"), + }; + } + const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId); + if (!canUserAccessEnvironment) { + return { + response: responses.unauthorizedResponse(), + }; + } - if (code && typeof code !== "string") { - return responses.badRequestResponse("`code` must be a string"); - } - const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId); - if (!canUserAccessEnvironment) { - return responses.unauthorizedResponse(); - } + const client_id = AIRTABLE_CLIENT_ID; + const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback"; + const code_verifier = Buffer.from(environmentId + authentication.user.id + environmentId).toString( + "base64" + ); - const client_id = AIRTABLE_CLIENT_ID; - const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback"; - const code_verifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64"); + if (!client_id) + return { + response: responses.internalServerErrorResponse("Airtable client id is missing"), + }; - if (!client_id) return responses.internalServerErrorResponse("Airtable client id is missing"); - if (!redirect_uri) return responses.internalServerErrorResponse("Airtable redirect url is missing"); + const formData = { + grant_type: "authorization_code", + code, + redirect_uri, + client_id, + code_verifier, + }; - const formData = { - grant_type: "authorization_code", - code, - redirect_uri, - client_id, - code_verifier, - }; + try { + const key = await fetchAirtableAuthToken(formData); + if (!key) { + return { + response: responses.notFoundResponse("airtable auth token", key), + }; + } + const email = await getEmail(key.access_token); - try { - const key = await fetchAirtableAuthToken(formData); - if (!key) { - return responses.notFoundResponse("airtable auth token", key); + const airtableIntegrationInput = { + type: "airtable" as "airtable", + environment: environmentId, + config: { + key, + data: [], + email, + }, + }; + await createOrUpdateIntegration(environmentId, airtableIntegrationInput); + return { + response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`), + }; + } catch (error) { + logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback"); + return { + response: responses.internalServerErrorResponse(error), + }; } - const email = await getEmail(key.access_token); - - const airtableIntegrationInput = { - type: "airtable" as "airtable", - environment: environmentId, - config: { - key, - data: [], - email, - }, - }; - await createOrUpdateIntegration(environmentId, airtableIntegrationInput); - return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`); - } catch (error) { - logger.error({ error, url: req.url }, "Error in GET /api/v1/integrations/airtable/callback"); - responses.internalServerErrorResponse(error); - } - responses.badRequestResponse("unknown error occurred"); -}; + }, +}); diff --git a/apps/web/app/api/v1/integrations/airtable/route.ts b/apps/web/app/api/v1/integrations/airtable/route.ts index 3045ecd087a5..4d73f27350d4 100644 --- a/apps/web/app/api/v1/integrations/airtable/route.ts +++ b/apps/web/app/api/v1/integrations/airtable/route.ts @@ -1,54 +1,66 @@ import { responses } from "@/app/lib/api/response"; -import { authOptions } from "@/modules/auth/lib/authOptions"; +import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants"; +import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import crypto from "crypto"; -import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; -import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; const scope = `data.records:read data.records:write schema.bases:read schema.bases:write user.email:read`; -export const GET = async (req: NextRequest) => { - const environmentId = req.headers.get("environmentId"); - const session = await getServerSession(authOptions); - - if (!environmentId) { - return responses.badRequestResponse("environmentId is missing"); - } - - if (!session) { - return responses.notAuthenticatedResponse(); - } - - const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId); - if (!canUserAccessEnvironment) { - return responses.unauthorizedResponse(); - } - - const client_id = AIRTABLE_CLIENT_ID; - const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback"; - if (!client_id) return responses.internalServerErrorResponse("Airtable client id is missing"); - if (!redirect_uri) return responses.internalServerErrorResponse("Airtable redirect url is missing"); - const codeVerifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64"); - - const codeChallengeMethod = "S256"; - const codeChallenge = crypto - .createHash("sha256") - .update(codeVerifier) // hash the code verifier with the sha256 algorithm - .digest("base64") // base64 encode, needs to be transformed to base64url - .replace(/=/g, "") // remove = - .replace(/\+/g, "-") // replace + with - - .replace(/\//g, "_"); // replace / with _ now base64url encoded - - const authUrl = new URL("https://airtable.com/oauth2/v1/authorize"); - - authUrl.searchParams.append("client_id", client_id); - authUrl.searchParams.append("redirect_uri", redirect_uri); - authUrl.searchParams.append("state", environmentId); - authUrl.searchParams.append("scope", scope); - authUrl.searchParams.append("response_type", "code"); - authUrl.searchParams.append("code_challenge_method", codeChallengeMethod); - authUrl.searchParams.append("code_challenge", codeChallenge); - - return responses.successResponse({ authUrl: authUrl.toString() }); -}; +export const GET = withV1ApiWrapper({ + handler: async ({ + req, + authentication, + }: { + req: NextRequest; + authentication: NonNullable; + }) => { + const environmentId = req.headers.get("environmentId"); + + if (!environmentId) { + return { + response: responses.badRequestResponse("environmentId is missing"), + }; + } + + const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId); + if (!canUserAccessEnvironment) { + return { + response: responses.unauthorizedResponse(), + }; + } + + const client_id = AIRTABLE_CLIENT_ID; + const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback"; + if (!client_id) + return { + response: responses.internalServerErrorResponse("Airtable client id is missing"), + }; + const codeVerifier = Buffer.from(environmentId + authentication.user.id + environmentId).toString( + "base64" + ); + + const codeChallengeMethod = "S256"; + const codeChallenge = crypto + .createHash("sha256") + .update(codeVerifier) // hash the code verifier with the sha256 algorithm + .digest("base64") // base64 encode, needs to be transformed to base64url + .replace(/=/g, "") // remove = + .replace(/\+/g, "-") // replace + with - + .replace(/\//g, "_"); // replace / with _ now base64url encoded + + const authUrl = new URL("https://airtable.com/oauth2/v1/authorize"); + + authUrl.searchParams.append("client_id", client_id); + authUrl.searchParams.append("redirect_uri", redirect_uri); + authUrl.searchParams.append("state", environmentId); + authUrl.searchParams.append("scope", scope); + authUrl.searchParams.append("response_type", "code"); + authUrl.searchParams.append("code_challenge_method", codeChallengeMethod); + authUrl.searchParams.append("code_challenge", codeChallenge); + + return { + response: responses.successResponse({ authUrl: authUrl.toString() }), + }; + }, +}); diff --git a/apps/web/app/api/v1/integrations/airtable/tables/route.ts b/apps/web/app/api/v1/integrations/airtable/tables/route.ts index 08056b4f0f69..bfc3d165004f 100644 --- a/apps/web/app/api/v1/integrations/airtable/tables/route.ts +++ b/apps/web/app/api/v1/integrations/airtable/tables/route.ts @@ -1,43 +1,55 @@ import { responses } from "@/app/lib/api/response"; -import { authOptions } from "@/modules/auth/lib/authOptions"; -import { getServerSession } from "next-auth"; +import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { getTables } from "@/lib/airtable/service"; +import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; +import { getIntegrationByType } from "@/lib/integration/service"; import { NextRequest } from "next/server"; import * as z from "zod"; -import { getTables } from "@formbricks/lib/airtable/service"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; -import { getIntegrationByType } from "@formbricks/lib/integration/service"; import { TIntegrationAirtable } from "@formbricks/types/integration/airtable"; -export const GET = async (req: NextRequest) => { - const url = req.url; - const environmentId = req.headers.get("environmentId"); - const queryParams = new URLSearchParams(url.split("?")[1]); - const session = await getServerSession(authOptions); - const baseId = z.string().safeParse(queryParams.get("baseId")); - - if (!baseId.success) { - return responses.badRequestResponse("Base Id is Required"); - } - - if (!session) { - return responses.notAuthenticatedResponse(); - } - - if (!environmentId) { - return responses.badRequestResponse("environmentId is missing"); - } - - const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId); - if (!canUserAccessEnvironment || !environmentId) { - return responses.unauthorizedResponse(); - } - - const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable; - - if (!integration) { - return responses.notFoundResponse("Integration not found", environmentId); - } - - const tables = await getTables(integration.config.key, baseId.data); - return responses.successResponse(tables); -}; +export const GET = withV1ApiWrapper({ + handler: async ({ + req, + authentication, + }: { + req: NextRequest; + authentication: NonNullable; + }) => { + const url = req.url; + const environmentId = req.headers.get("environmentId"); + const queryParams = new URLSearchParams(url.split("?")[1]); + const baseId = z.string().safeParse(queryParams.get("baseId")); + + if (!baseId.success) { + return { + response: responses.badRequestResponse("Base Id is Required"), + }; + } + + if (!environmentId) { + return { + response: responses.badRequestResponse("environmentId is missing"), + }; + } + + const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId); + if (!canUserAccessEnvironment) { + return { + response: responses.unauthorizedResponse(), + }; + } + + const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable; + + if (!integration) { + return { + response: responses.notFoundResponse("Integration not found", environmentId), + }; + } + + const tables = await getTables(integration.config.key, baseId.data); + return { + response: responses.successResponse(tables), + }; + }, +}); diff --git a/apps/web/app/api/v1/integrations/notion/callback/route.ts b/apps/web/app/api/v1/integrations/notion/callback/route.ts index 5483dc639e59..c407419119b1 100644 --- a/apps/web/app/api/v1/integrations/notion/callback/route.ts +++ b/apps/web/app/api/v1/integrations/notion/callback/route.ts @@ -1,80 +1,104 @@ import { responses } from "@/app/lib/api/response"; -import { NextRequest } from "next/server"; +import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { ENCRYPTION_KEY, NOTION_OAUTH_CLIENT_ID, NOTION_OAUTH_CLIENT_SECRET, NOTION_REDIRECT_URI, WEBAPP_URL, -} from "@formbricks/lib/constants"; -import { symmetricEncrypt } from "@formbricks/lib/crypto"; -import { createOrUpdateIntegration, getIntegrationByType } from "@formbricks/lib/integration/service"; +} from "@/lib/constants"; +import { symmetricEncrypt } from "@/lib/crypto"; +import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service"; +import { NextRequest } from "next/server"; import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion"; -export const GET = async (req: NextRequest) => { - const url = req.url; - const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters - const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter - const code = queryParams.get("code"); - const error = queryParams.get("error"); +export const GET = withV1ApiWrapper({ + handler: async ({ req }: { req: NextRequest }) => { + const url = req.url; + const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters + const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter + const code = queryParams.get("code"); + const error = queryParams.get("error"); - if (!environmentId) { - return responses.badRequestResponse("Invalid environmentId"); - } + if (!environmentId) { + return { + response: responses.badRequestResponse("Invalid environmentId"), + }; + } - if (code && typeof code !== "string") { - return responses.badRequestResponse("`code` must be a string"); - } + if (code && typeof code !== "string") { + return { + response: responses.badRequestResponse("`code` must be a string"), + }; + } - const client_id = NOTION_OAUTH_CLIENT_ID; - const client_secret = NOTION_OAUTH_CLIENT_SECRET; - const redirect_uri = NOTION_REDIRECT_URI; - if (!client_id) return responses.internalServerErrorResponse("Notion client id is missing"); - if (!redirect_uri) return responses.internalServerErrorResponse("Notion redirect url is missing"); - if (!client_secret) return responses.internalServerErrorResponse("Notion client secret is missing"); - if (code) { - // encode in base 64 - const encoded = Buffer.from(`${client_id}:${client_secret}`).toString("base64"); + const client_id = NOTION_OAUTH_CLIENT_ID; + const client_secret = NOTION_OAUTH_CLIENT_SECRET; + const redirect_uri = NOTION_REDIRECT_URI; + if (!client_id) + return { + response: responses.internalServerErrorResponse("Notion client id is missing"), + }; + if (!redirect_uri) + return { + response: responses.internalServerErrorResponse("Notion redirect url is missing"), + }; + if (!client_secret) + return { + response: responses.internalServerErrorResponse("Notion client secret is missing"), + }; + if (code) { + // encode in base 64 + const encoded = Buffer.from(`${client_id}:${client_secret}`).toString("base64"); - const response = await fetch("https://api.notion.com/v1/oauth/token", { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - Authorization: `Basic ${encoded}`, - }, - body: JSON.stringify({ - grant_type: "authorization_code", - code: code, - redirect_uri: redirect_uri, - }), - }); + const response = await fetch("https://api.notion.com/v1/oauth/token", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Basic ${encoded}`, + }, + body: JSON.stringify({ + grant_type: "authorization_code", + code: code, + redirect_uri: redirect_uri, + }), + }); - const tokenData = await response.json(); - const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY!); - tokenData.access_token = encryptedAccessToken; + const tokenData = await response.json(); + const encryptedAccessToken = symmetricEncrypt(tokenData.access_token, ENCRYPTION_KEY); + tokenData.access_token = encryptedAccessToken; - const notionIntegration: TIntegrationNotionInput = { - type: "notion" as "notion", - config: { - key: tokenData, - data: [], - }, - }; + const notionIntegration: TIntegrationNotionInput = { + type: "notion" as "notion", + config: { + key: tokenData, + data: [], + }, + }; - const existingIntegration = await getIntegrationByType(environmentId, "notion"); - if (existingIntegration) { - notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[]; - } + const existingIntegration = await getIntegrationByType(environmentId, "notion"); + if (existingIntegration) { + notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[]; + } - const result = await createOrUpdateIntegration(environmentId, notionIntegration); + const result = await createOrUpdateIntegration(environmentId, notionIntegration); - if (result) { - return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`); + if (result) { + return { + response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/notion`), + }; + } + } else if (error) { + return { + response: Response.redirect( + `${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}` + ), + }; } - } else if (error) { - return Response.redirect( - `${WEBAPP_URL}/environments/${environmentId}/integrations/notion?error=${error}` - ); - } -}; + + return { + response: responses.badRequestResponse("Missing code or error parameter"), + }; + }, +}); diff --git a/apps/web/app/api/v1/integrations/notion/route.ts b/apps/web/app/api/v1/integrations/notion/route.ts index d707e583d441..32b01e67e569 100644 --- a/apps/web/app/api/v1/integrations/notion/route.ts +++ b/apps/web/app/api/v1/integrations/notion/route.ts @@ -1,40 +1,60 @@ import { responses } from "@/app/lib/api/response"; -import { authOptions } from "@/modules/auth/lib/authOptions"; -import { getServerSession } from "next-auth"; -import { NextRequest } from "next/server"; +import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { NOTION_AUTH_URL, NOTION_OAUTH_CLIENT_ID, NOTION_OAUTH_CLIENT_SECRET, NOTION_REDIRECT_URI, -} from "@formbricks/lib/constants"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; - -export const GET = async (req: NextRequest) => { - const environmentId = req.headers.get("environmentId"); - const session = await getServerSession(authOptions); +} from "@/lib/constants"; +import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; +import { NextRequest } from "next/server"; - if (!environmentId) { - return responses.badRequestResponse("environmentId is missing"); - } +export const GET = withV1ApiWrapper({ + handler: async ({ + req, + authentication, + }: { + req: NextRequest; + authentication: NonNullable; + }) => { + const environmentId = req.headers.get("environmentId"); - if (!session) { - return responses.notAuthenticatedResponse(); - } + if (!environmentId) { + return { + response: responses.badRequestResponse("environmentId is missing"), + }; + } - const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId); - if (!canUserAccessEnvironment) { - return responses.unauthorizedResponse(); - } + const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId); + if (!canUserAccessEnvironment) { + return { + response: responses.unauthorizedResponse(), + }; + } - const client_id = NOTION_OAUTH_CLIENT_ID; - const client_secret = NOTION_OAUTH_CLIENT_SECRET; - const auth_url = NOTION_AUTH_URL; - const redirect_uri = NOTION_REDIRECT_URI; - if (!client_id) return responses.internalServerErrorResponse("Notion client id is missing"); - if (!redirect_uri) return responses.internalServerErrorResponse("Notion redirect url is missing"); - if (!client_secret) return responses.internalServerErrorResponse("Notion client secret is missing"); - if (!auth_url) return responses.internalServerErrorResponse("Notion auth url is missing"); + const client_id = NOTION_OAUTH_CLIENT_ID; + const client_secret = NOTION_OAUTH_CLIENT_SECRET; + const auth_url = NOTION_AUTH_URL; + const redirect_uri = NOTION_REDIRECT_URI; + if (!client_id) + return { + response: responses.internalServerErrorResponse("Notion client id is missing"), + }; + if (!redirect_uri) + return { + response: responses.internalServerErrorResponse("Notion redirect url is missing"), + }; + if (!client_secret) + return { + response: responses.internalServerErrorResponse("Notion client secret is missing"), + }; + if (!auth_url) + return { + response: responses.internalServerErrorResponse("Notion auth url is missing"), + }; - return responses.successResponse({ authUrl: `${auth_url}&state=${environmentId}` }); -}; + return { + response: responses.successResponse({ authUrl: `${auth_url}&state=${environmentId}` }), + }; + }, +}); diff --git a/apps/web/app/api/v1/integrations/slack/callback/route.ts b/apps/web/app/api/v1/integrations/slack/callback/route.ts index 3661ae05bbaa..e7b8e1ea0932 100644 --- a/apps/web/app/api/v1/integrations/slack/callback/route.ts +++ b/apps/web/app/api/v1/integrations/slack/callback/route.ts @@ -1,86 +1,111 @@ import { responses } from "@/app/lib/api/response"; +import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants"; +import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service"; import { NextRequest } from "next/server"; -import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; -import { createOrUpdateIntegration, getIntegrationByType } from "@formbricks/lib/integration/service"; import { TIntegrationSlackConfig, TIntegrationSlackConfigData, TIntegrationSlackCredential, } from "@formbricks/types/integration/slack"; -export const GET = async (req: NextRequest) => { - const url = req.url; - const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters - const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter - const code = queryParams.get("code"); - const error = queryParams.get("error"); +export const GET = withV1ApiWrapper({ + handler: async ({ req }: { req: NextRequest }) => { + const url = req.url; + const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters + const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter + const code = queryParams.get("code"); + const error = queryParams.get("error"); - if (!environmentId) { - return responses.badRequestResponse("Invalid environmentId"); - } + if (!environmentId) { + return { + response: responses.badRequestResponse("Invalid environmentId"), + }; + } - if (code && typeof code !== "string") { - return responses.badRequestResponse("`code` must be a string"); - } + if (code && typeof code !== "string") { + return { + response: responses.badRequestResponse("`code` must be a string"), + }; + } - if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing"); - if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing"); + if (!SLACK_CLIENT_ID) + return { + response: responses.internalServerErrorResponse("Slack client id is missing"), + }; + if (!SLACK_CLIENT_SECRET) + return { + response: responses.internalServerErrorResponse("Slack client secret is missing"), + }; - const formData = { - code, - client_id: SLACK_CLIENT_ID, - client_secret: SLACK_CLIENT_SECRET, - }; - const formBody: string[] = []; - for (const property in formData) { - const encodedKey = encodeURIComponent(property); - const encodedValue = encodeURIComponent(formData[property]); - formBody.push(encodedKey + "=" + encodedValue); - } - const bodyString = formBody.join("&"); - if (code) { - const response = await fetch("https://slack.com/api/oauth.v2.access", { - method: "POST", - body: bodyString, - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - }); + const formData = { + code, + client_id: SLACK_CLIENT_ID, + client_secret: SLACK_CLIENT_SECRET, + }; + const formBody: string[] = []; + for (const property in formData) { + const encodedKey = encodeURIComponent(property); + const encodedValue = encodeURIComponent(formData[property]); + formBody.push(encodedKey + "=" + encodedValue); + } + const bodyString = formBody.join("&"); + if (code) { + const response = await fetch("https://slack.com/api/oauth.v2.access", { + method: "POST", + body: bodyString, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); - const data = await response.json(); + const data = await response.json(); - if (!data.ok) { - return responses.badRequestResponse(data.error); - } + if (!data.ok) { + return { + response: responses.badRequestResponse(data.error), + }; + } - const slackCredentials: TIntegrationSlackCredential = { - app_id: data.app_id, - authed_user: data.authed_user, - token_type: data.token_type, - access_token: data.access_token, - bot_user_id: data.bot_user_id, - team: data.team, - }; + const slackCredentials: TIntegrationSlackCredential = { + app_id: data.app_id, + authed_user: data.authed_user, + token_type: data.token_type, + access_token: data.access_token, + bot_user_id: data.bot_user_id, + team: data.team, + }; - const slackIntegration = await getIntegrationByType(environmentId, "slack"); + const slackIntegration = await getIntegrationByType(environmentId, "slack"); - const slackConfiguration: TIntegrationSlackConfig = { - data: (slackIntegration?.config.data as TIntegrationSlackConfigData[]) ?? [], - key: slackCredentials, - }; + const slackConfiguration: TIntegrationSlackConfig = { + data: (slackIntegration?.config.data as TIntegrationSlackConfigData[]) ?? [], + key: slackCredentials, + }; - const integration = { - type: "slack" as "slack", - environment: environmentId, - config: slackConfiguration, - }; + const integration = { + type: "slack" as "slack", + environment: environmentId, + config: slackConfiguration, + }; - const result = await createOrUpdateIntegration(environmentId, integration); + const result = await createOrUpdateIntegration(environmentId, integration); - if (result) { - return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`); + if (result) { + return { + response: Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack`), + }; + } + } else if (error) { + return { + response: Response.redirect( + `${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}` + ), + }; } - } else if (error) { - return Response.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/slack?error=${error}`); - } -}; + + return { + response: responses.badRequestResponse("Missing code or error parameter"), + }; + }, +}); diff --git a/apps/web/app/api/v1/integrations/slack/route.ts b/apps/web/app/api/v1/integrations/slack/route.ts index 46fa8fb33964..62cab41c6d8f 100644 --- a/apps/web/app/api/v1/integrations/slack/route.ts +++ b/apps/web/app/api/v1/integrations/slack/route.ts @@ -1,30 +1,47 @@ import { responses } from "@/app/lib/api/response"; -import { authOptions } from "@/modules/auth/lib/authOptions"; -import { getServerSession } from "next-auth"; +import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { SLACK_AUTH_URL, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET } from "@/lib/constants"; +import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { NextRequest } from "next/server"; -import { SLACK_AUTH_URL, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET } from "@formbricks/lib/constants"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; -export const GET = async (req: NextRequest) => { - const environmentId = req.headers.get("environmentId"); - const session = await getServerSession(authOptions); +export const GET = withV1ApiWrapper({ + handler: async ({ + req, + authentication, + }: { + req: NextRequest; + authentication: NonNullable; + }) => { + const environmentId = req.headers.get("environmentId"); - if (!environmentId) { - return responses.badRequestResponse("environmentId is missing"); - } + if (!environmentId) { + return { + response: responses.badRequestResponse("environmentId is missing"), + }; + } - if (!session) { - return responses.notAuthenticatedResponse(); - } + const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId); + if (!canUserAccessEnvironment) { + return { + response: responses.unauthorizedResponse(), + }; + } - const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId); - if (!canUserAccessEnvironment) { - return responses.unauthorizedResponse(); - } + if (!SLACK_CLIENT_ID) + return { + response: responses.internalServerErrorResponse("Slack client id is missing"), + }; + if (!SLACK_CLIENT_SECRET) + return { + response: responses.internalServerErrorResponse("Slack client secret is missing"), + }; + if (!SLACK_AUTH_URL) + return { + response: responses.internalServerErrorResponse("Slack auth url is missing"), + }; - if (!SLACK_CLIENT_ID) return responses.internalServerErrorResponse("Slack client id is missing"); - if (!SLACK_CLIENT_SECRET) return responses.internalServerErrorResponse("Slack client secret is missing"); - if (!SLACK_AUTH_URL) return responses.internalServerErrorResponse("Slack auth url is missing"); - - return responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${environmentId}` }); -}; + return { + response: responses.successResponse({ authUrl: `${SLACK_AUTH_URL}&state=${environmentId}` }), + }; + }, +}); diff --git a/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts b/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts index 1a3e2c073bea..5577abdaf27b 100644 --- a/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts +++ b/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts @@ -1,8 +1,10 @@ -import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; +import { handleErrorResponse } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; -import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service"; +import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; @@ -26,81 +28,134 @@ const fetchAndAuthorizeActionClass = async ( return actionClass; }; -export const GET = async ( - request: Request, - props: { params: Promise<{ actionClassId: string }> } -): Promise => { - const params = await props.params; - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); - const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET"); - if (actionClass) { - return responses.successResponse(actionClass); - } - return responses.notFoundResponse("Action Class", params.actionClassId); - } catch (error) { - return handleErrorResponse(error); - } -}; +export const GET = withV1ApiWrapper({ + handler: async ({ + props, + authentication, + }: { + props: { params: Promise<{ actionClassId: string }> }; + authentication: NonNullable; + }) => { + const params = await props.params; -export const PUT = async ( - request: Request, - props: { params: Promise<{ actionClassId: string }> } -): Promise => { - const params = await props.params; - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); - const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT"); - if (!actionClass) { - return responses.notFoundResponse("Action Class", params.actionClassId); - } - - let actionClassUpdate; try { - actionClassUpdate = await request.json(); + const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET"); + if (actionClass) { + return { + response: responses.successResponse(actionClass), + }; + } + return { + response: responses.notFoundResponse("Action Class", params.actionClassId), + }; } catch (error) { - logger.error({ error, url: request.url }, "Error parsing JSON"); - return responses.badRequestResponse("Malformed JSON input, please check your request body"); + return { + response: handleErrorResponse(error), + }; } + }, +}); + +export const PUT = withV1ApiWrapper({ + handler: async ({ + req, + props, + auditLog, + authentication, + }: { + req: NextRequest; + props: { params: Promise<{ actionClassId: string }> }; + auditLog: TApiAuditLog; + authentication: NonNullable; + }) => { + const params = await props.params; - const inputValidation = ZActionClassInput.safeParse(actionClassUpdate); - if (!inputValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(inputValidation.error) + try { + const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT"); + if (!actionClass) { + return { + response: responses.notFoundResponse("Action Class", params.actionClassId), + }; + } + auditLog.oldObject = actionClass; + + let actionClassUpdate; + try { + actionClassUpdate = await req.json(); + } catch (error) { + logger.error({ error, url: req.url }, "Error parsing JSON"); + return { + response: responses.badRequestResponse("Malformed JSON input, please check your request body"), + }; + } + + const inputValidation = ZActionClassInput.safeParse(actionClassUpdate); + if (!inputValidation.success) { + return { + response: responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(inputValidation.error) + ), + }; + } + const updatedActionClass = await updateActionClass( + inputValidation.data.environmentId, + params.actionClassId, + inputValidation.data ); + if (updatedActionClass) { + auditLog.newObject = updatedActionClass; + return { + response: responses.successResponse(updatedActionClass), + }; + } + return { + response: responses.internalServerErrorResponse("Some error occurred while updating action"), + }; + } catch (error) { + return { + response: handleErrorResponse(error), + }; } - const updatedActionClass = await updateActionClass( - inputValidation.data.environmentId, - params.actionClassId, - inputValidation.data - ); - if (updatedActionClass) { - return responses.successResponse(updatedActionClass); - } - return responses.internalServerErrorResponse("Some error ocured while updating action"); - } catch (error) { - return handleErrorResponse(error); - } -}; + }, + action: "updated", + targetType: "actionClass", +}); + +export const DELETE = withV1ApiWrapper({ + handler: async ({ + props, + auditLog, + authentication, + }: { + props: { params: Promise<{ actionClassId: string }> }; + auditLog: TApiAuditLog; + authentication: NonNullable; + }) => { + const params = await props.params; + + auditLog.targetId = params.actionClassId; + + try { + const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE"); + if (!actionClass) { + return { + response: responses.notFoundResponse("Action Class", params.actionClassId), + }; + } + + auditLog.oldObject = actionClass; -export const DELETE = async ( - request: Request, - props: { params: Promise<{ actionClassId: string }> } -): Promise => { - const params = await props.params; - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); - const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE"); - if (!actionClass) { - return responses.notFoundResponse("Action Class", params.actionClassId); + const deletedActionClass = await deleteActionClass(params.actionClassId); + return { + response: responses.successResponse(deletedActionClass), + }; + } catch (error) { + return { + response: handleErrorResponse(error), + }; } - const deletedActionClass = await deleteActionClass(params.actionClassId); - return responses.successResponse(deletedActionClass); - } catch (error) { - return handleErrorResponse(error); - } -}; + }, + action: "deleted", + targetType: "actionClass", +}); diff --git a/apps/web/app/api/v1/management/action-classes/lib/action-classes.test.ts b/apps/web/app/api/v1/management/action-classes/lib/action-classes.test.ts index a1a8f0410ea4..f8b4eaba8a03 100644 --- a/apps/web/app/api/v1/management/action-classes/lib/action-classes.test.ts +++ b/apps/web/app/api/v1/management/action-classes/lib/action-classes.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { DatabaseError } from "@formbricks/types/errors"; import { getActionClasses } from "./action-classes"; @@ -43,7 +43,7 @@ describe("getActionClasses", () => { vi.clearAllMocks(); }); - it("should successfully fetch action classes for given environment IDs", async () => { + test("successfully fetches action classes for given environment IDs", async () => { // Mock the prisma findMany response vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses); @@ -61,14 +61,14 @@ describe("getActionClasses", () => { }); }); - it("should throw DatabaseError when prisma query fails", async () => { + test("throws DatabaseError when prisma query fails", async () => { // Mock the prisma findMany to throw an error vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("Database error")); await expect(getActionClasses(mockEnvironmentIds)).rejects.toThrow(DatabaseError); }); - it("should handle empty environment IDs array", async () => { + test("handles empty environment IDs array", async () => { // Mock the prisma findMany response vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]); diff --git a/apps/web/app/api/v1/management/action-classes/lib/action-classes.ts b/apps/web/app/api/v1/management/action-classes/lib/action-classes.ts index 3cd0c2263b90..b824233b40a8 100644 --- a/apps/web/app/api/v1/management/action-classes/lib/action-classes.ts +++ b/apps/web/app/api/v1/management/action-classes/lib/action-classes.ts @@ -1,12 +1,10 @@ "use server"; import "server-only"; +import { validateInputs } from "@/lib/utils/validate"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { actionClassCache } from "@formbricks/lib/actionClass/cache"; -import { cache } from "@formbricks/lib/cache"; -import { validateInputs } from "@formbricks/lib/utils/validate"; import { TActionClass } from "@formbricks/types/action-classes"; import { ZId } from "@formbricks/types/common"; import { DatabaseError } from "@formbricks/types/errors"; @@ -23,29 +21,20 @@ const selectActionClass = { environmentId: true, } satisfies Prisma.ActionClassSelect; -export const getActionClasses = reactCache( - async (environmentIds: string[]): Promise => - cache( - async () => { - validateInputs([environmentIds, ZId.array()]); +export const getActionClasses = reactCache(async (environmentIds: string[]): Promise => { + validateInputs([environmentIds, ZId.array()]); - try { - return await prisma.actionClass.findMany({ - where: { - environmentId: { in: environmentIds }, - }, - select: selectActionClass, - orderBy: { - createdAt: "asc", - }, - }); - } catch (error) { - throw new DatabaseError(`Database error when fetching actions for environment ${environmentIds}`); - } + try { + return await prisma.actionClass.findMany({ + where: { + environmentId: { in: environmentIds }, }, - environmentIds.map((environmentId) => `getActionClasses-management-api-${environmentId}`), - { - tags: environmentIds.map((environmentId) => actionClassCache.tag.byEnvironmentId(environmentId)), - } - )() -); + select: selectActionClass, + orderBy: { + createdAt: "asc", + }, + }); + } catch (error) { + throw new DatabaseError(`Database error when fetching actions for environment ${environmentIds}`); + } +}); diff --git a/apps/web/app/api/v1/management/action-classes/route.ts b/apps/web/app/api/v1/management/action-classes/route.ts index 378f64e52823..b342f6ed91ad 100644 --- a/apps/web/app/api/v1/management/action-classes/route.ts +++ b/apps/web/app/api/v1/management/action-classes/route.ts @@ -1,68 +1,92 @@ -import { authenticateRequest } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { createActionClass } from "@/lib/actionClass/service"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; -import { createActionClass } from "@formbricks/lib/actionClass/service"; +import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes"; import { DatabaseError } from "@formbricks/types/errors"; import { getActionClasses } from "./lib/action-classes"; -export const GET = async (request: Request) => { - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); - - const environmentIds = authentication.environmentPermissions.map( - (permission) => permission.environmentId - ); - - const actionClasses = await getActionClasses(environmentIds); - - return responses.successResponse(actionClasses); - } catch (error) { - if (error instanceof DatabaseError) { - return responses.badRequestResponse(error.message); - } - throw error; - } -}; +export const GET = withV1ApiWrapper({ + handler: async ({ authentication }: { authentication: NonNullable }) => { + try { + const environmentIds = authentication.environmentPermissions.map( + (permission) => permission.environmentId + ); -export const POST = async (request: Request): Promise => { - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); + const actionClasses = await getActionClasses(environmentIds); - let actionClassInput; - try { - actionClassInput = await request.json(); + return { + response: responses.successResponse(actionClasses), + }; } catch (error) { - logger.error({ error, url: request.url }, "Error parsing JSON input"); - return responses.badRequestResponse("Malformed JSON input, please check your request body"); + if (error instanceof DatabaseError) { + return { + response: responses.badRequestResponse(error.message), + }; + } + throw error; } + }, +}); - const inputValidation = ZActionClassInput.safeParse(actionClassInput); +export const POST = withV1ApiWrapper({ + handler: async ({ + req, + auditLog, + authentication, + }: { + req: NextRequest; + auditLog: TApiAuditLog; + authentication: NonNullable; + }) => { + try { + let actionClassInput; + try { + actionClassInput = await req.json(); + } catch (error) { + logger.error({ error, url: req.url }, "Error parsing JSON input"); + return { + response: responses.badRequestResponse("Malformed JSON input, please check your request body"), + }; + } - const environmentId = actionClassInput.environmentId; + const inputValidation = ZActionClassInput.safeParse(actionClassInput); + if (!inputValidation.success) { + return { + response: responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(inputValidation.error), + true + ), + }; + } - if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { - return responses.unauthorizedResponse(); - } + const environmentId = inputValidation.data.environmentId; - if (!inputValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(inputValidation.error), - true - ); - } + if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { + return { + response: responses.unauthorizedResponse(), + }; + } - const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data); - return responses.successResponse(actionClass); - } catch (error) { - if (error instanceof DatabaseError) { - return responses.badRequestResponse(error.message); + const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data); + auditLog.targetId = actionClass.id; + auditLog.newObject = actionClass; + return { + response: responses.successResponse(actionClass), + }; + } catch (error) { + if (error instanceof DatabaseError) { + return { + response: responses.badRequestResponse(error.message), + }; + } + throw error; } - throw error; - } -}; + }, + action: "created", + targetType: "actionClass", +}); diff --git a/apps/web/app/api/v1/management/me/lib/utils.test.ts b/apps/web/app/api/v1/management/me/lib/utils.test.ts new file mode 100644 index 000000000000..4e1633187e87 --- /dev/null +++ b/apps/web/app/api/v1/management/me/lib/utils.test.ts @@ -0,0 +1,62 @@ +import { getSessionUser } from "@/app/api/v1/management/me/lib/utils"; +import { authOptions } from "@/modules/auth/lib/authOptions"; +import { mockUser } from "@/modules/auth/lib/mock-data"; +import { cleanup } from "@testing-library/react"; +import { NextApiRequest, NextApiResponse } from "next"; +import { getServerSession } from "next-auth"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("@/modules/auth/lib/authOptions", () => ({ + authOptions: {}, +})); + +describe("getSessionUser", () => { + afterEach(() => { + cleanup(); + }); + + test("should return the user object when valid req and res are provided", async () => { + const mockReq = {} as NextApiRequest; + const mockRes = {} as NextApiResponse; + + vi.mocked(getServerSession).mockResolvedValue({ user: mockUser }); + + const user = await getSessionUser(mockReq, mockRes); + + expect(user).toEqual(mockUser); + expect(getServerSession).toHaveBeenCalledWith(mockReq, mockRes, authOptions); + }); + + test("should return the user object when neither req nor res are provided", async () => { + vi.mocked(getServerSession).mockResolvedValue({ user: mockUser }); + + const user = await getSessionUser(); + + expect(user).toEqual(mockUser); + expect(getServerSession).toHaveBeenCalledWith(authOptions); + }); + + test("should return undefined if no session exists", async () => { + vi.mocked(getServerSession).mockResolvedValue(null); + + const user = await getSessionUser(); + + expect(user).toBeUndefined(); + }); + + test("should return null when session exists and user property is null", async () => { + const mockReq = {} as NextApiRequest; + const mockRes = {} as NextApiResponse; + + vi.mocked(getServerSession).mockResolvedValue({ user: null }); + + const user = await getSessionUser(mockReq, mockRes); + + expect(user).toBeNull(); + expect(getServerSession).toHaveBeenCalledWith(mockReq, mockRes, authOptions); + }); +}); diff --git a/apps/web/app/api/v1/management/me/route.ts b/apps/web/app/api/v1/management/me/route.ts index d4dd33017e21..fe65ae9d43ba 100644 --- a/apps/web/app/api/v1/management/me/route.ts +++ b/apps/web/app/api/v1/management/me/route.ts @@ -1,15 +1,22 @@ import { getSessionUser } from "@/app/api/v1/management/me/lib/utils"; +import { responses } from "@/app/lib/api/response"; import { hashApiKey } from "@/modules/api/v2/management/lib/utils"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { headers } from "next/headers"; import { prisma } from "@formbricks/database"; +const ALLOWED_PERMISSIONS = ["manage", "read", "write"] as const; + export const GET = async () => { const headersList = await headers(); const apiKey = headersList.get("x-api-key"); if (apiKey) { + const hashedApiKey = hashApiKey(apiKey); + const apiKeyData = await prisma.apiKey.findUnique({ where: { - hashedKey: hashApiKey(apiKey), + hashedKey: hashedApiKey, }, select: { apiKeyEnvironments: { @@ -37,14 +44,18 @@ export const GET = async () => { }); if (!apiKeyData) { - return new Response("Not authenticated", { - status: 401, - }); + return responses.notAuthenticatedResponse(); + } + + try { + await applyRateLimit(rateLimitConfigs.api.v1, hashedApiKey); + } catch (error) { + return responses.tooManyRequestsResponse(error.message); } if ( apiKeyData.apiKeyEnvironments.length === 1 && - apiKeyData.apiKeyEnvironments[0].permission === "manage" + ALLOWED_PERMISSIONS.includes(apiKeyData.apiKeyEnvironments[0].permission) ) { return Response.json({ id: apiKeyData.apiKeyEnvironments[0].environment.id, @@ -58,16 +69,18 @@ export const GET = async () => { }, }); } else { - return new Response("You can't use this method with this API key", { - status: 400, - }); + return responses.badRequestResponse("You can't use this method with this API key"); } } else { const sessionUser = await getSessionUser(); if (!sessionUser) { - return new Response("Not authenticated", { - status: 401, - }); + return responses.notAuthenticatedResponse(); + } + + try { + await applyRateLimit(rateLimitConfigs.api.v1, sessionUser.id); + } catch (error) { + return responses.tooManyRequestsResponse(error.message); } const user = await prisma.user.findUnique({ diff --git a/apps/web/app/api/v1/management/responses/[responseId]/route.ts b/apps/web/app/api/v1/management/responses/[responseId]/route.ts index 43fac5e93e89..f8960bfecfe9 100644 --- a/apps/web/app/api/v1/management/responses/[responseId]/route.ts +++ b/apps/web/app/api/v1/management/responses/[responseId]/route.ts @@ -1,17 +1,24 @@ -import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; +import { handleErrorResponse } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { validateFileUploads } from "@/lib/fileValidation"; +import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service"; +import { getSurvey } from "@/lib/survey/service"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; -import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service"; -import { getSurvey } from "@formbricks/lib/survey/service"; +import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; import { ZResponseUpdateInput } from "@formbricks/types/responses"; async function fetchAndAuthorizeResponse( responseId: string, - authentication: any, + authentication: TApiKeyAuthentication, requiredPermission: "GET" | "PUT" | "DELETE" ) { + if (!authentication) { + return { error: responses.notAuthenticatedResponse() }; + } + const response = await getResponse(responseId); if (!response) { return { error: responses.notFoundResponse("Response", responseId) }; @@ -26,75 +33,132 @@ async function fetchAndAuthorizeResponse( return { error: responses.unauthorizedResponse() }; } - return { response }; + return { response, survey }; } -export const GET = async ( - request: Request, - props: { params: Promise<{ responseId: string }> } -): Promise => { - const params = await props.params; - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); +export const GET = withV1ApiWrapper({ + handler: async ({ + props, + authentication, + }: { + props: { params: Promise<{ responseId: string }> }; + authentication: TApiKeyAuthentication; + }) => { + const params = await props.params; + try { + const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET"); + if (result.error) { + return { + response: result.error, + }; + } - const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET"); - if (result.error) return result.error; + return { + response: responses.successResponse(result.response), + }; + } catch (error) { + return { + response: handleErrorResponse(error), + }; + } + }, +}); - return responses.successResponse(result.response); - } catch (error) { - return handleErrorResponse(error); - } -}; +export const DELETE = withV1ApiWrapper({ + handler: async ({ + props, + auditLog, + authentication, + }: { + props: { params: Promise<{ responseId: string }> }; + auditLog: TApiAuditLog; + authentication: TApiKeyAuthentication; + }) => { + const params = await props.params; + auditLog.targetId = params.responseId; + try { + const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE"); + if (result.error) { + return { + response: result.error, + }; + } + auditLog.oldObject = result.response; -export const DELETE = async ( - request: Request, - props: { params: Promise<{ responseId: string }> } -): Promise => { - const params = await props.params; - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); + const deletedResponse = await deleteResponse(params.responseId); + return { + response: responses.successResponse(deletedResponse), + }; + } catch (error) { + return { + response: handleErrorResponse(error), + }; + } + }, + action: "deleted", + targetType: "response", +}); - const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE"); - if (result.error) return result.error; +export const PUT = withV1ApiWrapper({ + handler: async ({ + req, + props, + auditLog, + authentication, + }: { + req: NextRequest; + props: { params: Promise<{ responseId: string }> }; + auditLog: TApiAuditLog; + authentication: TApiKeyAuthentication; + }) => { + const params = await props.params; + auditLog.targetId = params.responseId; + try { + const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT"); + if (result.error) { + return { + response: result.error, + }; + } + auditLog.oldObject = result.response; - const deletedResponse = await deleteResponse(params.responseId); - return responses.successResponse(deletedResponse); - } catch (error) { - return handleErrorResponse(error); - } -}; + let responseUpdate; + try { + responseUpdate = await req.json(); + } catch (error) { + logger.error({ error, url: req.url }, "Error parsing JSON"); + return { + response: responses.badRequestResponse("Malformed JSON input, please check your request body"), + }; + } -export const PUT = async ( - request: Request, - props: { params: Promise<{ responseId: string }> } -): Promise => { - const params = await props.params; - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); + if (!validateFileUploads(responseUpdate.data, result.survey.questions)) { + return { + response: responses.badRequestResponse("Invalid file upload response"), + }; + } - const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT"); - if (result.error) return result.error; + const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate); + if (!inputValidation.success) { + return { + response: responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(inputValidation.error) + ), + }; + } - let responseUpdate; - try { - responseUpdate = await request.json(); + const updated = await updateResponse(params.responseId, inputValidation.data); + auditLog.newObject = updated; + return { + response: responses.successResponse(updated), + }; } catch (error) { - logger.error({ error, url: request.url }, "Error parsing JSON"); - return responses.badRequestResponse("Malformed JSON input, please check your request body"); + return { + response: handleErrorResponse(error), + }; } - - const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate); - if (!inputValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(inputValidation.error) - ); - } - return responses.successResponse(await updateResponse(params.responseId, inputValidation.data)); - } catch (error) { - return handleErrorResponse(error); - } -}; + }, + action: "updated", + targetType: "response", +}); diff --git a/apps/web/app/api/v1/management/responses/lib/contact.test.ts b/apps/web/app/api/v1/management/responses/lib/contact.test.ts new file mode 100644 index 000000000000..868ce9db5d22 --- /dev/null +++ b/apps/web/app/api/v1/management/responses/lib/contact.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { TContactAttributes } from "@formbricks/types/contact-attribute"; +import { getContactByUserId } from "./contact"; + +// Mock prisma +vi.mock("@formbricks/database", () => ({ + prisma: { + contact: { + findFirst: vi.fn(), + }, + }, +})); + +const environmentId = "test-env-id"; +const userId = "test-user-id"; +const contactId = "test-contact-id"; + +const mockContactDbData = { + id: contactId, + attributes: [ + { attributeKey: { key: "userId" }, value: userId }, + { attributeKey: { key: "email" }, value: "test@example.com" }, + { attributeKey: { key: "plan" }, value: "premium" }, + ], +}; + +const expectedContactAttributes: TContactAttributes = { + userId: userId, + email: "test@example.com", + plan: "premium", +}; + +describe("getContactByUserId", () => { + test("should return contact with attributes when found", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactDbData); + + const contact = await getContactByUserId(environmentId, userId); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId, + }, + value: userId, + }, + }, + }, + select: { + id: true, + attributes: { + select: { + attributeKey: { select: { key: true } }, + value: true, + }, + }, + }, + }); + expect(contact).toEqual({ + id: contactId, + attributes: expectedContactAttributes, + }); + }); + + test("should return null when contact is not found", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(null); + + const contact = await getContactByUserId(environmentId, userId); + + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId, + }, + value: userId, + }, + }, + }, + select: { + id: true, + attributes: { + select: { + attributeKey: { select: { key: true } }, + value: true, + }, + }, + }, + }); + expect(contact).toBeNull(); + }); +}); diff --git a/apps/web/app/api/v1/management/responses/lib/contact.ts b/apps/web/app/api/v1/management/responses/lib/contact.ts index 810f01c64563..12611be455e8 100644 --- a/apps/web/app/api/v1/management/responses/lib/contact.ts +++ b/apps/web/app/api/v1/management/responses/lib/contact.ts @@ -1,60 +1,51 @@ import "server-only"; -import { contactCache } from "@/lib/cache/contact"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; import { TContactAttributes } from "@formbricks/types/contact-attribute"; export const getContactByUserId = reactCache( - ( + async ( environmentId: string, userId: string ): Promise<{ id: string; attributes: TContactAttributes; - } | null> => - cache( - async () => { - const contact = await prisma.contact.findFirst({ - where: { - attributes: { - some: { - attributeKey: { - key: "userId", - environmentId, - }, - value: userId, - }, + } | null> => { + const contact = await prisma.contact.findFirst({ + where: { + attributes: { + some: { + attributeKey: { + key: "userId", + environmentId, }, + value: userId, }, + }, + }, + select: { + id: true, + attributes: { select: { - id: true, - attributes: { - select: { - attributeKey: { select: { key: true } }, - value: true, - }, - }, + attributeKey: { select: { key: true } }, + value: true, }, - }); + }, + }, + }); - if (!contact) { - return null; - } + if (!contact) { + return null; + } - const contactAttributes = contact.attributes.reduce((acc, attr) => { - acc[attr.attributeKey.key] = attr.value; - return acc; - }, {}) as TContactAttributes; + const contactAttributes = contact.attributes.reduce((acc, attr) => { + acc[attr.attributeKey.key] = attr.value; + return acc; + }, {}) as TContactAttributes; - return { - id: contact.id, - attributes: contactAttributes, - }; - }, - [`getContactByUserIdForResponsesApi-${environmentId}-${userId}`], - { - tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)], - } - )() + return { + id: contact.id, + attributes: contactAttributes, + }; + } ); diff --git a/apps/web/app/api/v1/management/responses/lib/response.test.ts b/apps/web/app/api/v1/management/responses/lib/response.test.ts new file mode 100644 index 000000000000..0567ade9eeb6 --- /dev/null +++ b/apps/web/app/api/v1/management/responses/lib/response.test.ts @@ -0,0 +1,335 @@ +import { + getMonthlyOrganizationResponseCount, + getOrganizationByEnvironmentId, +} from "@/lib/organization/service"; +import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer"; +import { getResponseContact } from "@/lib/response/service"; +import { calculateTtcTotal } from "@/lib/response/utils"; +import { validateInputs } from "@/lib/utils/validate"; +import { Organization, Prisma, Response as ResponsePrisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TResponse, TResponseInput } from "@formbricks/types/responses"; +import { getContactByUserId } from "./contact"; +import { createResponse, getResponsesByEnvironmentIds } from "./response"; + +// Mock Data +const environmentId = "test-environment-id"; +const organizationId = "test-organization-id"; +const mockUserId = "test-user-id"; +const surveyId = "test-survey-id"; +const displayId = "test-display-id"; +const responseId = "test-response-id"; + +const mockOrganization = { + id: organizationId, + name: "Test Org", + createdAt: new Date(), + updatedAt: new Date(), + billing: { plan: "free", limits: { monthly: { responses: null } } } as any, // Default no limit +} as unknown as Organization; + +const mockResponseInput: TResponseInput = { + environmentId, + surveyId, + displayId, + finished: true, + data: { q1: "answer1" }, + meta: { userAgent: { browser: "test-browser" } }, + ttc: { q1: 5 }, + language: "en", +}; + +const mockResponseInputWithUserId: TResponseInput = { + ...mockResponseInput, + userId: mockUserId, +}; + +// Prisma response structure (simplified) +const mockResponsePrisma = { + id: responseId, + createdAt: new Date(), + updatedAt: new Date(), + surveyId, + finished: true, + endingId: null, + data: { q1: "answer1" }, + meta: { userAgent: { browser: "test-browser" } }, + ttc: { q1: 5, total: 10 }, // Assume calculateTtcTotal adds 'total' + variables: {}, + contactAttributes: {}, + singleUseId: null, + language: "en", + displayId, + contact: null, // Prisma relation + tags: [], // Prisma relation +} as unknown as ResponsePrisma & { contact: any; tags: any[] }; // Adjust type as needed + +const mockResponse: TResponse = { + id: responseId, + createdAt: mockResponsePrisma.createdAt, + updatedAt: mockResponsePrisma.updatedAt, + surveyId, + finished: true, + endingId: null, + data: { q1: "answer1" }, + meta: { userAgent: { browser: "test-browser" } }, + ttc: { q1: 5, total: 10 }, + variables: {}, + contactAttributes: {}, + singleUseId: null, + language: "en", + displayId, + contact: null, // Transformed structure + tags: [], // Transformed structure +}; + +const mockEnvironmentIds = [environmentId, "env-2"]; +const mockLimit = 10; +const mockOffset = 5; + +const mockResponsesPrisma = [mockResponsePrisma, { ...mockResponsePrisma, id: "response-2" }]; +const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response-2" }]; + +// Mock dependencies +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: true, + POSTHOG_API_KEY: "mock-posthog-api-key", + POSTHOG_HOST: "mock-posthog-host", + IS_POSTHOG_CONFIGURED: true, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", +})); +vi.mock("@/lib/organization/service"); +vi.mock("@/lib/posthogServer"); +vi.mock("@/lib/response/service"); +vi.mock("@/lib/response/utils"); +vi.mock("@/lib/telemetry"); +vi.mock("@/lib/utils/validate"); +vi.mock("@formbricks/database", () => ({ + prisma: { + response: { + create: vi.fn(), + findMany: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger"); +vi.mock("./contact"); + +describe("Response Lib Tests", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createResponse", () => { + test("should create a response successfully with userId", async () => { + const mockContact = { id: "contact1", attributes: { userId: mockUserId } }; + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); + vi.mocked(getContactByUserId).mockResolvedValue(mockContact); + vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 }); + vi.mocked(prisma.response.create).mockResolvedValue({ + ...mockResponsePrisma, + }); + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); + + const response = await createResponse(mockResponseInputWithUserId); + + expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId); + expect(getContactByUserId).toHaveBeenCalledWith(environmentId, mockUserId); + expect(prisma.response.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + contact: { connect: { id: mockContact.id } }, + contactAttributes: mockContact.attributes, + }), + }) + ); + expect(response.contact).toEqual({ id: mockContact.id, userId: mockUserId }); + }); + + test("should throw ResourceNotFoundError if organization not found", async () => { + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); + await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError); + expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId); + expect(prisma.response.create).not.toHaveBeenCalled(); + }); + + test("should handle PrismaClientKnownRequestError", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", { + code: "P2002", + clientVersion: "2.0", + }); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); + vi.mocked(prisma.response.create).mockRejectedValue(prismaError); + + await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError); + expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError + }); + + test("should handle RelatedRecordDoesNotExist error with specific message", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Related record does not exist", { + code: "P2025", // PrismaErrorType.RelatedRecordDoesNotExist + clientVersion: "2.0", + }); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); + vi.mocked(prisma.response.create).mockRejectedValue(prismaError); + + await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError); + await expect(createResponse(mockResponseInput)).rejects.toThrow("Display ID does not exist"); + }); + + test("should handle generic errors", async () => { + const genericError = new Error("Something went wrong"); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); + vi.mocked(prisma.response.create).mockRejectedValue(genericError); + + await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError); + }); + + describe("Cloud specific tests", () => { + test("should check response limit and send event if limit reached", async () => { + // IS_FORMBRICKS_CLOUD is true by default from the top-level mock + const limit = 100; + const mockOrgWithBilling = { + ...mockOrganization, + billing: { limits: { monthly: { responses: limit } } }, + } as any; + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling); + vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 }); + vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma); + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached + + await createResponse(mockResponseInput); + + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); + expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled(); + }); + + test("should check response limit and not send event if limit not reached", async () => { + const limit = 100; + const mockOrgWithBilling = { + ...mockOrganization, + billing: { limits: { monthly: { responses: limit } } }, + } as any; + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling); + vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 }); + vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma); + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit - 1); // Limit not reached + + await createResponse(mockResponseInput); + + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); + expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled(); + }); + + test("should log error if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => { + const limit = 100; + const mockOrgWithBilling = { + ...mockOrganization, + billing: { limits: { monthly: { responses: limit } } }, + } as any; + const posthogError = new Error("Posthog error"); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling); + vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 }); + vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma); + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached + vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError); + + // Expecting successful response creation despite PostHog error + const response = await createResponse(mockResponseInput); + + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); + expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + posthogError, + "Error sending plan limits reached event to Posthog" + ); + expect(response).toEqual(mockResponse); // Should still return the created response + }); + }); + }); + + describe("getResponsesByEnvironmentIds", () => { + test("should return responses successfully", async () => { + vi.mocked(prisma.response.findMany).mockResolvedValue(mockResponsesPrisma); + vi.mocked(getResponseContact).mockReturnValue(null); // Assume no contact for simplicity + + const responses = await getResponsesByEnvironmentIds(mockEnvironmentIds); + + expect(validateInputs).toHaveBeenCalledTimes(1); + expect(prisma.response.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + survey: { + environmentId: { in: mockEnvironmentIds }, + }, + }, + orderBy: [{ createdAt: "desc" }], + take: undefined, + skip: undefined, + }) + ); + expect(getResponseContact).toHaveBeenCalledTimes(mockResponsesPrisma.length); + expect(responses).toEqual(mockTransformedResponses); + }); + + test("should return responses with limit and offset", async () => { + vi.mocked(prisma.response.findMany).mockResolvedValue(mockResponsesPrisma); + vi.mocked(getResponseContact).mockReturnValue(null); + + await getResponsesByEnvironmentIds(mockEnvironmentIds, mockLimit, mockOffset); + + expect(prisma.response.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: mockLimit, + skip: mockOffset, + }) + ); + }); + + test("should return empty array if no responses found", async () => { + vi.mocked(prisma.response.findMany).mockResolvedValue([]); + + const responses = await getResponsesByEnvironmentIds(mockEnvironmentIds); + + expect(responses).toEqual([]); + expect(prisma.response.findMany).toHaveBeenCalled(); + expect(getResponseContact).not.toHaveBeenCalled(); + }); + + test("should handle PrismaClientKnownRequestError", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", { + code: "P2002", + clientVersion: "2.0", + }); + vi.mocked(prisma.response.findMany).mockRejectedValue(prismaError); + + await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(DatabaseError); + }); + + test("should handle generic errors", async () => { + const genericError = new Error("Something went wrong"); + vi.mocked(prisma.response.findMany).mockRejectedValue(genericError); + + await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(genericError); + }); + }); +}); diff --git a/apps/web/app/api/v1/management/responses/lib/response.ts b/apps/web/app/api/v1/management/responses/lib/response.ts index bd5c80d56751..e931355bb765 100644 --- a/apps/web/app/api/v1/management/responses/lib/response.ts +++ b/apps/web/app/api/v1/management/responses/lib/response.ts @@ -1,20 +1,18 @@ import "server-only"; -import { Prisma } from "@prisma/client"; -import { cache as reactCache } from "react"; -import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getMonthlyOrganizationResponseCount, getOrganizationByEnvironmentId, -} from "@formbricks/lib/organization/service"; -import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer"; -import { responseCache } from "@formbricks/lib/response/cache"; -import { getResponseContact } from "@formbricks/lib/response/service"; -import { calculateTtcTotal } from "@formbricks/lib/response/utils"; -import { responseNoteCache } from "@formbricks/lib/responseNote/cache"; -import { captureTelemetry } from "@formbricks/lib/telemetry"; -import { validateInputs } from "@formbricks/lib/utils/validate"; +} from "@/lib/organization/service"; +import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer"; +import { getResponseContact } from "@/lib/response/service"; +import { calculateTtcTotal } from "@/lib/response/utils"; +import { captureTelemetry } from "@/lib/telemetry"; +import { validateInputs } from "@/lib/utils/validate"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; import { logger } from "@formbricks/logger"; import { ZId, ZOptionalNumber } from "@formbricks/types/common"; import { TContactAttributes } from "@formbricks/types/contact-attribute"; @@ -59,22 +57,6 @@ export const responseSelection = { }, }, }, - notes: { - select: { - id: true, - createdAt: true, - updatedAt: true, - text: true, - user: { - select: { - id: true, - name: true, - }, - }, - isResolved: true, - isEdited: true, - }, - }, } satisfies Prisma.ResponseSelect; export const createResponse = async (responseInput: TResponseInput): Promise => { @@ -153,19 +135,6 @@ export const createResponse = async (responseInput: TResponseInput): Promise tagPrisma.tag), }; - responseCache.revalidate({ - environmentId, - id: response.id, - contactId: contact?.id, - ...(singleUseId && { singleUseId }), - userId: userId ?? undefined, - surveyId, - }); - - responseNoteCache.revalidate({ - responseId: response.id, - }); - if (IS_FORMBRICKS_CLOUD) { const responsesCount = await getMonthlyOrganizationResponseCount(organization.id); const responsesLimit = organization.billing.limits.monthly.responses; @@ -192,6 +161,9 @@ export const createResponse = async (responseInput: TResponseInput): Promise => - cache( - async () => { - validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]); - try { - const responses = await prisma.response.findMany({ - where: { - survey: { - environmentId: { in: environmentIds }, - }, - }, - select: responseSelection, - orderBy: [ - { - createdAt: "desc", - }, - ], - take: limit ? limit : undefined, - skip: offset ? offset : undefined, - }); - - const transformedResponses: TResponse[] = await Promise.all( - responses.map((responsePrisma) => { - return { - ...responsePrisma, - contact: getResponseContact(responsePrisma), - tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), - }; - }) - ); - - return transformedResponses; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } - }, - environmentIds.map( - (environmentId) => `getResponses-management-api-${environmentId}-${limit}-${offset}` - ), - { - tags: environmentIds.map((environmentId) => responseCache.tag.byEnvironmentId(environmentId)), + async (environmentIds: string[], limit?: number, offset?: number): Promise => { + validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]); + try { + const responses = await prisma.response.findMany({ + where: { + survey: { + environmentId: { in: environmentIds }, + }, + }, + select: responseSelection, + orderBy: [ + { + createdAt: "desc", + }, + ], + take: limit ? limit : undefined, + skip: offset ? offset : undefined, + }); + + const transformedResponses: TResponse[] = await Promise.all( + responses.map((responsePrisma) => { + return { + ...responsePrisma, + contact: getResponseContact(responsePrisma), + tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), + }; + }) + ); + + return transformedResponses; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )() + + throw error; + } + } ); diff --git a/apps/web/app/api/v1/management/responses/route.ts b/apps/web/app/api/v1/management/responses/route.ts index fe3fb059adfd..dffb4b8fa293 100644 --- a/apps/web/app/api/v1/management/responses/route.ts +++ b/apps/web/app/api/v1/management/responses/route.ts @@ -1,122 +1,183 @@ -import { authenticateRequest } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { validateFileUploads } from "@/lib/fileValidation"; +import { getResponses } from "@/lib/response/service"; +import { getSurvey } from "@/lib/survey/service"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { NextRequest } from "next/server"; -import { getResponses } from "@formbricks/lib/response/service"; -import { getSurvey } from "@formbricks/lib/survey/service"; import { logger } from "@formbricks/logger"; import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; -import { TResponse, ZResponseInput } from "@formbricks/types/responses"; +import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses"; import { createResponse, getResponsesByEnvironmentIds } from "./lib/response"; -export const GET = async (request: NextRequest) => { - const searchParams = request.nextUrl.searchParams; - const surveyId = searchParams.get("surveyId"); - const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined; - const offset = searchParams.get("skip") ? Number(searchParams.get("skip")) : undefined; +export const GET = withV1ApiWrapper({ + handler: async ({ + req, + authentication, + }: { + req: NextRequest; + authentication: NonNullable; + }) => { + const searchParams = req.nextUrl.searchParams; + const surveyId = searchParams.get("surveyId"); + const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined; + const offset = searchParams.get("skip") ? Number(searchParams.get("skip")) : undefined; - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); - let allResponses: TResponse[] = []; - - if (surveyId) { - const survey = await getSurvey(surveyId); - if (!survey) { - return responses.notFoundResponse("Survey", surveyId, true); + try { + let allResponses: TResponse[] = []; + + if (surveyId) { + const survey = await getSurvey(surveyId); + if (!survey) { + return { + response: responses.notFoundResponse("Survey", surveyId, true), + }; + } + if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) { + return { + response: responses.unauthorizedResponse(), + }; + } + const surveyResponses = await getResponses(surveyId, limit, offset); + allResponses.push(...surveyResponses); + } else { + const environmentIds = authentication.environmentPermissions.map( + (permission) => permission.environmentId + ); + const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset); + allResponses.push(...environmentResponses); } - if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) { - return responses.unauthorizedResponse(); + return { + response: responses.successResponse(allResponses), + }; + } catch (error) { + if (error instanceof DatabaseError) { + return { + response: responses.badRequestResponse(error.message), + }; } - const surveyResponses = await getResponses(surveyId, limit, offset); - allResponses.push(...surveyResponses); - } else { - const environmentIds = authentication.environmentPermissions.map( - (permission) => permission.environmentId - ); - const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset); - allResponses.push(...environmentResponses); - } - return responses.successResponse(allResponses); - } catch (error) { - if (error instanceof DatabaseError) { - return responses.badRequestResponse(error.message); + throw error; } - throw error; - } -}; + }, +}); -export const POST = async (request: Request): Promise => { +const validateInput = async (request: Request) => { + let jsonInput; try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); - - let jsonInput; - - try { - jsonInput = await request.json(); - } catch (err) { - logger.error({ error: err, url: request.url }, "Error parsing JSON input"); - return responses.badRequestResponse("Malformed JSON input, please check your request body"); - } - - const inputValidation = ZResponseInput.safeParse(jsonInput); + jsonInput = await request.json(); + } catch (err) { + logger.error({ error: err, url: request.url }, "Error parsing JSON input"); + return { error: responses.badRequestResponse("Malformed JSON input, please check your request body") }; + } - if (!inputValidation.success) { - return responses.badRequestResponse( + const inputValidation = ZResponseInput.safeParse(jsonInput); + if (!inputValidation.success) { + return { + error: responses.badRequestResponse( "Fields are missing or incorrectly formatted", transformErrorToDetails(inputValidation.error), true - ); - } - - const responseInput = inputValidation.data; - - const environmentId = responseInput.environmentId; + ), + }; + } - if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { - return responses.unauthorizedResponse(); - } + return { data: inputValidation.data }; +}; - // get and check survey - const survey = await getSurvey(responseInput.surveyId); - if (!survey) { - return responses.notFoundResponse("Survey", responseInput.surveyId, true); - } - if (survey.environmentId !== environmentId) { - return responses.badRequestResponse( +const validateSurvey = async (responseInput: TResponseInput, environmentId: string) => { + const survey = await getSurvey(responseInput.surveyId); + if (!survey) { + return { error: responses.notFoundResponse("Survey", responseInput.surveyId, true) }; + } + if (survey.environmentId !== environmentId) { + return { + error: responses.badRequestResponse( "Survey is part of another environment", { "survey.environmentId": survey.environmentId, environmentId, }, true - ); - } - - // if there is a createdAt but no updatedAt, set updatedAt to createdAt - if (responseInput.createdAt && !responseInput.updatedAt) { - responseInput.updatedAt = responseInput.createdAt; - } + ), + }; + } + return { survey }; +}; - let response: TResponse; +export const POST = withV1ApiWrapper({ + handler: async ({ + req, + auditLog, + authentication, + }: { + req: NextRequest; + auditLog: TApiAuditLog; + authentication: NonNullable; + }) => { try { - response = await createResponse(inputValidation.data); - } catch (error) { - if (error instanceof InvalidInputError) { - return responses.badRequestResponse(error.message); - } else { - logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses"); - return responses.internalServerErrorResponse(error.message); + const inputResult = await validateInput(req); + if (inputResult.error) { + return { + response: inputResult.error, + }; + } + + const responseInput = inputResult.data; + const environmentId = responseInput.environmentId; + + if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { + return { + response: responses.unauthorizedResponse(), + }; + } + + const surveyResult = await validateSurvey(responseInput, environmentId); + if (surveyResult.error) { + return { + response: surveyResult.error, + }; } - } - return responses.successResponse(response, true); - } catch (error) { - if (error instanceof DatabaseError) { - return responses.badRequestResponse(error.message); + if (!validateFileUploads(responseInput.data, surveyResult.survey.questions)) { + return { + response: responses.badRequestResponse("Invalid file upload response"), + }; + } + + if (responseInput.createdAt && !responseInput.updatedAt) { + responseInput.updatedAt = responseInput.createdAt; + } + + try { + const response = await createResponse(responseInput); + auditLog.targetId = response.id; + auditLog.newObject = response; + return { + response: responses.successResponse(response, true), + }; + } catch (error) { + logger.error({ error, url: req.url }, "Error in POST /api/v1/management/responses"); + + if (error instanceof InvalidInputError) { + return { + response: responses.badRequestResponse(error.message), + }; + } + + return { + response: responses.internalServerErrorResponse(error.message), + }; + } + } catch (error) { + if (error instanceof DatabaseError) { + return { + response: responses.badRequestResponse("An unexpected error occurred while creating the response"), + }; + } + throw error; } - throw error; - } -}; + }, + action: "created", + targetType: "response", +}); diff --git a/apps/web/app/api/v1/management/storage/lib/getSignedUrl.test.ts b/apps/web/app/api/v1/management/storage/lib/getSignedUrl.test.ts new file mode 100644 index 000000000000..04b3c3f70262 --- /dev/null +++ b/apps/web/app/api/v1/management/storage/lib/getSignedUrl.test.ts @@ -0,0 +1,58 @@ +import { responses } from "@/app/lib/api/response"; +import { getUploadSignedUrl } from "@/lib/storage/service"; +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { getSignedUrlForPublicFile } from "./getSignedUrl"; + +vi.mock("@/app/lib/api/response", () => ({ + responses: { + successResponse: vi.fn((data) => ({ data })), + internalServerErrorResponse: vi.fn((message) => ({ message })), + }, +})); + +vi.mock("@/lib/storage/service", () => ({ + getUploadSignedUrl: vi.fn(), +})); + +describe("getSignedUrlForPublicFile", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("should return success response with signed URL data", async () => { + const mockFileName = "test.jpg"; + const mockEnvironmentId = "env123"; + const mockFileType = "image/jpeg"; + const mockSignedUrlResponse = { + signedUrl: "http://example.com/signed-url", + signingData: { signature: "sig", timestamp: 123, uuid: "uuid" }, + updatedFileName: "test--fid--uuid.jpg", + fileUrl: "http://example.com/file-url", + }; + + vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse); + + const result = await getSignedUrlForPublicFile(mockFileName, mockEnvironmentId, mockFileType); + + expect(getUploadSignedUrl).toHaveBeenCalledWith(mockFileName, mockEnvironmentId, mockFileType, "public"); + expect(responses.successResponse).toHaveBeenCalledWith(mockSignedUrlResponse); + expect(result).toEqual({ data: mockSignedUrlResponse }); + }); + + test("should return internal server error response when getUploadSignedUrl throws an error", async () => { + const mockFileName = "test.png"; + const mockEnvironmentId = "env456"; + const mockFileType = "image/png"; + const mockError = new Error("Failed to get signed URL"); + + vi.mocked(getUploadSignedUrl).mockRejectedValue(mockError); + + const result = await getSignedUrlForPublicFile(mockFileName, mockEnvironmentId, mockFileType); + + expect(getUploadSignedUrl).toHaveBeenCalledWith(mockFileName, mockEnvironmentId, mockFileType, "public"); + expect(responses.internalServerErrorResponse).toHaveBeenCalledWith("Internal server error"); + expect(result).toEqual({ message: "Internal server error" }); + }); +}); diff --git a/apps/web/app/api/v1/management/storage/lib/getSignedUrl.ts b/apps/web/app/api/v1/management/storage/lib/getSignedUrl.ts index 7e44385973e1..8b98f1075e61 100644 --- a/apps/web/app/api/v1/management/storage/lib/getSignedUrl.ts +++ b/apps/web/app/api/v1/management/storage/lib/getSignedUrl.ts @@ -1,5 +1,5 @@ import { responses } from "@/app/lib/api/response"; -import { getUploadSignedUrl } from "@formbricks/lib/storage/service"; +import { getUploadSignedUrl } from "@/lib/storage/service"; export const getSignedUrlForPublicFile = async ( fileName: string, diff --git a/apps/web/app/api/v1/management/storage/lib/utils.test.ts b/apps/web/app/api/v1/management/storage/lib/utils.test.ts new file mode 100644 index 000000000000..270874fe3ab0 --- /dev/null +++ b/apps/web/app/api/v1/management/storage/lib/utils.test.ts @@ -0,0 +1,196 @@ +import { responses } from "@/app/lib/api/response"; +import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { Session } from "next-auth"; +import { describe, expect, test, vi } from "vitest"; +import { TAuthenticationApiKey } from "@formbricks/types/auth"; +import { checkAuth, checkForRequiredFields } from "./utils"; + +// Create mock response objects +const mockBadRequestResponse = new Response("Bad Request", { status: 400 }); +const mockNotAuthenticatedResponse = new Response("Not authenticated", { status: 401 }); +const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 }); + +vi.mock("@/lib/environment/auth", () => ({ + hasUserEnvironmentAccess: vi.fn(), +})); + +vi.mock("@/modules/organization/settings/api-keys/lib/utils", () => ({ + hasPermission: vi.fn(), +})); + +vi.mock("@/app/lib/api/response", () => ({ + responses: { + badRequestResponse: vi.fn(() => mockBadRequestResponse), + notAuthenticatedResponse: vi.fn(() => mockNotAuthenticatedResponse), + unauthorizedResponse: vi.fn(() => mockUnauthorizedResponse), + }, +})); + +describe("checkForRequiredFields", () => { + test("should return undefined when all required fields are present", () => { + const result = checkForRequiredFields("env-123", "image/png", "test-file.png"); + expect(result).toBeUndefined(); + }); + + test("should return bad request response when environmentId is missing", () => { + const result = checkForRequiredFields("", "image/png", "test-file.png"); + expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required"); + expect(result).toBe(mockBadRequestResponse); + }); + + test("should return bad request response when fileType is missing", () => { + const result = checkForRequiredFields("env-123", "", "test-file.png"); + expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required"); + expect(result).toBe(mockBadRequestResponse); + }); + + test("should return bad request response when encodedFileName is missing", () => { + const result = checkForRequiredFields("env-123", "image/png", ""); + expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required"); + expect(result).toBe(mockBadRequestResponse); + }); + + test("should return bad request response when environmentId is undefined", () => { + const result = checkForRequiredFields(undefined as any, "image/png", "test-file.png"); + expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required"); + expect(result).toBe(mockBadRequestResponse); + }); + + test("should return bad request response when fileType is undefined", () => { + const result = checkForRequiredFields("env-123", undefined as any, "test-file.png"); + expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required"); + expect(result).toBe(mockBadRequestResponse); + }); + + test("should return bad request response when encodedFileName is undefined", () => { + const result = checkForRequiredFields("env-123", "image/png", undefined as any); + expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required"); + expect(result).toBe(mockBadRequestResponse); + }); +}); + +describe("checkAuth", () => { + const environmentId = "env-123"; + + test("returns notAuthenticatedResponse when authentication is null", async () => { + const result = await checkAuth(null, environmentId); + + expect(responses.notAuthenticatedResponse).toHaveBeenCalled(); + expect(result).toBe(mockNotAuthenticatedResponse); + }); + + test("returns notAuthenticatedResponse when authentication is undefined", async () => { + const result = await checkAuth(undefined as any, environmentId); + + expect(responses.notAuthenticatedResponse).toHaveBeenCalled(); + expect(result).toBe(mockNotAuthenticatedResponse); + }); + + test("returns unauthorizedResponse when API key authentication lacks POST permission", async () => { + const mockAuthentication: TAuthenticationApiKey = { + type: "apiKey", + environmentPermissions: [ + { + environmentId: "env-123", + permission: "read", + environmentType: "development", + projectId: "project-1", + projectName: "Project 1", + }, + ], + hashedApiKey: "hashed-key", + apiKeyId: "api-key-id", + organizationId: "org-id", + organizationAccess: { + accessControl: {}, + }, + }; + + vi.mocked(hasPermission).mockReturnValue(false); + + const result = await checkAuth(mockAuthentication, environmentId); + + expect(hasPermission).toHaveBeenCalledWith( + mockAuthentication.environmentPermissions, + environmentId, + "POST" + ); + expect(responses.unauthorizedResponse).toHaveBeenCalled(); + expect(result).toBe(mockUnauthorizedResponse); + }); + + test("returns undefined when API key authentication has POST permission", async () => { + const mockAuthentication: TAuthenticationApiKey = { + type: "apiKey", + environmentPermissions: [ + { + environmentId: "env-123", + permission: "write", + environmentType: "development", + projectId: "project-1", + projectName: "Project 1", + }, + ], + hashedApiKey: "hashed-key", + apiKeyId: "api-key-id", + organizationId: "org-id", + organizationAccess: { + accessControl: {}, + }, + }; + + vi.mocked(hasPermission).mockReturnValue(true); + + const result = await checkAuth(mockAuthentication, environmentId); + + expect(hasPermission).toHaveBeenCalledWith( + mockAuthentication.environmentPermissions, + environmentId, + "POST" + ); + expect(result).toBeUndefined(); + }); + + test("returns unauthorizedResponse when session exists but user lacks environment access", async () => { + const mockSession: Session = { + user: { + id: "user-123", + }, + expires: "2024-12-31T23:59:59.999Z", + }; + + vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false); + + const result = await checkAuth(mockSession, environmentId); + + expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId); + expect(responses.unauthorizedResponse).toHaveBeenCalled(); + expect(result).toBe(mockUnauthorizedResponse); + }); + + test("returns undefined when session exists and user has environment access", async () => { + const mockSession: Session = { + user: { + id: "user-123", + }, + expires: "2024-12-31T23:59:59.999Z", + }; + + vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true); + + const result = await checkAuth(mockSession, environmentId); + + expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId); + expect(result).toBeUndefined(); + }); + + test("returns notAuthenticatedResponse when authentication object is neither session nor API key", async () => { + const invalidAuth = { someProperty: "value" } as any; + + const result = await checkAuth(invalidAuth, environmentId); + + expect(responses.notAuthenticatedResponse).toHaveBeenCalled(); + expect(result).toBe(mockNotAuthenticatedResponse); + }); +}); diff --git a/apps/web/app/api/v1/management/storage/lib/utils.ts b/apps/web/app/api/v1/management/storage/lib/utils.ts new file mode 100644 index 000000000000..bcd229bfd2cd --- /dev/null +++ b/apps/web/app/api/v1/management/storage/lib/utils.ts @@ -0,0 +1,41 @@ +import { responses } from "@/app/lib/api/response"; +import { TApiV1Authentication } from "@/app/lib/api/with-api-logging"; +import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; + +export const checkForRequiredFields = ( + environmentId: string, + fileType: string, + encodedFileName: string +): Response | undefined => { + if (!environmentId) { + return responses.badRequestResponse("environmentId is required"); + } + + if (!fileType) { + return responses.badRequestResponse("contentType is required"); + } + + if (!encodedFileName) { + return responses.badRequestResponse("fileName is required"); + } +}; + +export const checkAuth = async (authentication: TApiV1Authentication, environmentId: string) => { + if (!authentication) { + return responses.notAuthenticatedResponse(); + } + + if ("user" in authentication) { + const isUserAuthorized = await hasUserEnvironmentAccess(authentication.user.id, environmentId); + if (!isUserAuthorized) { + return responses.unauthorizedResponse(); + } + } else if ("hashedApiKey" in authentication) { + if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { + return responses.unauthorizedResponse(); + } + } else { + return responses.notAuthenticatedResponse(); + } +}; diff --git a/apps/web/app/api/v1/management/storage/local/route.ts b/apps/web/app/api/v1/management/storage/local/route.ts index 4c1398903ed4..148c08cb8d3d 100644 --- a/apps/web/app/api/v1/management/storage/local/route.ts +++ b/apps/web/app/api/v1/management/storage/local/route.ts @@ -1,106 +1,109 @@ // headers -> "Content-Type" should be present and set to a valid MIME type // body -> should be a valid file object (buffer) // method -> PUT (to be the same as the signedUrl method) +import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils"; import { responses } from "@/app/lib/api/response"; -import { authOptions } from "@/modules/auth/lib/authOptions"; -import { getServerSession } from "next-auth"; -import { headers } from "next/headers"; +import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants"; +import { validateLocalSignedUrl } from "@/lib/crypto"; +import { validateFile } from "@/lib/fileValidation"; +import { putFileToLocalStorage } from "@/lib/storage/service"; import { NextRequest } from "next/server"; -import { ENCRYPTION_KEY, UPLOADS_DIR } from "@formbricks/lib/constants"; -import { validateLocalSignedUrl } from "@formbricks/lib/crypto"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; -import { putFileToLocalStorage } from "@formbricks/lib/storage/service"; - -export const POST = async (req: NextRequest): Promise => { - if (!ENCRYPTION_KEY) { - return responses.internalServerErrorResponse("Encryption key is not set"); - } - - const accessType = "public"; // public files are accessible by anyone - const headersList = await headers(); - - const fileType = headersList.get("X-File-Type"); - const encodedFileName = headersList.get("X-File-Name"); - const environmentId = headersList.get("X-Environment-ID"); - - const signedSignature = headersList.get("X-Signature"); - const signedUuid = headersList.get("X-UUID"); - const signedTimestamp = headersList.get("X-Timestamp"); - - if (!fileType) { - return responses.badRequestResponse("fileType is required"); - } - - if (!encodedFileName) { - return responses.badRequestResponse("fileName is required"); - } - - if (!environmentId) { - return responses.badRequestResponse("environmentId is required"); - } - - if (!signedSignature) { - return responses.unauthorizedResponse(); - } - - if (!signedUuid) { - return responses.unauthorizedResponse(); - } - - if (!signedTimestamp) { - return responses.unauthorizedResponse(); - } - - const session = await getServerSession(authOptions); - - if (!session || !session.user) { - return responses.notAuthenticatedResponse(); - } - - const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); - - if (!isUserAuthorized) { - return responses.unauthorizedResponse(); - } +import { logger } from "@formbricks/logger"; + +export const POST = withV1ApiWrapper({ + handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => { + if (!ENCRYPTION_KEY) { + return { + response: responses.internalServerErrorResponse("Encryption key is not set"), + }; + } - const fileName = decodeURIComponent(encodedFileName); + const accessType = "public"; // public files are accessible by anyone + + const jsonInput = await req.json(); + const fileType = jsonInput.fileType as string; + const encodedFileName = jsonInput.fileName as string; + const signedSignature = jsonInput.signature as string; + const signedUuid = jsonInput.uuid as string; + const signedTimestamp = jsonInput.timestamp as string; + const environmentId = jsonInput.environmentId as string; + + const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, encodedFileName); + if (requiredFieldResponse) { + return { + response: requiredFieldResponse, + }; + } - // validate signature + if (!signedSignature || !signedUuid || !signedTimestamp) { + return { + response: responses.unauthorizedResponse(), + }; + } - const validated = validateLocalSignedUrl( - signedUuid, - fileName, - environmentId, - fileType, - Number(signedTimestamp), - signedSignature, - ENCRYPTION_KEY - ); + const authResponse = await checkAuth(authentication, environmentId); + if (authResponse) return { response: authResponse }; - if (!validated) { - return responses.unauthorizedResponse(); - } + const fileName = decodeURIComponent(encodedFileName); - const formData = await req.formData(); - const file = formData.get("file") as unknown as File; + // Perform server-side file validation + const fileValidation = validateFile(fileName, fileType); + if (!fileValidation.valid) { + return { + response: responses.badRequestResponse(fileValidation.error ?? "Invalid file"), + }; + } - if (!file) { - return responses.badRequestResponse("fileBuffer is required"); - } + // validate signature + + const validated = validateLocalSignedUrl( + signedUuid, + fileName, + environmentId, + fileType, + Number(signedTimestamp), + signedSignature, + ENCRYPTION_KEY + ); + + if (!validated) { + return { + response: responses.unauthorizedResponse(), + }; + } - try { - const bytes = await file.arrayBuffer(); - const fileBuffer = Buffer.from(bytes); + const base64String = jsonInput.fileBase64String as string; + const buffer = Buffer.from(base64String.split(",")[1], "base64"); + const file = new Blob([buffer], { type: fileType }); - await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR); + if (!file) { + return { + response: responses.badRequestResponse("fileBuffer is required"), + }; + } - return responses.successResponse({ - message: "File uploaded successfully", - }); - } catch (err) { - if (err.name === "FileTooLargeError") { - return responses.badRequestResponse(err.message); + try { + const bytes = await file.arrayBuffer(); + const fileBuffer = Buffer.from(bytes); + + await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR); + + return { + response: responses.successResponse({ + message: "File uploaded successfully", + }), + }; + } catch (err) { + logger.error(err, "Error uploading file"); + if (err.name === "FileTooLargeError") { + return { + response: responses.badRequestResponse(err.message), + }; + } + return { + response: responses.internalServerErrorResponse("File upload failed"), + }; } - return responses.internalServerErrorResponse("File upload failed"); - } -}; + }, +}); diff --git a/apps/web/app/api/v1/management/storage/route.ts b/apps/web/app/api/v1/management/storage/route.ts index 9a5060b2be2b..d45f386b56bc 100644 --- a/apps/web/app/api/v1/management/storage/route.ts +++ b/apps/web/app/api/v1/management/storage/route.ts @@ -1,8 +1,8 @@ +import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils"; import { responses } from "@/app/lib/api/response"; -import { authOptions } from "@/modules/auth/lib/authOptions"; -import { getServerSession } from "next-auth"; +import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { validateFile } from "@/lib/fileValidation"; import { NextRequest } from "next/server"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { logger } from "@formbricks/logger"; import { getSignedUrlForPublicFile } from "./lib/getSignedUrl"; @@ -12,51 +12,57 @@ import { getSignedUrlForPublicFile } from "./lib/getSignedUrl"; // use this to upload files for a specific resource, e.g. a user profile picture or a survey // this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage -export const POST = async (req: NextRequest): Promise => { - let storageInput; +export const POST = withV1ApiWrapper({ + handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => { + let storageInput; - try { - storageInput = await req.json(); - } catch (error) { - logger.error({ error, url: req.url }, "Error parsing JSON input"); - return responses.badRequestResponse("Malformed JSON input, please check your request body"); - } - - const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput; - - if (!fileName) { - return responses.badRequestResponse("fileName is required"); - } - - if (!fileType) { - return responses.badRequestResponse("fileType is required"); - } + try { + storageInput = await req.json(); + } catch (error) { + logger.error({ error, url: req.url }, "Error parsing JSON input"); + return { + response: responses.badRequestResponse("Malformed JSON input, please check your request body"), + }; + } - if (!environmentId) { - return responses.badRequestResponse("environmentId is required"); - } + const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput; - if (allowedFileExtensions?.length) { - const fileExtension = fileName.split(".").pop(); - if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) { - return responses.badRequestResponse( - `File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}` - ); + const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName); + if (requiredFieldResponse) { + return { + response: requiredFieldResponse, + }; } - } - // auth and upload private file - const session = await getServerSession(authOptions); - - if (!session || !session.user) { - return responses.notAuthenticatedResponse(); - } + const authResponse = await checkAuth(authentication, environmentId); + if (authResponse) { + return { + response: authResponse, + }; + } - const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); + // Perform server-side file validation first to block dangerous file types + const fileValidation = validateFile(fileName, fileType); + if (!fileValidation.valid) { + return { + response: responses.badRequestResponse(fileValidation.error ?? "Invalid file type"), + }; + } - if (!isUserAuthorized) { - return responses.unauthorizedResponse(); - } + // Also perform client-specified allowed file extensions validation if provided + if (allowedFileExtensions?.length) { + const fileExtension = fileName.split(".").pop()?.toLowerCase(); + if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) { + return { + response: responses.badRequestResponse( + `File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}` + ), + }; + } + } - return await getSignedUrlForPublicFile(fileName, environmentId, fileType); -}; + return { + response: await getSignedUrlForPublicFile(fileName, environmentId, fileType), + }; + }, +}); diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/lib/surveys.test.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/lib/surveys.test.ts new file mode 100644 index 000000000000..ac3de3c2811b --- /dev/null +++ b/apps/web/app/api/v1/management/surveys/[surveyId]/lib/surveys.test.ts @@ -0,0 +1,121 @@ +import { validateInputs } from "@/lib/utils/validate"; +import { Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { DatabaseError } from "@formbricks/types/errors"; +import { deleteSurvey } from "./surveys"; + +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), +})); +vi.mock("@formbricks/database", () => ({ + prisma: { + survey: { + delete: vi.fn(), + }, + segment: { + delete: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +const surveyId = "clq5n7p1q0000m7z0h5p6g3r2"; +const environmentId = "clq5n7p1q0000m7z0h5p6g3r3"; +const segmentId = "clq5n7p1q0000m7z0h5p6g3r4"; +const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5"; +const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6"; + +const mockDeletedSurveyAppPrivateSegment = { + id: surveyId, + environmentId, + type: "app", + segment: { id: segmentId, isPrivate: true }, + triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }], +}; + +const mockDeletedSurveyLink = { + id: surveyId, + environmentId, + type: "link", + segment: null, + triggers: [], +}; + +describe("deleteSurvey", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should delete a link survey without a segment and revalidate caches", async () => { + vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any); + + const deletedSurvey = await deleteSurvey(surveyId); + + expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]); + expect(prisma.survey.delete).toHaveBeenCalledWith({ + where: { id: surveyId }, + include: { + segment: true, + triggers: { include: { actionClass: true } }, + }, + }); + expect(prisma.segment.delete).not.toHaveBeenCalled(); + + expect(deletedSurvey).toEqual(mockDeletedSurveyLink); + }); + + test("should handle PrismaClientKnownRequestError during survey deletion", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", { + code: "P2025", + clientVersion: "4.0.0", + }); + vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError); + + await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError); + expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey"); + expect(prisma.segment.delete).not.toHaveBeenCalled(); + }); + + test("should handle PrismaClientKnownRequestError during segment deletion", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Foreign key constraint failed", { + code: "P2003", + clientVersion: "4.0.0", + }); + vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyAppPrivateSegment as any); + vi.mocked(prisma.segment.delete).mockRejectedValue(prismaError); + + await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError); + expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey"); + expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } }); + }); + + test("should handle generic errors during deletion", async () => { + const genericError = new Error("Something went wrong"); + vi.mocked(prisma.survey.delete).mockRejectedValue(genericError); + + await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError); + expect(logger.error).not.toHaveBeenCalled(); + expect(prisma.segment.delete).not.toHaveBeenCalled(); + }); + + test("should throw validation error for invalid surveyId", async () => { + const invalidSurveyId = "invalid-id"; + const validationError = new Error("Validation failed"); + vi.mocked(validateInputs).mockImplementation(() => { + throw validationError; + }); + + await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError); + expect(prisma.survey.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/lib/surveys.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/lib/surveys.ts index c70179f17b19..ac7411e870ca 100644 --- a/apps/web/app/api/v1/management/surveys/[surveyId]/lib/surveys.ts +++ b/apps/web/app/api/v1/management/surveys/[surveyId]/lib/surveys.ts @@ -1,10 +1,7 @@ +import { validateInputs } from "@/lib/utils/validate"; import { Prisma } from "@prisma/client"; import { z } from "zod"; import { prisma } from "@formbricks/database"; -import { segmentCache } from "@formbricks/lib/cache/segment"; -import { responseCache } from "@formbricks/lib/response/cache"; -import { surveyCache } from "@formbricks/lib/survey/cache"; -import { validateInputs } from "@formbricks/lib/utils/validate"; import { logger } from "@formbricks/logger"; import { DatabaseError } from "@formbricks/types/errors"; @@ -27,44 +24,13 @@ export const deleteSurvey = async (surveyId: string) => { }); if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) { - const deletedSegment = await prisma.segment.delete({ + await prisma.segment.delete({ where: { id: deletedSurvey.segment.id, }, }); - - if (deletedSegment) { - segmentCache.revalidate({ - id: deletedSegment.id, - environmentId: deletedSurvey.environmentId, - }); - } - } - - responseCache.revalidate({ - surveyId, - environmentId: deletedSurvey.environmentId, - }); - surveyCache.revalidate({ - id: deletedSurvey.id, - environmentId: deletedSurvey.environmentId, - resultShareKey: deletedSurvey.resultShareKey ?? undefined, - }); - - if (deletedSurvey.segment?.id) { - segmentCache.revalidate({ - id: deletedSurvey.segment.id, - environmentId: deletedSurvey.environmentId, - }); } - // Revalidate public triggers by actionClassId - deletedSurvey.triggers.forEach((trigger) => { - surveyCache.revalidate({ - actionClassId: trigger.actionClass.id, - }); - }); - return deletedSurvey; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts index 1e5f46a4d6c3..b0a3a71c6a0b 100644 --- a/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts +++ b/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts @@ -1,12 +1,13 @@ -import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; +import { handleErrorResponse } from "@/app/api/v1/auth"; import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys"; +import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; +import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; -import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; -import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; +import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types"; @@ -27,91 +28,147 @@ const fetchAndAuthorizeSurvey = async ( return { survey }; }; -export const GET = async ( - request: Request, - props: { params: Promise<{ surveyId: string }> } -): Promise => { - const params = await props.params; - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); - const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET"); - if (result.error) return result.error; - return responses.successResponse(result.survey); - } catch (error) { - return handleErrorResponse(error); - } -}; +export const GET = withV1ApiWrapper({ + handler: async ({ + props, + authentication, + }: { + props: { params: Promise<{ surveyId: string }> }; + authentication: NonNullable; + }) => { + const params = await props.params; -export const DELETE = async ( - request: Request, - props: { params: Promise<{ surveyId: string }> } -): Promise => { - const params = await props.params; - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); - const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE"); - if (result.error) return result.error; - const deletedSurvey = await deleteSurvey(params.surveyId); - return responses.successResponse(deletedSurvey); - } catch (error) { - return handleErrorResponse(error); - } -}; - -export const PUT = async ( - request: Request, - props: { params: Promise<{ surveyId: string }> } -): Promise => { - const params = await props.params; - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); - const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT"); - if (result.error) return result.error; - - const organization = await getOrganizationByEnvironmentId(result.survey.environmentId); - if (!organization) { - return responses.notFoundResponse("Organization", null); + try { + const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET"); + if (result.error) { + return { + response: result.error, + }; + } + return { + response: responses.successResponse(result.survey), + }; + } catch (error) { + return { + response: handleErrorResponse(error), + }; } + }, +}); - let surveyUpdate; +export const DELETE = withV1ApiWrapper({ + handler: async ({ + props, + auditLog, + authentication, + }: { + props: { params: Promise<{ surveyId: string }> }; + auditLog: TApiAuditLog; + authentication: NonNullable; + }) => { + const params = await props.params; + auditLog.targetId = params.surveyId; try { - surveyUpdate = await request.json(); + const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE"); + if (result.error) { + return { + response: result.error, + }; + } + auditLog.oldObject = result.survey; + + const deletedSurvey = await deleteSurvey(params.surveyId); + return { + response: responses.successResponse(deletedSurvey), + }; } catch (error) { - logger.error({ error, url: request.url }, "Error parsing JSON input"); - return responses.badRequestResponse("Malformed JSON input, please check your request body"); + return { + response: handleErrorResponse(error), + }; } + }, + action: "deleted", + targetType: "survey", +}); - const inputValidation = ZSurveyUpdateInput.safeParse({ - ...result.survey, - ...surveyUpdate, - }); +export const PUT = withV1ApiWrapper({ + handler: async ({ + req, + props, + auditLog, + authentication, + }: { + req: NextRequest; + props: { params: Promise<{ surveyId: string }> }; + auditLog: TApiAuditLog; + authentication: NonNullable; + }) => { + const params = await props.params; + auditLog.targetId = params.surveyId; + try { + const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT"); + if (result.error) { + return { + response: result.error, + }; + } + auditLog.oldObject = result.survey; - if (!inputValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(inputValidation.error) - ); - } + const organization = await getOrganizationByEnvironmentId(result.survey.environmentId); + if (!organization) { + return { + response: responses.notFoundResponse("Organization", null), + }; + } - if (surveyUpdate.followUps && surveyUpdate.followUps.length) { - const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan); - if (!isSurveyFollowUpsEnabled) { - return responses.forbiddenResponse("Survey follow ups are not enabled for this organization"); + let surveyUpdate; + try { + surveyUpdate = await req.json(); + } catch (error) { + logger.error({ error, url: req.url }, "Error parsing JSON input"); + return { + response: responses.badRequestResponse("Malformed JSON input, please check your request body"), + }; } - } - if (surveyUpdate.languages && surveyUpdate.languages.length) { - const isMultiLanguageEnabled = await getMultiLanguagePermission(organization.billing.plan); - if (!isMultiLanguageEnabled) { - return responses.forbiddenResponse("Multi language is not enabled for this organization"); + const inputValidation = ZSurveyUpdateInput.safeParse({ + ...result.survey, + ...surveyUpdate, + }); + + if (!inputValidation.success) { + return { + response: responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(inputValidation.error) + ), + }; } - } - return responses.successResponse(await updateSurvey({ ...inputValidation.data, id: params.surveyId })); - } catch (error) { - return handleErrorResponse(error); - } -}; + const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization); + if (featureCheckResult) { + return { + response: featureCheckResult, + }; + } + + try { + const updatedSurvey = await updateSurvey({ ...inputValidation.data, id: params.surveyId }); + auditLog.newObject = updatedSurvey; + return { + response: responses.successResponse(updatedSurvey), + }; + } catch (error) { + return { + response: handleErrorResponse(error), + }; + } + } catch (error) { + return { + response: handleErrorResponse(error), + }; + } + }, + action: "updated", + targetType: "survey", +}); diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts index 93439f92f3ea..fc25a9cafc6c 100644 --- a/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts +++ b/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts @@ -1,51 +1,77 @@ -import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; +import { handleErrorResponse } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; +import { TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { getPublicDomain } from "@/lib/getPublicUrl"; +import { getSurvey } from "@/lib/survey/service"; +import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { NextRequest } from "next/server"; -import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl"; -import { getSurvey } from "@formbricks/lib/survey/service"; -import { generateSurveySingleUseIds } from "@formbricks/lib/utils/singleUseSurveys"; - -export const GET = async ( - request: NextRequest, - props: { params: Promise<{ surveyId: string }> } -): Promise => { - const params = await props.params; - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); - const survey = await getSurvey(params.surveyId); - if (!survey) { - return responses.notFoundResponse("Survey", params.surveyId); - } - if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) { - return responses.unauthorizedResponse(); - } - if (!survey.singleUse || !survey.singleUse.enabled) { - return responses.badRequestResponse("Single use links are not enabled for this survey"); - } - const searchParams = request.nextUrl.searchParams; - const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : 10; +export const GET = withV1ApiWrapper({ + handler: async ({ + req, + props, + authentication, + }: { + req: NextRequest; + props: { params: Promise<{ surveyId: string }> }; + authentication: NonNullable; + }) => { + try { + const params = await props.params; + const survey = await getSurvey(params.surveyId); + if (!survey) { + return { + response: responses.notFoundResponse("Survey", params.surveyId), + }; + } + if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) { + return { + response: responses.unauthorizedResponse(), + }; + } - if (limit < 1) { - return responses.badRequestResponse("Limit cannot be less than 1"); - } + if (survey.type !== "link") { + return { + response: responses.badRequestResponse("Single use links are only available for link surveys"), + }; + } - if (limit > 5000) { - return responses.badRequestResponse("Limit cannot be more than 5000"); - } + if (!survey.singleUse || !survey.singleUse.enabled) { + return { + response: responses.badRequestResponse("Single use links are not enabled for this survey"), + }; + } + const searchParams = req.nextUrl.searchParams; + const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : 10; + + if (limit < 1) { + return { + response: responses.badRequestResponse("Limit cannot be less than 1"), + }; + } - const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted); + if (limit > 5000) { + return { + response: responses.badRequestResponse("Limit cannot be more than 5000"), + }; + } - const surveyDomain = getSurveyDomain(); - // map single use ids to survey links - const surveyLinks = singleUseIds.map( - (singleUseId) => `${surveyDomain}/s/${survey.id}?suId=${singleUseId}` - ); + const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted); - return responses.successResponse(surveyLinks); - } catch (error) { - return handleErrorResponse(error); - } -}; + const publicDomain = getPublicDomain(); + // map single use ids to survey links + const surveyLinks = singleUseIds.map( + (singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}` + ); + + return { + response: responses.successResponse(surveyLinks), + }; + } catch (error) { + return { + response: handleErrorResponse(error), + }; + } + }, +}); diff --git a/apps/web/app/api/v1/management/surveys/lib/surveys.test.ts b/apps/web/app/api/v1/management/surveys/lib/surveys.test.ts new file mode 100644 index 000000000000..35d96dc00cc6 --- /dev/null +++ b/apps/web/app/api/v1/management/surveys/lib/surveys.test.ts @@ -0,0 +1,174 @@ +import { selectSurvey } from "@/lib/survey/service"; +import { transformPrismaSurvey } from "@/lib/survey/utils"; +import { validateInputs } from "@/lib/utils/validate"; +import { Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { getSurveys } from "./surveys"; + +// Mock dependencies +vi.mock("@/lib/survey/utils"); +vi.mock("@/lib/utils/validate"); +vi.mock("@formbricks/database", () => ({ + prisma: { + survey: { + findMany: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger"); +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { + ...actual, + cache: vi.fn((fn) => fn), // Mock reactCache to just execute the function + }; +}); + +const environmentId1 = "env1"; +const environmentId2 = "env2"; +const surveyId1 = "survey1"; +const surveyId2 = "survey2"; +const surveyId3 = "survey3"; + +const mockSurveyPrisma1 = { + id: surveyId1, + environmentId: environmentId1, + name: "Survey 1", + updatedAt: new Date(), +}; +const mockSurveyPrisma2 = { + id: surveyId2, + environmentId: environmentId1, + name: "Survey 2", + updatedAt: new Date(), +}; +const mockSurveyPrisma3 = { + id: surveyId3, + environmentId: environmentId2, + name: "Survey 3", + updatedAt: new Date(), +}; + +const mockSurveyTransformed1: TSurvey = { + ...mockSurveyPrisma1, + displayPercentage: null, + segment: null, +} as TSurvey; +const mockSurveyTransformed2: TSurvey = { + ...mockSurveyPrisma2, + displayPercentage: null, + segment: null, +} as TSurvey; +const mockSurveyTransformed3: TSurvey = { + ...mockSurveyPrisma3, + displayPercentage: null, + segment: null, +} as TSurvey; + +describe("getSurveys (Management API)", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(transformPrismaSurvey).mockImplementation((survey) => ({ + ...survey, + displayPercentage: null, + segment: null, + })); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("should return surveys for a single environment ID with limit and offset", async () => { + const limit = 1; + const offset = 1; + vi.mocked(prisma.survey.findMany).mockResolvedValue([mockSurveyPrisma2]); + + const surveys = await getSurveys([environmentId1], limit, offset); + + expect(validateInputs).toHaveBeenCalledWith( + [[environmentId1], expect.any(Object)], + [limit, expect.any(Object)], + [offset, expect.any(Object)] + ); + expect(prisma.survey.findMany).toHaveBeenCalledWith({ + where: { environmentId: { in: [environmentId1] } }, + select: selectSurvey, + orderBy: { updatedAt: "desc" }, + take: limit, + skip: offset, + }); + expect(transformPrismaSurvey).toHaveBeenCalledTimes(1); + expect(transformPrismaSurvey).toHaveBeenCalledWith(mockSurveyPrisma2); + expect(surveys).toEqual([mockSurveyTransformed2]); + }); + + test("should return surveys for multiple environment IDs without limit and offset", async () => { + vi.mocked(prisma.survey.findMany).mockResolvedValue([ + mockSurveyPrisma1, + mockSurveyPrisma2, + mockSurveyPrisma3, + ]); + + const surveys = await getSurveys([environmentId1, environmentId2]); + + expect(validateInputs).toHaveBeenCalledWith( + [[environmentId1, environmentId2], expect.any(Object)], + [undefined, expect.any(Object)], + [undefined, expect.any(Object)] + ); + expect(prisma.survey.findMany).toHaveBeenCalledWith({ + where: { environmentId: { in: [environmentId1, environmentId2] } }, + select: selectSurvey, + orderBy: { updatedAt: "desc" }, + take: undefined, + skip: undefined, + }); + expect(transformPrismaSurvey).toHaveBeenCalledTimes(3); + expect(surveys).toEqual([mockSurveyTransformed1, mockSurveyTransformed2, mockSurveyTransformed3]); + }); + + test("should return an empty array if no surveys are found", async () => { + vi.mocked(prisma.survey.findMany).mockResolvedValue([]); + + const surveys = await getSurveys([environmentId1]); + + expect(prisma.survey.findMany).toHaveBeenCalled(); + expect(transformPrismaSurvey).not.toHaveBeenCalled(); + expect(surveys).toEqual([]); + }); + + test("should handle PrismaClientKnownRequestError", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", { + code: "P2021", + clientVersion: "4.0.0", + }); + vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError); + + await expect(getSurveys([environmentId1])).rejects.toThrow(DatabaseError); + expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys"); + }); + + test("should handle generic errors", async () => { + const genericError = new Error("Something went wrong"); + vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError); + + await expect(getSurveys([environmentId1])).rejects.toThrow(genericError); + expect(logger.error).not.toHaveBeenCalled(); + }); + + test("should throw validation error for invalid input", async () => { + const invalidEnvId = "invalid-env"; + const validationError = new Error("Validation failed"); + vi.mocked(validateInputs).mockImplementation(() => { + throw validationError; + }); + + await expect(getSurveys([invalidEnvId])).rejects.toThrow(validationError); + expect(prisma.survey.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/v1/management/surveys/lib/surveys.ts b/apps/web/app/api/v1/management/surveys/lib/surveys.ts index 9529a51ed5ed..f9f8f5946a33 100644 --- a/apps/web/app/api/v1/management/surveys/lib/surveys.ts +++ b/apps/web/app/api/v1/management/surveys/lib/surveys.ts @@ -1,48 +1,38 @@ import "server-only"; +import { selectSurvey } from "@/lib/survey/service"; +import { transformPrismaSurvey } from "@/lib/survey/utils"; +import { validateInputs } from "@/lib/utils/validate"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { surveyCache } from "@formbricks/lib/survey/cache"; -import { selectSurvey } from "@formbricks/lib/survey/service"; -import { transformPrismaSurvey } from "@formbricks/lib/survey/utils"; -import { validateInputs } from "@formbricks/lib/utils/validate"; import { logger } from "@formbricks/logger"; -import { ZOptionalNumber } from "@formbricks/types/common"; -import { ZId } from "@formbricks/types/common"; +import { ZId, ZOptionalNumber } from "@formbricks/types/common"; import { DatabaseError } from "@formbricks/types/errors"; import { TSurvey } from "@formbricks/types/surveys/types"; export const getSurveys = reactCache( - async (environmentIds: string[], limit?: number, offset?: number): Promise => - cache( - async () => { - validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]); + async (environmentIds: string[], limit?: number, offset?: number): Promise => { + validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]); - try { - const surveysPrisma = await prisma.survey.findMany({ - where: { - environmentId: { in: environmentIds }, - }, - select: selectSurvey, - orderBy: { - updatedAt: "desc", - }, - take: limit, - skip: offset, - }); - return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey(surveyPrisma)); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - logger.error(error, "Error getting surveys"); - throw new DatabaseError(error.message); - } - throw error; - } - }, - environmentIds.map((environmentId) => `getSurveys-management-api-${environmentId}-${limit}-${offset}`), - { - tags: environmentIds.map((environmentId) => surveyCache.tag.byEnvironmentId(environmentId)), + try { + const surveysPrisma = await prisma.survey.findMany({ + where: { + environmentId: { in: environmentIds }, + }, + select: selectSurvey, + orderBy: { + updatedAt: "desc", + }, + take: limit, + skip: offset, + }); + return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey(surveyPrisma)); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error getting surveys"); + throw new DatabaseError(error.message); } - )() + throw error; + } + } ); diff --git a/apps/web/app/api/v1/management/surveys/lib/utils.test.ts b/apps/web/app/api/v1/management/surveys/lib/utils.test.ts new file mode 100644 index 000000000000..75a0a77f57d8 --- /dev/null +++ b/apps/web/app/api/v1/management/surveys/lib/utils.test.ts @@ -0,0 +1,231 @@ +import { responses } from "@/app/lib/api/response"; +import { getIsSpamProtectionEnabled, getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; +import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; +import { describe, expect, test, vi } from "vitest"; +import { TOrganization } from "@formbricks/types/organizations"; +import { + TSurveyCreateInputWithEnvironmentId, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; +import { checkFeaturePermissions } from "./utils"; + +// Mock dependencies +vi.mock("@/app/lib/api/response", () => ({ + responses: { + forbiddenResponse: vi.fn((message) => new Response(message, { status: 403 })), + }, +})); + +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getIsSpamProtectionEnabled: vi.fn(), + getMultiLanguagePermission: vi.fn(), +})); + +vi.mock("@/modules/survey/follow-ups/lib/utils", () => ({ + getSurveyFollowUpsPermission: vi.fn(), +})); + +const mockOrganization: TOrganization = { + id: "test-org", + name: "Test Organization", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + plan: "free", + stripeCustomerId: null, + period: "monthly", + limits: { + projects: 3, + monthly: { + responses: 1500, + miu: 2000, + }, + }, + periodStart: new Date(), + }, + isAIEnabled: false, +}; + +const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = { + id: "followup1", + surveyId: "mockSurveyId", + name: "Test Follow-up", + trigger: { + type: "response", + properties: null, + }, + action: { + type: "send-email", + properties: { + to: "mockQuestion1Id", + from: "noreply@example.com", + replyTo: [], + subject: "Follow-up Subject", + body: "Follow-up Body", + attachResponseData: false, + }, + }, +}; + +const mockLanguage: TSurveyCreateInputWithEnvironmentId["languages"][number] = { + language: { + id: "lang1", + code: "en", + alias: "English", + createdAt: new Date(), + projectId: "mockProjectId", + updatedAt: new Date(), + }, + default: true, + enabled: true, +}; + +const baseSurveyData: TSurveyCreateInputWithEnvironmentId = { + name: "Test Survey", + environmentId: "test-env", + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Q1" }, + required: false, + charLimit: {}, + inputType: "text", + }, + ], + endings: [], + languages: [], + type: "link", + welcomeCard: { enabled: false, showResponseCount: false, timeToFinish: false }, + followUps: [], +}; + +describe("checkFeaturePermissions", () => { + test("should return null if no restricted features are used", async () => { + const surveyData = { ...baseSurveyData }; + const result = await checkFeaturePermissions(surveyData, mockOrganization); + expect(result).toBeNull(); + }); + + // Recaptcha tests + test("should return forbiddenResponse if recaptcha is enabled but permission denied", async () => { + vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false); + const surveyData = { ...baseSurveyData, recaptcha: { enabled: true, threshold: 0.5 } }; + const result = await checkFeaturePermissions(surveyData, mockOrganization); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(403); + expect(responses.forbiddenResponse).toHaveBeenCalledWith( + "Spam protection is not enabled for this organization" + ); + }); + + test("should return null if recaptcha is enabled and permission granted", async () => { + vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true); + const surveyData: TSurveyCreateInputWithEnvironmentId = { + ...baseSurveyData, + recaptcha: { enabled: true, threshold: 0.5 }, + }; + const result = await checkFeaturePermissions(surveyData, mockOrganization); + expect(result).toBeNull(); + }); + + // Follow-ups tests + test("should return forbiddenResponse if follow-ups are used but permission denied", async () => { + vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(false); + const surveyData = { + ...baseSurveyData, + followUps: [mockFollowUp], + }; // Add minimal follow-up data + const result = await checkFeaturePermissions(surveyData, mockOrganization); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(403); + expect(responses.forbiddenResponse).toHaveBeenCalledWith( + "Survey follow ups are not allowed for this organization" + ); + }); + + test("should return null if follow-ups are used and permission granted", async () => { + vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(true); + const surveyData = { ...baseSurveyData, followUps: [mockFollowUp] }; // Add minimal follow-up data + const result = await checkFeaturePermissions(surveyData, mockOrganization); + expect(result).toBeNull(); + }); + + // Multi-language tests + test("should return forbiddenResponse if multi-language is used but permission denied", async () => { + vi.mocked(getMultiLanguagePermission).mockResolvedValue(false); + const surveyData: TSurveyCreateInputWithEnvironmentId = { + ...baseSurveyData, + languages: [mockLanguage], + }; + const result = await checkFeaturePermissions(surveyData, mockOrganization); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(403); + expect(responses.forbiddenResponse).toHaveBeenCalledWith( + "Multi language is not enabled for this organization" + ); + }); + + test("should return null if multi-language is used and permission granted", async () => { + vi.mocked(getMultiLanguagePermission).mockResolvedValue(true); + const surveyData = { + ...baseSurveyData, + languages: [mockLanguage], + }; + const result = await checkFeaturePermissions(surveyData, mockOrganization); + expect(result).toBeNull(); + }); + + // Combined tests + test("should return null if multiple features are used and all permissions granted", async () => { + vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true); + vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(true); + vi.mocked(getMultiLanguagePermission).mockResolvedValue(true); + const surveyData = { + ...baseSurveyData, + recaptcha: { enabled: true, threshold: 0.5 }, + followUps: [mockFollowUp], + languages: [mockLanguage], + }; + const result = await checkFeaturePermissions(surveyData, mockOrganization); + expect(result).toBeNull(); + }); + + test("should return forbiddenResponse for the first denied feature (recaptcha)", async () => { + vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false); // Denied + vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(true); + vi.mocked(getMultiLanguagePermission).mockResolvedValue(true); + const surveyData = { + ...baseSurveyData, + recaptcha: { enabled: true, threshold: 0.5 }, + followUps: [mockFollowUp], + languages: [mockLanguage], + }; + const result = await checkFeaturePermissions(surveyData, mockOrganization); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(403); + expect(responses.forbiddenResponse).toHaveBeenCalledWith( + "Spam protection is not enabled for this organization" + ); + expect(responses.forbiddenResponse).toHaveBeenCalledTimes(1); // Ensure it stops at the first failure + }); + + test("should return forbiddenResponse for the first denied feature (follow-ups)", async () => { + vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true); + vi.mocked(getSurveyFollowUpsPermission).mockResolvedValue(false); // Denied + vi.mocked(getMultiLanguagePermission).mockResolvedValue(true); + const surveyData = { + ...baseSurveyData, + recaptcha: { enabled: true, threshold: 0.5 }, + followUps: [mockFollowUp], + languages: [mockLanguage], + }; + const result = await checkFeaturePermissions(surveyData, mockOrganization); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(403); + expect(responses.forbiddenResponse).toHaveBeenCalledWith( + "Survey follow ups are not allowed for this organization" + ); + expect(responses.forbiddenResponse).toHaveBeenCalledTimes(1); // Ensure it stops at the first failure + }); +}); diff --git a/apps/web/app/api/v1/management/surveys/lib/utils.ts b/apps/web/app/api/v1/management/surveys/lib/utils.ts new file mode 100644 index 000000000000..9aff1cc306dd --- /dev/null +++ b/apps/web/app/api/v1/management/surveys/lib/utils.ts @@ -0,0 +1,33 @@ +import { responses } from "@/app/lib/api/response"; +import { getIsSpamProtectionEnabled, getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; +import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types"; + +export const checkFeaturePermissions = async ( + surveyData: TSurveyCreateInputWithEnvironmentId, + organization: TOrganization +): Promise => { + if (surveyData.recaptcha?.enabled) { + const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organization.billing.plan); + if (!isSpamProtectionEnabled) { + return responses.forbiddenResponse("Spam protection is not enabled for this organization"); + } + } + + if (surveyData.followUps?.length) { + const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan); + if (!isSurveyFollowUpsEnabled) { + return responses.forbiddenResponse("Survey follow ups are not allowed for this organization"); + } + } + + if (surveyData.languages?.length) { + const isMultiLanguageEnabled = await getMultiLanguagePermission(organization.billing.plan); + if (!isMultiLanguageEnabled) { + return responses.forbiddenResponse("Multi language is not enabled for this organization"); + } + } + + return null; +}; diff --git a/apps/web/app/api/v1/management/surveys/route.ts b/apps/web/app/api/v1/management/surveys/route.ts index c9db2c4e38af..eb3f4d8a0e5f 100644 --- a/apps/web/app/api/v1/management/surveys/route.ts +++ b/apps/web/app/api/v1/management/surveys/route.ts @@ -1,94 +1,120 @@ -import { authenticateRequest } from "@/app/api/v1/auth"; +import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; +import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import { createSurvey } from "@/lib/survey/service"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; -import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; -import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { createSurvey } from "@formbricks/lib/survey/service"; +import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; import { DatabaseError } from "@formbricks/types/errors"; import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types"; import { getSurveys } from "./lib/surveys"; -export const GET = async (request: Request) => { - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); - - const searchParams = new URL(request.url).searchParams; - const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined; - const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined; +export const GET = withV1ApiWrapper({ + handler: async ({ + req, + authentication, + }: { + req: NextRequest; + authentication: NonNullable; + }) => { + try { + const searchParams = new URL(req.url).searchParams; + const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined; + const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined; - const environmentIds = authentication.environmentPermissions.map( - (permission) => permission.environmentId - ); - const surveys = await getSurveys(environmentIds, limit, offset); + const environmentIds = authentication.environmentPermissions.map( + (permission) => permission.environmentId + ); + const surveys = await getSurveys(environmentIds, limit, offset); - return responses.successResponse(surveys); - } catch (error) { - if (error instanceof DatabaseError) { - return responses.badRequestResponse(error.message); + return { + response: responses.successResponse(surveys), + }; + } catch (error) { + if (error instanceof DatabaseError) { + return { + response: responses.badRequestResponse(error.message), + }; + } + throw error; } - throw error; - } -}; - -export const POST = async (request: Request): Promise => { - try { - const authentication = await authenticateRequest(request); - if (!authentication) return responses.notAuthenticatedResponse(); + }, +}); - let surveyInput; +export const POST = withV1ApiWrapper({ + handler: async ({ + req, + auditLog, + authentication, + }: { + req: NextRequest; + auditLog: TApiAuditLog; + authentication: NonNullable; + }) => { try { - surveyInput = await request.json(); - } catch (error) { - logger.error({ error, url: request.url }, "Error parsing JSON"); - return responses.badRequestResponse("Malformed JSON input, please check your request body"); - } - const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput); + let surveyInput; + try { + surveyInput = await req.json(); + } catch (error) { + logger.error({ error, url: req.url }, "Error parsing JSON"); + return { + response: responses.badRequestResponse("Malformed JSON input, please check your request body"), + }; + } + const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput); - if (!inputValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(inputValidation.error), - true - ); - } + if (!inputValidation.success) { + return { + response: responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(inputValidation.error), + true + ), + }; + } - const environmentId = inputValidation.data.environmentId; + const { environmentId } = inputValidation.data; - if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { - return responses.unauthorizedResponse(); - } + if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { + return { + response: responses.unauthorizedResponse(), + }; + } - const organization = await getOrganizationByEnvironmentId(environmentId); - if (!organization) { - return responses.notFoundResponse("Organization", null); - } + const organization = await getOrganizationByEnvironmentId(environmentId); + if (!organization) { + return { + response: responses.notFoundResponse("Organization", null), + }; + } - const surveyData = { ...inputValidation.data, environmentId }; + const surveyData = { ...inputValidation.data, environmentId }; - if (surveyData.followUps?.length) { - const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan); - if (!isSurveyFollowUpsEnabled) { - return responses.forbiddenResponse("Survey follow ups are not enabled allowed for this organization"); + const featureCheckResult = await checkFeaturePermissions(surveyData, organization); + if (featureCheckResult) { + return { + response: featureCheckResult, + }; } - } - if (surveyData.languages && surveyData.languages.length) { - const isMultiLanguageEnabled = await getMultiLanguagePermission(organization.billing.plan); - if (!isMultiLanguageEnabled) { - return responses.forbiddenResponse("Multi language is not enabled for this organization"); - } - } + const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined }); + auditLog.targetId = survey.id; + auditLog.newObject = survey; - const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined }); - return responses.successResponse(survey); - } catch (error) { - if (error instanceof DatabaseError) { - return responses.badRequestResponse(error.message); + return { + response: responses.successResponse(survey), + }; + } catch (error) { + if (error instanceof DatabaseError) { + return { + response: responses.internalServerErrorResponse(error.message), + }; + } + throw error; } - throw error; - } -}; + }, + action: "created", + targetType: "survey", +}); diff --git a/apps/web/app/api/v1/og/route.tsx b/apps/web/app/api/v1/og/route.tsx deleted file mode 100644 index bc8b17d5b744..000000000000 --- a/apps/web/app/api/v1/og/route.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { ImageResponse } from "@vercel/og"; -import { NextRequest } from "next/server"; - -export const GET = async (req: NextRequest) => { - let name = req.nextUrl.searchParams.get("name"); - let brandColor = req.nextUrl.searchParams.get("brandColor"); - - return new ImageResponse( - ( -
-
-
-
-
-
-
-

- {name} -

-
-
-
-
- -
- -
-
-
-
- ), - { - width: 800, - height: 400, - headers: { - "Cache-Control": "public, s-maxage=600, max-age=1800, stale-while-revalidate=600, stale-if-error=600", - }, - } - ); -}; diff --git a/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.test.ts b/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.test.ts new file mode 100644 index 000000000000..b70cfc9aad84 --- /dev/null +++ b/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.test.ts @@ -0,0 +1,219 @@ +import { Prisma, Webhook } from "@prisma/client"; +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors"; +import { deleteWebhook, getWebhook } from "./webhook"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + webhook: { + delete: vi.fn(), + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), + ValidationError: class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } + }, +})); + +describe("deleteWebhook", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("should delete the webhook and return the deleted webhook object when provided with a valid webhook ID", async () => { + const mockedWebhook: Webhook = { + id: "test-webhook-id", + url: "https://example.com", + name: "Test Webhook", + createdAt: new Date(), + updatedAt: new Date(), + source: "user", + environmentId: "test-environment-id", + triggers: [], + surveyIds: [], + }; + + vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedWebhook); + + const deletedWebhook = await deleteWebhook("test-webhook-id"); + + expect(deletedWebhook).toEqual(mockedWebhook); + expect(prisma.webhook.delete).toHaveBeenCalledWith({ + where: { + id: "test-webhook-id", + }, + }); + }); + + test("should delete the webhook", async () => { + const mockedWebhook: Webhook = { + id: "test-webhook-id", + url: "https://example.com", + name: "Test Webhook", + createdAt: new Date(), + updatedAt: new Date(), + source: "user", + environmentId: "test-environment-id", + triggers: [], + surveyIds: [], + }; + + vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedWebhook); + + const deletedWebhook = await deleteWebhook("test-webhook-id"); + + expect(deletedWebhook).toEqual(mockedWebhook); + expect(prisma.webhook.delete).toHaveBeenCalledWith({ + where: { + id: "test-webhook-id", + }, + }); + }); + + test("should throw an error when called with an invalid webhook ID format", async () => { + const { validateInputs } = await import("@/lib/utils/validate"); + (validateInputs as any).mockImplementation(() => { + throw new ValidationError("Validation failed"); + }); + + await expect(deleteWebhook("invalid-id")).rejects.toThrow(ValidationError); + + expect(prisma.webhook.delete).not.toHaveBeenCalled(); + }); + + test("should throw ResourceNotFoundError when webhook does not exist", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Record does not exist", { + code: "P2025", + clientVersion: "1.0.0", + }); + + vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaError); + + await expect(deleteWebhook("non-existent-id")).rejects.toThrow(ResourceNotFoundError); + }); + + test("should throw DatabaseError when database operation fails", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "1.0.0", + }); + + vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaError); + + await expect(deleteWebhook("test-webhook-id")).rejects.toThrow(DatabaseError); + }); + + test("should throw DatabaseError when an unknown error occurs", async () => { + vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(new Error("Unknown error")); + + await expect(deleteWebhook("test-webhook-id")).rejects.toThrow(DatabaseError); + }); +}); + +describe("getWebhook", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("should return webhook when it exists", async () => { + const mockedWebhook: Webhook = { + id: "test-webhook-id", + url: "https://example.com", + name: "Test Webhook", + createdAt: new Date(), + updatedAt: new Date(), + source: "user", + environmentId: "test-environment-id", + triggers: [], + surveyIds: [], + }; + + vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(mockedWebhook); + + const webhook = await getWebhook("test-webhook-id"); + + expect(webhook).toEqual(mockedWebhook); + expect(prisma.webhook.findUnique).toHaveBeenCalledWith({ + where: { + id: "test-webhook-id", + }, + }); + }); + + test("should return null when webhook does not exist", async () => { + vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(null); + + const webhook = await getWebhook("non-existent-id"); + + expect(webhook).toBeNull(); + expect(prisma.webhook.findUnique).toHaveBeenCalledWith({ + where: { + id: "non-existent-id", + }, + }); + }); + + test("should throw ValidationError when called with invalid webhook ID", async () => { + const { validateInputs } = await import("@/lib/utils/validate"); + (validateInputs as any).mockImplementation(() => { + throw new ValidationError("Validation failed"); + }); + + await expect(getWebhook("invalid-id")).rejects.toThrow(ValidationError); + expect(prisma.webhook.findUnique).not.toHaveBeenCalled(); + }); + + test("should throw DatabaseError when database operation fails", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "1.0.0", + }); + + vi.mocked(prisma.webhook.findUnique).mockRejectedValueOnce(prismaError); + + await expect(getWebhook("test-webhook-id")).rejects.toThrow(DatabaseError); + }); + + test("should throw original error when an unknown error occurs", async () => { + const unknownError = new Error("Unknown error"); + vi.mocked(prisma.webhook.findUnique).mockRejectedValueOnce(unknownError); + + await expect(getWebhook("test-webhook-id")).rejects.toThrow(unknownError); + }); + + test("should use cache when getting webhook", async () => { + const mockedWebhook: Webhook = { + id: "test-webhook-id", + url: "https://example.com", + name: "Test Webhook", + createdAt: new Date(), + updatedAt: new Date(), + source: "user", + environmentId: "test-environment-id", + triggers: [], + surveyIds: [], + }; + + vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(mockedWebhook); + + const webhook = await getWebhook("test-webhook-id"); + + expect(webhook).toEqual(mockedWebhook); + expect(prisma.webhook.findUnique).toHaveBeenCalledWith({ + where: { + id: "test-webhook-id", + }, + }); + }); +}); diff --git a/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts b/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts index 4e7ffb9a4749..aab8feb09548 100644 --- a/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts +++ b/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts @@ -1,12 +1,9 @@ -import { webhookCache } from "@/lib/cache/webhook"; +import { validateInputs } from "@/lib/utils/validate"; import { Prisma, Webhook } from "@prisma/client"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; -import { cache } from "@formbricks/lib/cache"; -import { validateInputs } from "@formbricks/lib/utils/validate"; import { ZId } from "@formbricks/types/common"; -import { DatabaseError } from "@formbricks/types/errors"; -import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; export const deleteWebhook = async (id: string): Promise => { validateInputs([id, ZId]); @@ -18,12 +15,6 @@ export const deleteWebhook = async (id: string): Promise => { }, }); - webhookCache.revalidate({ - id: deletedWebhook.id, - environmentId: deletedWebhook.environmentId, - source: deletedWebhook.source, - }); - return deletedWebhook; } catch (error) { if ( @@ -36,28 +27,21 @@ export const deleteWebhook = async (id: string): Promise => { } }; -export const getWebhook = async (id: string): Promise => - cache( - async () => { - validateInputs([id, ZId]); - - try { - const webhook = await prisma.webhook.findUnique({ - where: { - id, - }, - }); - return webhook; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } +export const getWebhook = async (id: string): Promise => { + validateInputs([id, ZId]); - throw error; - } - }, - [`getWebhook-${id}`], - { - tags: [webhookCache.tag.byId(id)], + try { + const webhook = await prisma.webhook.findUnique({ + where: { + id, + }, + }); + return webhook; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )(); + + throw error; + } +}; diff --git a/apps/web/app/api/v1/webhooks/[webhookId]/route.ts b/apps/web/app/api/v1/webhooks/[webhookId]/route.ts index de6ac15b0b16..7653b7eaa4a8 100644 --- a/apps/web/app/api/v1/webhooks/[webhookId]/route.ts +++ b/apps/web/app/api/v1/webhooks/[webhookId]/route.ts @@ -1,60 +1,81 @@ -import { authenticateRequest } from "@/app/api/v1/auth"; import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook"; import { responses } from "@/app/lib/api/response"; +import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; -import { headers } from "next/headers"; +import { NextRequest } from "next/server"; import { logger } from "@formbricks/logger"; -export const GET = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => { - const params = await props.params; - const headersList = await headers(); - const apiKey = headersList.get("x-api-key"); - if (!apiKey) { - return responses.notAuthenticatedResponse(); - } - const authentication = await authenticateRequest(request); - if (!authentication) { - return responses.notAuthenticatedResponse(); - } +export const GET = withV1ApiWrapper({ + handler: async ({ + props, + authentication, + }: { + props: { params: Promise<{ webhookId: string }> }; + authentication: NonNullable; + }) => { + const params = await props.params; - // add webhook to database - const webhook = await getWebhook(params.webhookId); - if (!webhook) { - return responses.notFoundResponse("Webhook", params.webhookId); - } - if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) { - return responses.unauthorizedResponse(); - } - return responses.successResponse(webhook); -}; + const webhook = await getWebhook(params.webhookId); + if (!webhook) { + return { + response: responses.notFoundResponse("Webhook", params.webhookId), + }; + } + if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) { + return { + response: responses.unauthorizedResponse(), + }; + } + return { + response: responses.successResponse(webhook), + }; + }, +}); -export const DELETE = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => { - const params = await props.params; - const headersList = await headers(); - const apiKey = headersList.get("x-api-key"); - if (!apiKey) { - return responses.notAuthenticatedResponse(); - } - const authentication = await authenticateRequest(request); - if (!authentication) { - return responses.notAuthenticatedResponse(); - } +export const DELETE = withV1ApiWrapper({ + handler: async ({ + req, + props, + auditLog, + authentication, + }: { + req: NextRequest; + props: { params: Promise<{ webhookId: string }> }; + auditLog: TApiAuditLog; + authentication: NonNullable; + }) => { + const params = await props.params; + auditLog.targetId = params.webhookId; - // check if webhook exists - const webhook = await getWebhook(params.webhookId); - if (!webhook) { - return responses.notFoundResponse("Webhook", params.webhookId); - } - if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "DELETE")) { - return responses.unauthorizedResponse(); - } + // check if webhook exists + const webhook = await getWebhook(params.webhookId); + if (!webhook) { + return { + response: responses.notFoundResponse("Webhook", params.webhookId), + }; + } + if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "DELETE")) { + return { + response: responses.unauthorizedResponse(), + }; + } - // delete webhook from database - try { - const webhook = await deleteWebhook(params.webhookId); - return responses.successResponse(webhook); - } catch (e) { - logger.error({ error: e, url: request.url }, "Error deleting webhook"); - return responses.notFoundResponse("Webhook", params.webhookId); - } -}; + auditLog.oldObject = webhook; + + // delete webhook from database + try { + const deletedWebhook = await deleteWebhook(params.webhookId); + return { + response: responses.successResponse(deletedWebhook), + }; + } catch (e) { + auditLog.status = "failure"; + logger.error({ error: e, url: req.url }, "Error deleting webhook"); + return { + response: responses.notFoundResponse("Webhook", params.webhookId), + }; + } + }, + action: "deleted", + targetType: "webhook", +}); diff --git a/apps/web/app/api/v1/webhooks/lib/webhook.test.ts b/apps/web/app/api/v1/webhooks/lib/webhook.test.ts new file mode 100644 index 000000000000..5919fd2bb7be --- /dev/null +++ b/apps/web/app/api/v1/webhooks/lib/webhook.test.ts @@ -0,0 +1,155 @@ +import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook"; +import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks"; +import { validateInputs } from "@/lib/utils/validate"; +import { Prisma, WebhookSource } from "@prisma/client"; +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, ValidationError } from "@formbricks/types/errors"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + webhook: { + create: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), +})); + +describe("createWebhook", () => { + afterEach(() => { + cleanup(); + }); + + test("should create a webhook", async () => { + const webhookInput: TWebhookInput = { + environmentId: "test-env-id", + name: "Test Webhook", + url: "https://example.com", + source: "user", + triggers: ["responseCreated"], + surveyIds: ["survey1", "survey2"], + }; + + const createdWebhook = { + id: "webhook-id", + environmentId: "test-env-id", + name: "Test Webhook", + url: "https://example.com", + source: "user" as WebhookSource, + triggers: ["responseCreated"], + surveyIds: ["survey1", "survey2"], + createdAt: new Date(), + updatedAt: new Date(), + } as any; + + vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook); + + const result = await createWebhook(webhookInput); + + expect(validateInputs).toHaveBeenCalled(); + + expect(prisma.webhook.create).toHaveBeenCalledWith({ + data: { + url: webhookInput.url, + name: webhookInput.name, + source: webhookInput.source, + surveyIds: webhookInput.surveyIds, + triggers: webhookInput.triggers, + environment: { + connect: { + id: webhookInput.environmentId, + }, + }, + }, + }); + + expect(result).toEqual(createdWebhook); + }); + + test("should throw a ValidationError if the input data does not match the ZWebhookInput schema", async () => { + const invalidWebhookInput = { + environmentId: "test-env-id", + name: "Test Webhook", + url: 123, // Invalid URL + source: "user" as WebhookSource, + triggers: ["responseCreated"], + surveyIds: ["survey1", "survey2"], + }; + + vi.mocked(validateInputs).mockImplementation(() => { + throw new ValidationError("Validation failed"); + }); + + await expect(createWebhook(invalidWebhookInput as any)).rejects.toThrowError(ValidationError); + }); + + test("should throw a DatabaseError if a PrismaClientKnownRequestError occurs", async () => { + const webhookInput: TWebhookInput = { + environmentId: "test-env-id", + name: "Test Webhook", + url: "https://example.com", + source: "user", + triggers: ["responseCreated"], + surveyIds: ["survey1", "survey2"], + }; + + vi.mocked(prisma.webhook.create).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError("Test error", { + code: "P2002", + clientVersion: "5.0.0", + }) + ); + + await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError); + }); + + test("should throw a DatabaseError when provided with invalid surveyIds", async () => { + const webhookInput: TWebhookInput = { + environmentId: "test-env-id", + name: "Test Webhook", + url: "https://example.com", + source: "user", + triggers: ["responseCreated"], + surveyIds: ["invalid-survey-id"], + }; + + vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Foreign key constraint violation")); + + await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError); + }); + + test("should handle edge case URLs that are technically valid but problematic", async () => { + const webhookInput: TWebhookInput = { + environmentId: "test-env-id", + name: "Test Webhook", + url: "http://localhost:3000", // Example of a potentially problematic URL + source: "user", + triggers: ["responseCreated"], + surveyIds: ["survey1", "survey2"], + }; + + vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new DatabaseError("Invalid URL")); + + await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError); + + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.webhook.create).toHaveBeenCalledWith({ + data: { + url: webhookInput.url, + name: webhookInput.name, + source: webhookInput.source, + surveyIds: webhookInput.surveyIds, + triggers: webhookInput.triggers, + environment: { + connect: { + id: webhookInput.environmentId, + }, + }, + }, + }); + }); +}); diff --git a/apps/web/app/api/v1/webhooks/lib/webhook.ts b/apps/web/app/api/v1/webhooks/lib/webhook.ts index a1dedd70faa0..456bfe031bec 100644 --- a/apps/web/app/api/v1/webhooks/lib/webhook.ts +++ b/apps/web/app/api/v1/webhooks/lib/webhook.ts @@ -1,10 +1,8 @@ import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks"; -import { webhookCache } from "@/lib/cache/webhook"; +import { ITEMS_PER_PAGE } from "@/lib/constants"; +import { validateInputs } from "@/lib/utils/validate"; import { Prisma, Webhook } from "@prisma/client"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { ITEMS_PER_PAGE } from "@formbricks/lib/constants"; -import { validateInputs } from "@formbricks/lib/utils/validate"; import { ZId, ZOptionalNumber } from "@formbricks/types/common"; import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; @@ -27,12 +25,6 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise => - cache( - async () => { - validateInputs([environmentIds, ZId.array()], [page, ZOptionalNumber]); - - try { - const webhooks = await prisma.webhook.findMany({ - where: { - environmentId: { in: environmentIds }, - }, - take: page ? ITEMS_PER_PAGE : undefined, - skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, - }); - return webhooks; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } +export const getWebhooks = async (environmentIds: string[], page?: number): Promise => { + validateInputs([environmentIds, ZId.array()], [page, ZOptionalNumber]); - throw error; - } - }, - environmentIds.map((environmentId) => `getWebhooks-${environmentId}-${page}`), - { - tags: environmentIds.map((environmentId) => webhookCache.tag.byEnvironmentId(environmentId)), + try { + const webhooks = await prisma.webhook.findMany({ + where: { + environmentId: { in: environmentIds }, + }, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + }); + return webhooks; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )(); + + throw error; + } +}; diff --git a/apps/web/app/api/v1/webhooks/route.ts b/apps/web/app/api/v1/webhooks/route.ts index 415fb8501e7a..64d45f361006 100644 --- a/apps/web/app/api/v1/webhooks/route.ts +++ b/apps/web/app/api/v1/webhooks/route.ts @@ -1,67 +1,91 @@ -import { authenticateRequest } from "@/app/api/v1/auth"; import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook"; import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { NextRequest } from "next/server"; import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; -export const GET = async (request: Request) => { - const authentication = await authenticateRequest(request); - if (!authentication) { - return responses.notAuthenticatedResponse(); - } - try { - const environmentIds = authentication.environmentPermissions.map( - (permission) => permission.environmentId - ); - const webhooks = await getWebhooks(environmentIds); - return responses.successResponse(webhooks); - } catch (error) { - if (error instanceof DatabaseError) { - return responses.internalServerErrorResponse(error.message); +export const GET = withV1ApiWrapper({ + handler: async ({ authentication }: { authentication: NonNullable }) => { + try { + const environmentIds = authentication.environmentPermissions.map( + (permission) => permission.environmentId + ); + const webhooks = await getWebhooks(environmentIds); + return { + response: responses.successResponse(webhooks), + }; + } catch (error) { + if (error instanceof DatabaseError) { + return { + response: responses.internalServerErrorResponse(error.message), + }; + } + throw error; } - throw error; - } -}; + }, +}); -export const POST = async (request: Request) => { - const authentication = await authenticateRequest(request); - if (!authentication) { - return responses.notAuthenticatedResponse(); - } - const webhookInput = await request.json(); - const inputValidation = ZWebhookInput.safeParse(webhookInput); +export const POST = withV1ApiWrapper({ + handler: async ({ + req, + auditLog, + authentication, + }: { + req: NextRequest; + auditLog: TApiAuditLog; + authentication: NonNullable; + }) => { + const webhookInput = await req.json(); + const inputValidation = ZWebhookInput.safeParse(webhookInput); - if (!inputValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(inputValidation.error), - true - ); - } + if (!inputValidation.success) { + return { + response: responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(inputValidation.error), + true + ), + }; + } - const environmentId = inputValidation.data.environmentId; + const environmentId = inputValidation.data.environmentId; + if (!environmentId) { + return { + response: responses.badRequestResponse("Environment ID is required"), + }; + } - if (!environmentId) { - return responses.badRequestResponse("Environment ID is required"); - } + if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { + return { + response: responses.unauthorizedResponse(), + }; + } - if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { - return responses.unauthorizedResponse(); - } + try { + const webhook = await createWebhook(inputValidation.data); + auditLog.targetId = webhook.id; + auditLog.newObject = webhook; - // add webhook to database - try { - const webhook = await createWebhook(inputValidation.data); - return responses.successResponse(webhook); - } catch (error) { - if (error instanceof InvalidInputError) { - return responses.badRequestResponse(error.message); - } - if (error instanceof DatabaseError) { - return responses.internalServerErrorResponse(error.message); + return { + response: responses.successResponse(webhook), + }; + } catch (error) { + if (error instanceof InvalidInputError) { + return { + response: responses.badRequestResponse(error.message), + }; + } + if (error instanceof DatabaseError) { + return { + response: responses.internalServerErrorResponse(error.message), + }; + } + throw error; } - throw error; - } -}; + }, + action: "created", + targetType: "webhook", +}); diff --git a/apps/web/app/api/v2/client/[environmentId]/displays/lib/contact.test.ts b/apps/web/app/api/v2/client/[environmentId]/displays/lib/contact.test.ts new file mode 100644 index 000000000000..92376e1ab1c8 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/displays/lib/contact.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { doesContactExist } from "./contact"; + +// Mock prisma +vi.mock("@formbricks/database", () => ({ + prisma: { + contact: { + findFirst: vi.fn(), + }, + }, +})); + +// Mock react cache +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { + ...actual, + cache: vi.fn((fn) => fn), // Mock react's cache to just return the function + }; +}); + +const contactId = "test-contact-id"; + +describe("doesContactExist", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + test("should return true if contact exists", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: contactId }); + + const result = await doesContactExist(contactId); + + expect(result).toBe(true); + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { id: contactId }, + select: { id: true }, + }); + }); + + test("should return false if contact does not exist", async () => { + vi.mocked(prisma.contact.findFirst).mockResolvedValue(null); + + const result = await doesContactExist(contactId); + + expect(result).toBe(false); + expect(prisma.contact.findFirst).toHaveBeenCalledWith({ + where: { id: contactId }, + select: { id: true }, + }); + }); +}); diff --git a/apps/web/app/api/v2/client/[environmentId]/displays/lib/contact.ts b/apps/web/app/api/v2/client/[environmentId]/displays/lib/contact.ts index a7c02dad9438..8c3f461e33ed 100644 --- a/apps/web/app/api/v2/client/[environmentId]/displays/lib/contact.ts +++ b/apps/web/app/api/v2/client/[environmentId]/displays/lib/contact.ts @@ -1,26 +1,15 @@ -import { contactCache } from "@/lib/cache/contact"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -export const doesContactExist = reactCache( - (id: string): Promise => - cache( - async () => { - const contact = await prisma.contact.findFirst({ - where: { - id, - }, - select: { - id: true, - }, - }); +export const doesContactExist = reactCache(async (id: string): Promise => { + const contact = await prisma.contact.findFirst({ + where: { + id, + }, + select: { + id: true, + }, + }); - return !!contact; - }, - [`doesContactExistDisplaysApiV2-${id}`], - { - tags: [contactCache.tag.byId(id)], - } - )() -); + return !!contact; +}); diff --git a/apps/web/app/api/v2/client/[environmentId]/displays/lib/display.test.ts b/apps/web/app/api/v2/client/[environmentId]/displays/lib/display.test.ts new file mode 100644 index 000000000000..ce6a67345cf5 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/displays/lib/display.test.ts @@ -0,0 +1,178 @@ +import { validateInputs } from "@/lib/utils/validate"; +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors"; +import { TDisplayCreateInputV2 } from "../types/display"; +import { doesContactExist } from "./contact"; +import { createDisplay } from "./display"; + +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn((inputs) => inputs.map((input) => input[0])), // Pass through validation for testing +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + display: { + create: vi.fn(), + }, + survey: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("./contact", () => ({ + doesContactExist: vi.fn(), +})); + +const environmentId = "test-env-id"; +const surveyId = "test-survey-id"; +const contactId = "test-contact-id"; +const displayId = "test-display-id"; + +const displayInput: TDisplayCreateInputV2 = { + environmentId, + surveyId, + contactId, +}; + +const displayInputWithoutContact: TDisplayCreateInputV2 = { + environmentId, + surveyId, +}; + +const mockDisplay = { + id: displayId, + contactId, + surveyId, + responseId: null, + status: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockDisplayWithoutContact = { + id: displayId, + contactId: null, + surveyId, + responseId: null, + status: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockSurvey = { + id: surveyId, + name: "Test Survey", + environmentId, +} as any; + +describe("createDisplay", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.survey.findUnique).mockResolvedValue(mockSurvey); + }); + + test("should create a display with contactId successfully", async () => { + vi.mocked(doesContactExist).mockResolvedValue(true); + vi.mocked(prisma.display.create).mockResolvedValue(mockDisplay); + + const result = await createDisplay(displayInput); + + expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]); + expect(doesContactExist).toHaveBeenCalledWith(contactId); + expect(prisma.display.create).toHaveBeenCalledWith({ + data: { + survey: { connect: { id: surveyId } }, + contact: { connect: { id: contactId } }, + }, + select: { id: true, contactId: true, surveyId: true }, + }); + expect(result).toEqual(mockDisplay); // Changed this line + }); + + test("should create a display without contactId successfully", async () => { + vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); + + const result = await createDisplay(displayInputWithoutContact); + + expect(validateInputs).toHaveBeenCalledWith([displayInputWithoutContact, expect.any(Object)]); + expect(doesContactExist).not.toHaveBeenCalled(); + expect(prisma.display.create).toHaveBeenCalledWith({ + data: { + survey: { connect: { id: surveyId } }, + }, + select: { id: true, contactId: true, surveyId: true }, + }); + expect(result).toEqual(mockDisplayWithoutContact); // Changed this line + }); + + test("should create a display even if contact does not exist", async () => { + vi.mocked(doesContactExist).mockResolvedValue(false); + vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); // Expect no contact connection + + const result = await createDisplay(displayInput); + + expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]); + expect(doesContactExist).toHaveBeenCalledWith(contactId); + expect(prisma.display.create).toHaveBeenCalledWith({ + data: { + survey: { connect: { id: surveyId } }, + // No contact connection expected here + }, + select: { id: true, contactId: true, surveyId: true }, + }); + expect(result).toEqual(mockDisplayWithoutContact); // Changed this line + }); + + test("should throw ValidationError if validation fails", async () => { + const validationError = new ValidationError("Validation failed"); + vi.mocked(validateInputs).mockImplementation(() => { + throw validationError; + }); + + await expect(createDisplay(displayInput)).rejects.toThrow(ValidationError); + expect(doesContactExist).not.toHaveBeenCalled(); + expect(prisma.display.create).not.toHaveBeenCalled(); + }); + + test("should throw InvalidInputError when survey does not exist (P2025)", async () => { + vi.mocked(doesContactExist).mockResolvedValue(true); + vi.mocked(prisma.survey.findUnique).mockResolvedValue(null); + + await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId)); + expect(doesContactExist).toHaveBeenCalledWith(contactId); + expect(prisma.survey.findUnique).toHaveBeenCalledWith({ + where: { id: surveyId, environmentId }, + }); + expect(prisma.display.create).not.toHaveBeenCalled(); + }); + + test("should throw DatabaseError on other Prisma known request errors", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", { + code: "P2002", + clientVersion: "2.0.0", + }); + vi.mocked(doesContactExist).mockResolvedValue(true); + vi.mocked(prisma.display.create).mockRejectedValue(prismaError); + + await expect(createDisplay(displayInput)).rejects.toThrow(DatabaseError); + }); + + test("should throw original error on other errors during creation", async () => { + const genericError = new Error("Something went wrong"); + vi.mocked(doesContactExist).mockResolvedValue(true); + vi.mocked(prisma.display.create).mockRejectedValue(genericError); + + await expect(createDisplay(displayInput)).rejects.toThrow(genericError); + }); + + test("should throw original error if doesContactExist fails", async () => { + const contactCheckError = new Error("Failed to check contact"); + vi.mocked(doesContactExist).mockRejectedValue(contactCheckError); + + await expect(createDisplay(displayInput)).rejects.toThrow(contactCheckError); + expect(prisma.display.create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/v2/client/[environmentId]/displays/lib/display.ts b/apps/web/app/api/v2/client/[environmentId]/displays/lib/display.ts index c6ddd6479f3a..ebe030bfa2ad 100644 --- a/apps/web/app/api/v2/client/[environmentId]/displays/lib/display.ts +++ b/apps/web/app/api/v2/client/[environmentId]/displays/lib/display.ts @@ -2,21 +2,30 @@ import { TDisplayCreateInputV2, ZDisplayCreateInputV2, } from "@/app/api/v2/client/[environmentId]/displays/types/display"; +import { validateInputs } from "@/lib/utils/validate"; import { Prisma } from "@prisma/client"; import { prisma } from "@formbricks/database"; -import { displayCache } from "@formbricks/lib/display/cache"; -import { validateInputs } from "@formbricks/lib/utils/validate"; -import { DatabaseError } from "@formbricks/types/errors"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { doesContactExist } from "./contact"; export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => { validateInputs([displayInput, ZDisplayCreateInputV2]); - const { environmentId, contactId, surveyId } = displayInput; + const { contactId, surveyId, environmentId } = displayInput; try { const contactExists = contactId ? await doesContactExist(contactId) : false; + const survey = await prisma.survey.findUnique({ + where: { + id: surveyId, + environmentId, + }, + }); + if (!survey) { + throw new ResourceNotFoundError("Survey", surveyId); + } + const display = await prisma.display.create({ data: { survey: { @@ -36,13 +45,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis select: { id: true, contactId: true, surveyId: true }, }); - displayCache.revalidate({ - id: display.id, - contactId: display.contactId, - surveyId: display.surveyId, - environmentId, - }); - return display; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/apps/web/app/api/v2/client/[environmentId]/displays/route.ts b/apps/web/app/api/v2/client/[environmentId]/displays/route.ts index f91d3f1347f6..e60805dbc0ad 100644 --- a/apps/web/app/api/v2/client/[environmentId]/displays/route.ts +++ b/apps/web/app/api/v2/client/[environmentId]/displays/route.ts @@ -1,10 +1,10 @@ import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; -import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer"; import { logger } from "@formbricks/logger"; -import { InvalidInputError } from "@formbricks/types/errors"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; import { createDisplay } from "./lib/display"; interface Context { @@ -14,7 +14,13 @@ interface Context { } export const OPTIONS = async (): Promise => { - return responses.successResponse({}, true); + return responses.successResponse( + {}, + true, + // Cache CORS preflight responses for 1 hour (conservative approach) + // Balances performance gains with flexibility for CORS policy changes + "public, s-maxage=3600, max-age=3600" + ); }; export const POST = async (request: Request, context: Context): Promise => { @@ -46,11 +52,11 @@ export const POST = async (request: Request, context: Context): Promise ({ + prisma: { + contact: { + findUnique: vi.fn(), + }, + }, +})); + +const contactId = "test-contact-id"; +const mockContact = { + id: contactId, + attributes: [ + { attributeKey: { key: "email" }, value: "test@example.com" }, + { attributeKey: { key: "name" }, value: "Test User" }, + ], +}; + +const expectedContactAttributes: TContactAttributes = { + email: "test@example.com", + name: "Test User", +}; + +describe("getContact", () => { + test("should return contact with formatted attributes when found", async () => { + vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContact); + + const result = await getContact(contactId); + + expect(prisma.contact.findUnique).toHaveBeenCalledWith({ + where: { id: contactId }, + select: { + id: true, + attributes: { + select: { + attributeKey: { select: { key: true } }, + value: true, + }, + }, + }, + }); + expect(result).toEqual({ + id: contactId, + attributes: expectedContactAttributes, + }); + }); + + test("should return null when contact is not found", async () => { + vi.mocked(prisma.contact.findUnique).mockResolvedValue(null); + + const result = await getContact(contactId); + + expect(prisma.contact.findUnique).toHaveBeenCalledWith({ + where: { id: contactId }, + select: { + id: true, + attributes: { + select: { + attributeKey: { select: { key: true } }, + value: true, + }, + }, + }, + }); + expect(result).toBeNull(); + }); +}); diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/contact.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/contact.ts index 2fb4ec337cc3..c124eeff087c 100644 --- a/apps/web/app/api/v2/client/[environmentId]/responses/lib/contact.ts +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/contact.ts @@ -1,42 +1,32 @@ -import { contactCache } from "@/lib/cache/contact"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; import { TContactAttributes } from "@formbricks/types/contact-attribute"; -export const getContact = reactCache((contactId: string) => - cache( - async () => { - const contact = await prisma.contact.findUnique({ - where: { id: contactId }, +export const getContact = reactCache(async (contactId: string) => { + const contact = await prisma.contact.findUnique({ + where: { id: contactId }, + select: { + id: true, + attributes: { select: { - id: true, - attributes: { - select: { - attributeKey: { select: { key: true } }, - value: true, - }, - }, + attributeKey: { select: { key: true } }, + value: true, }, - }); + }, + }, + }); - if (!contact) { - return null; - } + if (!contact) { + return null; + } - const contactAttributes = contact.attributes.reduce((acc, attr) => { - acc[attr.attributeKey.key] = attr.value; - return acc; - }, {}) as TContactAttributes; + const contactAttributes = contact.attributes.reduce((acc, attr) => { + acc[attr.attributeKey.key] = attr.value; + return acc; + }, {}) as TContactAttributes; - return { - id: contact.id, - attributes: contactAttributes, - }; - }, - [`getContact-responses-api-${contactId}`], - { - tags: [contactCache.tag.byId(contactId)], - } - )() -); + return { + id: contact.id, + attributes: contactAttributes, + }; +}); diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/organization.test.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/organization.test.ts new file mode 100644 index 000000000000..c8ab3ee238e7 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/organization.test.ts @@ -0,0 +1,68 @@ +import { Organization } from "@prisma/client"; +import { describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { getOrganizationBillingByEnvironmentId } from "./organization"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + organization: { + findFirst: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +describe("getOrganizationBillingByEnvironmentId", () => { + const environmentId = "env-123"; + const mockBillingData: Organization["billing"] = { + limits: { + monthly: { miu: 0, responses: 0 }, + projects: 3, + }, + period: "monthly", + periodStart: new Date(), + plan: "scale", + stripeCustomerId: "mock-stripe-customer-id", + }; + + test("returns billing when organization is found", async () => { + vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: mockBillingData }); + const result = await getOrganizationBillingByEnvironmentId(environmentId); + expect(result).toEqual(mockBillingData); + expect(prisma.organization.findFirst).toHaveBeenCalledWith({ + where: { + projects: { + some: { + environments: { + some: { + id: environmentId, + }, + }, + }, + }, + }, + select: { + billing: true, + }, + }); + }); + + test("returns null when organization is not found", async () => { + vi.mocked(prisma.organization.findFirst).mockResolvedValueOnce(null); + const result = await getOrganizationBillingByEnvironmentId(environmentId); + expect(result).toBeNull(); + }); + + test("logs error and returns null on exception", async () => { + const error = new Error("db error"); + vi.mocked(prisma.organization.findFirst).mockRejectedValueOnce(error); + const result = await getOrganizationBillingByEnvironmentId(environmentId); + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith(error, "Failed to get organization billing by environment ID"); + }); +}); diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/organization.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/organization.ts new file mode 100644 index 000000000000..509a262b7f36 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/organization.ts @@ -0,0 +1,36 @@ +import { Organization } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; + +export const getOrganizationBillingByEnvironmentId = reactCache( + async (environmentId: string): Promise => { + try { + const organization = await prisma.organization.findFirst({ + where: { + projects: { + some: { + environments: { + some: { + id: environmentId, + }, + }, + }, + }, + }, + select: { + billing: true, + }, + }); + + if (!organization) { + return null; + } + + return organization.billing; + } catch (error) { + logger.error(error, "Failed to get organization billing by environment ID"); + return null; + } + } +); diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/recaptcha.test.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/recaptcha.test.ts new file mode 100644 index 000000000000..b16d1377574e --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/recaptcha.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; +import { verifyRecaptchaToken } from "./recaptcha"; + +// Mock constants +vi.mock("@/lib/constants", () => ({ + RECAPTCHA_SITE_KEY: "test-site-key", + RECAPTCHA_SECRET_KEY: "test-secret-key", +})); + +// Mock logger +vi.mock("@formbricks/logger", () => ({ + logger: { + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe("verifyRecaptchaToken", () => { + beforeEach(() => { + vi.resetAllMocks(); + global.fetch = vi.fn(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("returns true if site key or secret key is missing", async () => { + vi.doMock("@/lib/constants", () => ({ + RECAPTCHA_SITE_KEY: undefined, + RECAPTCHA_SECRET_KEY: undefined, + })); + // Re-import to get new mocked values + const { verifyRecaptchaToken: verifyWithNoKeys } = await import("./recaptcha"); + const result = await verifyWithNoKeys("token", 0.5); + expect(result).toBe(true); + expect(logger.warn).toHaveBeenCalledWith("reCAPTCHA verification skipped: keys not configured"); + }); + + test("returns false if fetch response is not ok", async () => { + (global.fetch as any).mockResolvedValue({ ok: false }); + const result = await verifyRecaptchaToken("token", 0.5); + expect(result).toBe(false); + }); + + test("returns false if verification fails (data.success is false)", async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ success: false }), + }); + const result = await verifyRecaptchaToken("token", 0.5); + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith({ success: false }, "reCAPTCHA verification failed"); + }); + + test("returns false if score is below or equal to threshold", async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ success: true, score: 0.3 }), + }); + const result = await verifyRecaptchaToken("token", 0.5); + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith( + { success: true, score: 0.3 }, + "reCAPTCHA score below threshold" + ); + }); + + test("returns true if verification is successful and score is above threshold", async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ success: true, score: 0.9 }), + }); + const result = await verifyRecaptchaToken("token", 0.5); + expect(result).toBe(true); + }); + + test("returns true if verification is successful and score is undefined", async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ success: true }), + }); + const result = await verifyRecaptchaToken("token", 0.5); + expect(result).toBe(true); + }); + + test("returns false and logs error if fetch throws", async () => { + (global.fetch as any).mockRejectedValue(new Error("network error")); + const result = await verifyRecaptchaToken("token", 0.5); + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Error verifying reCAPTCHA token"); + }); + + test("aborts fetch after timeout", async () => { + vi.useFakeTimers(); + let abortCalled = false; + const abortController = { + abort: () => { + abortCalled = true; + }, + signal: {}, + }; + vi.spyOn(global, "AbortController").mockImplementation(() => abortController as any); + (global.fetch as any).mockImplementation(() => new Promise(() => {})); + verifyRecaptchaToken("token", 0.5); + vi.advanceTimersByTime(5000); + expect(abortCalled).toBe(true); + }); +}); diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/recaptcha.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/recaptcha.ts new file mode 100644 index 000000000000..9776ccbc5565 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/recaptcha.ts @@ -0,0 +1,62 @@ +import { RECAPTCHA_SECRET_KEY, RECAPTCHA_SITE_KEY } from "@/lib/constants"; +import { logger } from "@formbricks/logger"; + +/** + * Verifies a reCAPTCHA token with Google's reCAPTCHA API + * @param token The reCAPTCHA token to verify + * @param threshold The minimum score threshold (0.0 to 1.0) + * @returns A promise that resolves to true if the verification is successful and the score meets the threshold, false otherwise + */ +export const verifyRecaptchaToken = async (token: string, threshold: number): Promise => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + try { + // If keys aren't configured, skip verification + if (!RECAPTCHA_SITE_KEY || !RECAPTCHA_SECRET_KEY) { + logger.warn("reCAPTCHA verification skipped: keys not configured"); + return true; + } + + // Build URL-encoded form data + const params = new URLSearchParams(); + params.append("secret", RECAPTCHA_SECRET_KEY); + params.append("response", token); + + // POST to Google’s siteverify endpoint + const response = await fetch("https://www.google.com/recaptcha/api/siteverify", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + signal: controller.signal, + }); + + if (!response.ok) { + logger.error(`reCAPTCHA HTTP error: ${response.status}`); + return false; + } + + const data = await response.json(); + + // Check if verification was successful + if (!data.success) { + logger.error(data, "reCAPTCHA verification failed"); + return false; + } + + // Check if the score meets the threshold + if (data.score !== undefined && data.score < threshold) { + logger.error(data, "reCAPTCHA score below threshold"); + return false; + } + + return true; + } catch (error) { + logger.error(error, "Error verifying reCAPTCHA token"); + return false; + } finally { + clearTimeout(timeoutId); + } +}; diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.test.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.test.ts new file mode 100644 index 000000000000..05e38649f6a0 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.test.ts @@ -0,0 +1,217 @@ +import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response"; +import { + getMonthlyOrganizationResponseCount, + getOrganizationByEnvironmentId, +} from "@/lib/organization/service"; +import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer"; +import { calculateTtcTotal } from "@/lib/response/utils"; +import { captureTelemetry } from "@/lib/telemetry"; +import { validateInputs } from "@/lib/utils/validate"; +import { Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { TContactAttributes } from "@formbricks/types/contact-attribute"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TResponse } from "@formbricks/types/responses"; +import { TTag } from "@formbricks/types/tags"; +import { getContact } from "./contact"; +import { createResponse } from "./response"; + +let mockIsFormbricksCloud = false; + +vi.mock("@/lib/constants", () => ({ + get IS_FORMBRICKS_CLOUD() { + return mockIsFormbricksCloud; + }, + IS_PRODUCTION: false, + FB_LOGO_URL: "https://example.com/mock-logo.png", + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "mock-github-secret", + GOOGLE_CLIENT_ID: "mock-google-client-id", + GOOGLE_CLIENT_SECRET: "mock-google-client-secret", + AZUREAD_CLIENT_ID: "mock-azuread-client-id", + AZUREAD_CLIENT_SECRET: "mock-azure-client-secret", + AZUREAD_TENANT_ID: "mock-azuread-tenant-id", + OIDC_CLIENT_ID: "mock-oidc-client-id", + OIDC_CLIENT_SECRET: "mock-oidc-client-secret", + OIDC_ISSUER: "mock-oidc-issuer", + OIDC_DISPLAY_NAME: "mock-oidc-display-name", + OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm", + SAML_DATABASE_URL: "mock-saml-database-url", + WEBAPP_URL: "mock-webapp-url", + SMTP_HOST: "mock-smtp-host", + SMTP_PORT: "mock-smtp-port", +})); + +vi.mock("@/lib/organization/service"); +vi.mock("@/lib/posthogServer"); +vi.mock("@/lib/response/utils"); +vi.mock("@/lib/telemetry"); +vi.mock("@/lib/utils/validate"); +vi.mock("@formbricks/database", () => ({ + prisma: { + response: { + create: vi.fn(), + }, + }, +})); +vi.mock("@formbricks/logger"); +vi.mock("./contact"); + +const environmentId = "test-environment-id"; +const surveyId = "test-survey-id"; +const organizationId = "test-organization-id"; +const responseId = "test-response-id"; +const contactId = "test-contact-id"; +const userId = "test-user-id"; +const displayId = "test-display-id"; + +const mockOrganization = { + id: organizationId, + name: "Test Org", + billing: { + limits: { monthly: { responses: 100 } }, + plan: "free", + }, +}; + +const mockContact: { id: string; attributes: TContactAttributes } = { + id: contactId, + attributes: { userId: userId, email: "test@example.com" }, +}; + +const mockResponseInput: TResponseInputV2 = { + environmentId, + surveyId, + contactId: null, + displayId: null, + finished: false, + data: { question1: "answer1" }, + meta: { source: "web" }, + ttc: { question1: 1000 }, + singleUseId: null, + language: "en", + variables: {}, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockResponsePrisma = { + id: responseId, + createdAt: new Date(), + updatedAt: new Date(), + surveyId, + finished: false, + data: { question1: "answer1" }, + meta: { source: "web" }, + ttc: { question1: 1000 }, + variables: {}, + contactAttributes: {}, + singleUseId: null, + language: "en", + displayId: null, + tags: [], +}; + +const expectedResponse: TResponse = { + ...mockResponsePrisma, + contact: null, + tags: [], +}; + +describe("createResponse V2", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(validateInputs).mockImplementation(() => {}); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any); + vi.mocked(getContact).mockResolvedValue(mockContact); + vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma as any); + vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ({ + ...ttc, + _total: Object.values(ttc).reduce((a, b) => a + b, 0), + })); + vi.mocked(captureTelemetry).mockResolvedValue(undefined); + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); + vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined); + }); + + afterEach(() => { + mockIsFormbricksCloud = false; + }); + + test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => { + mockIsFormbricksCloud = true; + await createResponse(mockResponseInput); + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); + expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled(); + }); + + test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => { + mockIsFormbricksCloud = true; + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100); + + await createResponse(mockResponseInput); + + expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId); + expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, { + plan: "free", + limits: { + projects: null, + monthly: { + responses: 100, + miu: null, + }, + }, + }); + }); + + test("should throw ResourceNotFoundError if organization not found", async () => { + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); + await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError); + }); + + test("should throw DatabaseError on Prisma known request error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", { + code: "P2002", + clientVersion: "test", + }); + vi.mocked(prisma.response.create).mockRejectedValue(prismaError); + await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError); + }); + + test("should throw original error on other errors", async () => { + const genericError = new Error("Generic database error"); + vi.mocked(prisma.response.create).mockRejectedValue(genericError); + await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError); + }); + + test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => { + mockIsFormbricksCloud = true; + vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100); + const posthogError = new Error("PostHog error"); + vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError); + + await createResponse(mockResponseInput); // Should not throw + + expect(logger.error).toHaveBeenCalledWith( + posthogError, + "Error sending plan limits reached event to Posthog" + ); + }); + + test("should correctly map prisma tags to response tags", async () => { + const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId }; + const prismaResponseWithTags = { + ...mockResponsePrisma, + tags: [{ tag: mockTag }], + }; + + vi.mocked(prisma.response.create).mockResolvedValue(prismaResponseWithTags as any); + + const result = await createResponse(mockResponseInput); + expect(result.tags).toEqual([mockTag]); + }); +}); diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.ts index 61dd326ea6d7..fe07394b52f6 100644 --- a/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.ts +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.ts @@ -1,19 +1,17 @@ import "server-only"; import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response"; import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response"; -import { Prisma } from "@prisma/client"; -import { prisma } from "@formbricks/database"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getMonthlyOrganizationResponseCount, getOrganizationByEnvironmentId, -} from "@formbricks/lib/organization/service"; -import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer"; -import { responseCache } from "@formbricks/lib/response/cache"; -import { calculateTtcTotal } from "@formbricks/lib/response/utils"; -import { responseNoteCache } from "@formbricks/lib/responseNote/cache"; -import { captureTelemetry } from "@formbricks/lib/telemetry"; -import { validateInputs } from "@formbricks/lib/utils/validate"; +} from "@/lib/organization/service"; +import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer"; +import { calculateTtcTotal } from "@/lib/response/utils"; +import { captureTelemetry } from "@/lib/telemetry"; +import { validateInputs } from "@/lib/utils/validate"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; import { TContactAttributes } from "@formbricks/types/contact-attribute"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; @@ -31,6 +29,7 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise tagPrisma.tag), }; - responseCache.revalidate({ - environmentId, - id: response.id, - contactId: contact?.id, - ...(singleUseId && { singleUseId }), - userId, - surveyId, - }); - - responseNoteCache.revalidate({ - responseId: response.id, - }); - if (IS_FORMBRICKS_CLOUD) { const responsesCount = await getMonthlyOrganizationResponseCount(organization.id); const responsesLimit = organization.billing.limits.monthly.responses; diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.test.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.test.ts new file mode 100644 index 000000000000..6ce6a1b65ca2 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.test.ts @@ -0,0 +1,331 @@ +import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization"; +import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha"; +import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils"; +import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response"; +import { responses } from "@/app/lib/api/response"; +import { symmetricDecrypt } from "@/lib/crypto"; +import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; +import { Organization } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; +import { TSurvey } from "@formbricks/types/surveys/types"; + +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: vi.fn().mockImplementation((value, language) => { + return typeof value === "string" ? value : value[language] || value["default"] || ""; + }), +})); + +vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/recaptcha", () => ({ + verifyRecaptchaToken: vi.fn(), +})); + +vi.mock("@/app/lib/api/response", () => ({ + responses: { + badRequestResponse: vi.fn((message) => new Response(message, { status: 400 })), + notFoundResponse: vi.fn((message) => new Response(message, { status: 404 })), + }, +})); + +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getIsSpamProtectionEnabled: vi.fn(), +})); + +vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/organization", () => ({ + getOrganizationBillingByEnvironmentId: vi.fn(), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +vi.mock("@/lib/crypto", () => ({ + symmetricDecrypt: vi.fn(), +})); +vi.mock("@/lib/constants", () => ({ + ENCRYPTION_KEY: "test-key", +})); + +const mockSurvey: TSurvey = { + id: "survey-1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + environmentId: "env-1", + type: "link", + status: "inProgress", + questions: [], + displayOption: "displayOnce", + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + displayPercentage: null, + autoComplete: null, + singleUse: null, + triggers: [], + languages: [], + pin: null, + segment: null, + styling: null, + surveyClosedMessage: null, + hiddenFields: { enabled: false }, + welcomeCard: { enabled: false, showResponseCount: false, timeToFinish: false }, + variables: [], + createdBy: null, + recaptcha: { enabled: false, threshold: 0.5 }, + displayLimit: null, + endings: [], + followUps: [], + isBackButtonHidden: false, + isSingleResponsePerEmailEnabled: false, + isVerifyEmailEnabled: false, + projectOverwrites: null, + runOnDate: null, + showLanguageSwitch: false, +}; + +const mockResponseInput: TResponseInputV2 = { + surveyId: "survey-1", + environmentId: "env-1", + data: {}, + finished: false, + ttc: {}, + meta: {}, +}; + +const mockBillingData: Organization["billing"] = { + limits: { + monthly: { miu: 0, responses: 0 }, + projects: 3, + }, + period: "monthly", + periodStart: new Date(), + plan: "scale", + stripeCustomerId: "mock-stripe-customer-id", +}; + +describe("checkSurveyValidity", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("should return badRequestResponse if survey environmentId does not match", async () => { + const survey = { ...mockSurvey, environmentId: "env-2" }; + const result = await checkSurveyValidity(survey, "env-1", mockResponseInput); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(400); + expect(responses.badRequestResponse).toHaveBeenCalledWith( + "Survey is part of another environment", + { + "survey.environmentId": "env-2", + environmentId: "env-1", + }, + true + ); + }); + + test("should return null if recaptcha is not enabled", async () => { + const survey = { ...mockSurvey, recaptcha: { enabled: false, threshold: 0.5 } }; + const result = await checkSurveyValidity(survey, "env-1", mockResponseInput); + expect(result).toBeNull(); + }); + + test("should return badRequestResponse if recaptcha enabled, spam protection enabled, but token is missing", async () => { + const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } }; + vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true); + const responseInputWithoutToken = { ...mockResponseInput }; + delete responseInputWithoutToken.recaptchaToken; + + const result = await checkSurveyValidity(survey, "env-1", responseInputWithoutToken); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(400); + expect(logger.error).toHaveBeenCalledWith("Missing recaptcha token"); + expect(responses.badRequestResponse).toHaveBeenCalledWith( + "Missing recaptcha token", + { code: "recaptcha_verification_failed" }, + true + ); + }); + + test("should return not found response if billing data is not found", async () => { + const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } }; + vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true); + vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(null); + + const result = await checkSurveyValidity(survey, "env-1", { + ...mockResponseInput, + recaptchaToken: "test-token", + }); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(404); + expect(responses.notFoundResponse).toHaveBeenCalledWith("Organization", null); + expect(getOrganizationBillingByEnvironmentId).toHaveBeenCalledWith("env-1"); + }); + + test("should return null if recaptcha is enabled but spam protection is disabled", async () => { + const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } }; + vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false); + vi.mocked(verifyRecaptchaToken).mockResolvedValue(true); + vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData); + const result = await checkSurveyValidity(survey, "env-1", { + ...mockResponseInput, + recaptchaToken: "test-token", + }); + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith("Spam protection is not enabled for this organization"); + }); + + test("should return badRequestResponse if recaptcha verification fails", async () => { + const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } }; + const responseInputWithToken = { ...mockResponseInput, recaptchaToken: "test-token" }; + vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true); + vi.mocked(verifyRecaptchaToken).mockResolvedValue(false); + vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData); + + const result = await checkSurveyValidity(survey, "env-1", responseInputWithToken); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(400); + expect(verifyRecaptchaToken).toHaveBeenCalledWith("test-token", 0.5); + expect(responses.badRequestResponse).toHaveBeenCalledWith( + "reCAPTCHA verification failed", + { code: "recaptcha_verification_failed" }, + true + ); + }); + + test("should return null if recaptcha verification passes", async () => { + const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } }; + const responseInputWithToken = { ...mockResponseInput, recaptchaToken: "test-token" }; + vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true); + vi.mocked(verifyRecaptchaToken).mockResolvedValue(true); + vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData); + + const result = await checkSurveyValidity(survey, "env-1", responseInputWithToken); + expect(result).toBeNull(); + expect(verifyRecaptchaToken).toHaveBeenCalledWith("test-token", 0.5); + }); + + test("should return null for a valid survey and input", async () => { + const survey = { ...mockSurvey }; // Recaptcha disabled by default in mock + const result = await checkSurveyValidity(survey, "env-1", mockResponseInput); + expect(result).toBeNull(); + }); + + test("should return badRequestResponse if singleUse is enabled and singleUseId is missing", async () => { + const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } }; + const result = await checkSurveyValidity(survey, "env-1", { ...mockResponseInput }); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(400); + expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", { + surveyId: survey.id, + environmentId: "env-1", + }); + }); + + test("should return badRequestResponse if singleUse is enabled and meta.url is missing", async () => { + const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } }; + const result = await checkSurveyValidity(survey, "env-1", { + ...mockResponseInput, + singleUseId: "su-1", + meta: {}, + }); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(400); + expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing or invalid URL in response metadata", { + surveyId: survey.id, + environmentId: "env-1", + }); + }); + + test("should return badRequestResponse if meta.url is invalid", async () => { + const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } }; + const result = await checkSurveyValidity(survey, "env-1", { + ...mockResponseInput, + singleUseId: "su-1", + meta: { url: "not-a-url" }, + }); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(400); + expect(responses.badRequestResponse).toHaveBeenCalledWith( + "Invalid URL in response metadata", + expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" }) + ); + }); + + test("should return badRequestResponse if suId is missing from url", async () => { + const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } }; + const url = "https://example.com/?foo=bar"; + const result = await checkSurveyValidity(survey, "env-1", { + ...mockResponseInput, + singleUseId: "su-1", + meta: { url }, + }); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(400); + expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", { + surveyId: survey.id, + environmentId: "env-1", + }); + }); + + test("should return badRequestResponse if isEncrypted and decrypted suId does not match singleUseId", async () => { + const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } }; + const url = "https://example.com/?suId=encrypted-id"; + vi.mocked(symmetricDecrypt).mockReturnValue("decrypted-id"); + const resultEncryptedMismatch = await checkSurveyValidity(survey, "env-1", { + ...mockResponseInput, + singleUseId: "su-1", + meta: { url }, + }); + expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key"); + expect(resultEncryptedMismatch).toBeInstanceOf(Response); + expect(resultEncryptedMismatch?.status).toBe(400); + expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", { + surveyId: survey.id, + environmentId: "env-1", + }); + }); + + test("should return badRequestResponse if not encrypted and suId does not match singleUseId", async () => { + const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } }; + const url = "https://example.com/?suId=su-2"; + const result = await checkSurveyValidity(survey, "env-1", { + ...mockResponseInput, + singleUseId: "su-1", + meta: { url }, + }); + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(400); + expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", { + surveyId: survey.id, + environmentId: "env-1", + }); + }); + + test("should return null if singleUse is enabled, not encrypted, and suId matches singleUseId", async () => { + const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } }; + const url = "https://example.com/?suId=su-1"; + const result = await checkSurveyValidity(survey, "env-1", { + ...mockResponseInput, + singleUseId: "su-1", + meta: { url }, + }); + expect(result).toBeNull(); + }); + + test("should return null if singleUse is enabled, encrypted, and decrypted suId matches singleUseId", async () => { + const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } }; + const url = "https://example.com/?suId=encrypted-id"; + vi.mocked(symmetricDecrypt).mockReturnValue("su-1"); + const _resultEncryptedMatch = await checkSurveyValidity(survey, "env-1", { + ...mockResponseInput, + singleUseId: "su-1", + meta: { url }, + }); + expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key"); + expect(_resultEncryptedMatch).toBeNull(); + }); +}); diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.ts new file mode 100644 index 000000000000..2a20085738d2 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.ts @@ -0,0 +1,114 @@ +import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization"; +import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha"; +import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response"; +import { responses } from "@/app/lib/api/response"; +import { ENCRYPTION_KEY } from "@/lib/constants"; +import { symmetricDecrypt } from "@/lib/crypto"; +import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; +import { logger } from "@formbricks/logger"; +import { TSurvey } from "@formbricks/types/surveys/types"; + +export const RECAPTCHA_VERIFICATION_ERROR_CODE = "recaptcha_verification_failed"; + +export const checkSurveyValidity = async ( + survey: TSurvey, + environmentId: string, + responseInput: TResponseInputV2 +): Promise => { + if (survey.environmentId !== environmentId) { + return responses.badRequestResponse( + "Survey is part of another environment", + { + "survey.environmentId": survey.environmentId, + environmentId, + }, + true + ); + } + + if (survey.type === "link" && survey.singleUse?.enabled) { + if (!responseInput.singleUseId) { + return responses.badRequestResponse("Missing single use id", { + surveyId: survey.id, + environmentId, + }); + } + + if (!responseInput.meta?.url) { + return responses.badRequestResponse("Missing or invalid URL in response metadata", { + surveyId: survey.id, + environmentId, + }); + } + + let url; + try { + url = new URL(responseInput.meta.url); + } catch (error) { + return responses.badRequestResponse("Invalid URL in response metadata", { + surveyId: survey.id, + environmentId, + error: error.message, + }); + } + const suId = url.searchParams.get("suId"); + if (!suId) { + return responses.badRequestResponse("Missing single use id", { + surveyId: survey.id, + environmentId, + }); + } + + if (survey.singleUse.isEncrypted) { + const decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY); + if (decryptedSuId !== responseInput.singleUseId) { + return responses.badRequestResponse("Invalid single use id", { + surveyId: survey.id, + environmentId, + }); + } + } else if (responseInput.singleUseId !== suId) { + return responses.badRequestResponse("Invalid single use id", { + surveyId: survey.id, + environmentId, + }); + } + } + + if (survey.recaptcha?.enabled) { + if (!responseInput.recaptchaToken) { + logger.error("Missing recaptcha token"); + return responses.badRequestResponse( + "Missing recaptcha token", + { + code: RECAPTCHA_VERIFICATION_ERROR_CODE, + }, + true + ); + } + const billing = await getOrganizationBillingByEnvironmentId(environmentId); + + if (!billing) { + return responses.notFoundResponse("Organization", null); + } + + const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(billing.plan); + + if (!isSpamProtectionEnabled) { + logger.error("Spam protection is not enabled for this organization"); + } + + const isPassed = await verifyRecaptchaToken(responseInput.recaptchaToken, survey.recaptcha.threshold); + if (!isPassed) { + return responses.badRequestResponse( + "reCAPTCHA verification failed", + { + code: RECAPTCHA_VERIFICATION_ERROR_CODE, + }, + true + ); + } + } + + return null; +}; diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/route.ts b/apps/web/app/api/v2/client/[environmentId]/responses/route.ts index 2231ad4d4e75..195e520eb4e9 100644 --- a/apps/web/app/api/v2/client/[environmentId]/responses/route.ts +++ b/apps/web/app/api/v2/client/[environmentId]/responses/route.ts @@ -1,11 +1,13 @@ +import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { sendToPipeline } from "@/app/lib/pipelines"; +import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer"; +import { getSurvey } from "@/lib/survey/service"; +import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { headers } from "next/headers"; import { UAParser } from "ua-parser-js"; -import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer"; -import { getSurvey } from "@formbricks/lib/survey/service"; import { logger } from "@formbricks/logger"; import { ZId } from "@formbricks/types/common"; import { InvalidInputError } from "@formbricks/types/errors"; @@ -20,7 +22,13 @@ interface Context { } export const OPTIONS = async (): Promise => { - return responses.successResponse({}, true); + return responses.successResponse( + {}, + true, + // Cache CORS preflight responses for 1 hour (conservative approach) + // Balances performance gains with flexibility for CORS policy changes + "public, s-maxage=3600, max-age=3600" + ); }; export const POST = async (request: Request, context: Context): Promise => { @@ -74,14 +82,23 @@ export const POST = async (request: Request, context: Context): Promise; diff --git a/apps/web/app/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts b/apps/web/app/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts new file mode 100644 index 000000000000..6ae62003eba4 --- /dev/null +++ b/apps/web/app/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts @@ -0,0 +1,7 @@ +import { + DELETE, + GET, + PUT, +} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route"; + +export { GET, PUT, DELETE }; diff --git a/apps/web/app/api/v2/management/contact-attribute-keys/route.ts b/apps/web/app/api/v2/management/contact-attribute-keys/route.ts new file mode 100644 index 000000000000..2b7018e8200b --- /dev/null +++ b/apps/web/app/api/v2/management/contact-attribute-keys/route.ts @@ -0,0 +1,3 @@ +import { GET, POST } from "@/modules/api/v2/management/contact-attribute-keys/route"; + +export { GET, POST }; diff --git a/apps/web/app/api/v2/management/contacts/route.ts b/apps/web/app/api/v2/management/contacts/route.ts new file mode 100644 index 000000000000..b216e7c2b981 --- /dev/null +++ b/apps/web/app/api/v2/management/contacts/route.ts @@ -0,0 +1 @@ +export { POST } from "@/modules/ee/contacts/api/v2/management/contacts/route"; diff --git a/apps/web/app/error.test.tsx b/apps/web/app/error.test.tsx new file mode 100644 index 000000000000..90cb5752ae51 --- /dev/null +++ b/apps/web/app/error.test.tsx @@ -0,0 +1,186 @@ +import * as Sentry from "@sentry/nextjs"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import ErrorBoundary from "./error"; + +vi.mock("@/modules/ui/components/button", () => ({ + Button: (props: any) => , +})); + +vi.mock("@/modules/ui/components/error-component", () => ({ + ErrorComponent: ({ title, description }: { title: string; description: string }) => ( +
+
{title}
+
{description}
+
+ ), +})); + +vi.mock("@sentry/nextjs", () => ({ + captureException: vi.fn(), +})); + +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => { + const translations: Record = { + "common.error_rate_limit_title": "Too Many Requests", + "common.error_rate_limit_description": "You're making too many requests. Please slow down.", + "common.error_component_title": "Something went wrong", + "common.error_component_description": "An unexpected error occurred. Please try again.", + "common.try_again": "Try Again", + "common.go_to_dashboard": "Go to Dashboard", + }; + return translations[key] || key; + }, + }), +})); + +vi.mock("@formbricks/types/errors", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getClientErrorData: vi.fn(), + }; +}); + +describe("ErrorBoundary", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const dummyError = new Error("Test error"); + const resetMock = vi.fn(); + + test("logs error via console.error in development", async () => { + (process.env as { [key: string]: string }).NODE_ENV = "development"; + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any); + + const { getClientErrorData } = await import("@formbricks/types/errors"); + vi.mocked(getClientErrorData).mockReturnValue({ + type: "general", + showButtons: true, + }); + + render(); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith("Test error"); + }); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + test("captures error with Sentry in production", async () => { + (process.env as { [key: string]: string }).NODE_ENV = "production"; + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any); + + const { getClientErrorData } = await import("@formbricks/types/errors"); + vi.mocked(getClientErrorData).mockReturnValue({ + type: "general", + showButtons: true, + }); + + render(); + + await waitFor(() => { + expect(Sentry.captureException).toHaveBeenCalled(); + }); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + test("calls reset when try again button is clicked for general errors", async () => { + const { getClientErrorData } = await import("@formbricks/types/errors"); + vi.mocked(getClientErrorData).mockReturnValue({ + type: "general", + showButtons: true, + }); + + render(); + const tryAgainBtn = screen.getByRole("button", { name: "Try Again" }); + userEvent.click(tryAgainBtn); + await waitFor(() => expect(resetMock).toHaveBeenCalled()); + }); + + test("sets window.location.href to '/' when dashboard button is clicked for general errors", async () => { + const { getClientErrorData } = await import("@formbricks/types/errors"); + vi.mocked(getClientErrorData).mockReturnValue({ + type: "general", + showButtons: true, + }); + + const originalLocation = window.location; + (window as any).location = undefined; + (window as any).location = { href: "" }; + render(); + const dashBtn = screen.getByRole("button", { name: "Go to Dashboard" }); + userEvent.click(dashBtn); + await waitFor(() => { + expect(window.location.href).toBe("/"); + }); + (window as any).location = originalLocation; + }); + + test("does not show buttons for rate limit errors", async () => { + const { getClientErrorData } = await import("@formbricks/types/errors"); + vi.mocked(getClientErrorData).mockReturnValue({ + type: "rate_limit", + showButtons: false, + }); + + render(); + + expect(screen.queryByRole("button", { name: "Try Again" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Go to Dashboard" })).not.toBeInTheDocument(); + }); + + test("shows error component with rate limit messages for rate limit errors", async () => { + const { getClientErrorData } = await import("@formbricks/types/errors"); + vi.mocked(getClientErrorData).mockReturnValue({ + type: "rate_limit", + showButtons: false, + }); + + render(); + + expect(screen.getByTestId("ErrorComponent")).toBeInTheDocument(); + expect(screen.getByTestId("error-title")).toHaveTextContent("Too Many Requests"); + expect(screen.getByTestId("error-description")).toHaveTextContent( + "You're making too many requests. Please slow down." + ); + expect(getClientErrorData).toHaveBeenCalledWith(dummyError); + }); + + test("shows error component with general messages for general errors", async () => { + const { getClientErrorData } = await import("@formbricks/types/errors"); + vi.mocked(getClientErrorData).mockReturnValue({ + type: "general", + showButtons: true, + }); + + render(); + + expect(screen.getByTestId("ErrorComponent")).toBeInTheDocument(); + expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong"); + expect(screen.getByTestId("error-description")).toHaveTextContent( + "An unexpected error occurred. Please try again." + ); + expect(getClientErrorData).toHaveBeenCalledWith(dummyError); + }); + + test("shows buttons for general errors", async () => { + const { getClientErrorData } = await import("@formbricks/types/errors"); + vi.mocked(getClientErrorData).mockReturnValue({ + type: "general", + showButtons: true, + }); + + render(); + + expect(screen.getByRole("button", { name: "Try Again" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Go to Dashboard" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx index a99389a6a076..3cd85fc24a19 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/error.tsx @@ -3,25 +3,52 @@ // Error components must be Client components import { Button } from "@/modules/ui/components/button"; import { ErrorComponent } from "@/modules/ui/components/error-component"; +import * as Sentry from "@sentry/nextjs"; import { useTranslate } from "@tolgee/react"; +import { type ClientErrorType, getClientErrorData } from "@formbricks/types/errors"; -const Error = ({ error, reset }: { error: Error; reset: () => void }) => { +/** + * Get translated error messages based on error type + * All translation keys are directly visible to Tolgee's static analysis + */ +const getErrorMessages = (type: ClientErrorType, t: (key: string) => string) => { + if (type === "rate_limit") { + return { + title: t("common.error_rate_limit_title"), + description: t("common.error_rate_limit_description"), + }; + } + + return { + title: t("common.error_component_title"), + description: t("common.error_component_description"), + }; +}; + +const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) => { const { t } = useTranslate(); + const errorData = getClientErrorData(error); + const { title, description } = getErrorMessages(errorData.type, t); + if (process.env.NODE_ENV === "development") { console.error(error.message); + } else { + Sentry.captureException(error); } return (
- -
- - -
+ + {errorData.showButtons && ( +
+ + +
+ )}
); }; -export default Error; +export default ErrorBoundary; diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico deleted file mode 100644 index e46f0bc2a2f3..000000000000 Binary files a/apps/web/app/favicon.ico and /dev/null differ diff --git a/apps/web/app/global-error.test.tsx b/apps/web/app/global-error.test.tsx new file mode 100644 index 000000000000..52b339d0313f --- /dev/null +++ b/apps/web/app/global-error.test.tsx @@ -0,0 +1,41 @@ +import * as Sentry from "@sentry/nextjs"; +import { cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import GlobalError from "./global-error"; + +vi.mock("@sentry/nextjs", () => ({ + captureException: vi.fn(), +})); + +describe("GlobalError", () => { + const dummyError = new Error("Test error"); + + afterEach(() => { + cleanup(); + }); + + test("logs error using console.error in development", async () => { + (process.env as { [key: string]: string }).NODE_ENV = "development"; + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any); + + render(); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith("Test error"); + }); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + test("captures error with Sentry in production", async () => { + (process.env as { [key: string]: string }).NODE_ENV = "production"; + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + render(); + + await waitFor(() => { + expect(Sentry.captureException).toHaveBeenCalled(); + }); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx new file mode 100644 index 000000000000..25682ad93985 --- /dev/null +++ b/apps/web/app/global-error.tsx @@ -0,0 +1,22 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import NextError from "next/error"; +import { useEffect } from "react"; + +export default function GlobalError({ error }: { error: Error & { digest?: string } }) { + useEffect(() => { + if (process.env.NODE_ENV === "development") { + console.error(error.message); + } else { + Sentry.captureException(error); + } + }, [error]); + return ( + + + + + + ); +} diff --git a/apps/web/app/intercom/IntercomClient.test.tsx b/apps/web/app/intercom/IntercomClient.test.tsx index 8c78cda32ac6..6f96920bd79e 100644 --- a/apps/web/app/intercom/IntercomClient.test.tsx +++ b/apps/web/app/intercom/IntercomClient.test.tsx @@ -1,7 +1,7 @@ import Intercom from "@intercom/messenger-js-sdk"; import "@testing-library/jest-dom/vitest"; import { cleanup, render } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { TUser } from "@formbricks/types/user"; import { IntercomClient } from "./IntercomClient"; @@ -26,7 +26,7 @@ describe("IntercomClient", () => { global.window.Intercom = originalWindowIntercom; }); - it("calls Intercom with user data when isIntercomConfigured is true and user is provided", () => { + test("calls Intercom with user data when isIntercomConfigured is true and user is provided", () => { const testUser = { id: "test-id", name: "Test User", @@ -55,7 +55,7 @@ describe("IntercomClient", () => { }); }); - it("calls Intercom with user data without createdAt", () => { + test("calls Intercom with user data without createdAt", () => { const testUser = { id: "test-id", name: "Test User", @@ -83,7 +83,7 @@ describe("IntercomClient", () => { }); }); - it("calls Intercom with minimal params if user is not provided", () => { + test("calls Intercom with minimal params if user is not provided", () => { render( ); @@ -94,7 +94,7 @@ describe("IntercomClient", () => { }); }); - it("does not call Intercom if isIntercomConfigured is false", () => { + test("does not call Intercom if isIntercomConfigured is false", () => { render( { expect(Intercom).not.toHaveBeenCalled(); }); - it("shuts down Intercom on unmount", () => { + test("shuts down Intercom on unmount", () => { const { unmount } = render( ); @@ -120,7 +120,7 @@ describe("IntercomClient", () => { expect(mockWindowIntercom).toHaveBeenCalledWith("shutdown"); }); - it("logs an error if Intercom initialization fails", () => { + test("logs an error if Intercom initialization fails", () => { // Spy on console.error const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -141,7 +141,7 @@ describe("IntercomClient", () => { consoleErrorSpy.mockRestore(); }); - it("logs an error if isIntercomConfigured is true but no intercomAppId is provided", () => { + test("logs an error if isIntercomConfigured is true but no intercomAppId is provided", () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); render( @@ -159,7 +159,7 @@ describe("IntercomClient", () => { consoleErrorSpy.mockRestore(); }); - it("logs an error if isIntercomConfigured is true but no intercomUserHash is provided", () => { + test("logs an error if isIntercomConfigured is true but no intercomUserHash is provided", () => { const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const testUser = { id: "test-id", diff --git a/apps/web/app/intercom/IntercomClientWrapper.test.tsx b/apps/web/app/intercom/IntercomClientWrapper.test.tsx index 52c8eaaf4f94..59bcc1989b7c 100644 --- a/apps/web/app/intercom/IntercomClientWrapper.test.tsx +++ b/apps/web/app/intercom/IntercomClientWrapper.test.tsx @@ -1,9 +1,9 @@ import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { TUser } from "@formbricks/types/user"; import { IntercomClientWrapper } from "./IntercomClientWrapper"; -vi.mock("@formbricks/lib/constants", () => ({ +vi.mock("@/lib/constants", () => ({ IS_INTERCOM_CONFIGURED: true, INTERCOM_APP_ID: "mock-intercom-app-id", INTERCOM_SECRET_KEY: "mock-intercom-secret-key", @@ -31,7 +31,7 @@ describe("IntercomClientWrapper", () => { cleanup(); }); - it("renders IntercomClient with computed user hash when user is provided", () => { + test("renders IntercomClient with computed user hash when user is provided", () => { const testUser = { id: "user-123", name: "Test User", email: "test@example.com" } as TUser; render(); @@ -48,7 +48,7 @@ describe("IntercomClientWrapper", () => { expect(props.user).toEqual(testUser); }); - it("renders IntercomClient without computing a hash when no user is provided", () => { + test("renders IntercomClient without computing a hash when no user is provided", () => { render(); const intercomClientEl = screen.getByTestId("mock-intercom-client"); diff --git a/apps/web/app/intercom/IntercomClientWrapper.tsx b/apps/web/app/intercom/IntercomClientWrapper.tsx index dd8daa76a521..331c93083a13 100644 --- a/apps/web/app/intercom/IntercomClientWrapper.tsx +++ b/apps/web/app/intercom/IntercomClientWrapper.tsx @@ -1,5 +1,5 @@ +import { INTERCOM_APP_ID, INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@/lib/constants"; import { createHmac } from "crypto"; -import { INTERCOM_APP_ID, INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@formbricks/lib/constants"; import type { TUser } from "@formbricks/types/user"; import { IntercomClient } from "./IntercomClient"; diff --git a/apps/web/app/layout.test.tsx b/apps/web/app/layout.test.tsx index 62b30062a8ed..4c3f4031d56b 100644 --- a/apps/web/app/layout.test.tsx +++ b/apps/web/app/layout.test.tsx @@ -1,14 +1,15 @@ import { getLocale } from "@/tolgee/language"; import { getTolgee } from "@/tolgee/server"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup } from "@testing-library/react"; import { TolgeeInstance } from "@tolgee/react"; import React from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import RootLayout from "./layout"; +import { renderToString } from "react-dom/server"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import RootLayout, { metadata } from "./layout"; // Mock dependencies for the layout -vi.mock("@formbricks/lib/constants", () => ({ +vi.mock("@/lib/constants", () => ({ IS_FORMBRICKS_CLOUD: false, POSTHOG_API_KEY: "mock-posthog-api-key", POSTHOG_HOST: "mock-posthog-host", @@ -30,6 +31,8 @@ vi.mock("@formbricks/lib/constants", () => ({ WEBAPP_URL: "test-webapp-url", IS_PRODUCTION: false, SENTRY_DSN: "mock-sentry-dsn", + SENTRY_RELEASE: "mock-sentry-release", + SENTRY_ENVIRONMENT: "mock-sentry-environment", })); vi.mock("@/tolgee/language", () => ({ @@ -40,15 +43,6 @@ vi.mock("@/tolgee/server", () => ({ getTolgee: vi.fn(), })); -vi.mock("@/modules/ui/components/post-hog-client", () => ({ - PHProvider: ({ children, posthogEnabled }: { children: React.ReactNode; posthogEnabled: boolean }) => ( -
- PHProvider: {posthogEnabled} - {children} -
- ), -})); - vi.mock("@/tolgee/client", () => ({ TolgeeNextProvider: ({ children, @@ -67,9 +61,18 @@ vi.mock("@/tolgee/client", () => ({ })); vi.mock("@/app/sentry/SentryProvider", () => ({ - SentryProvider: ({ children, sentryDsn }: { children: React.ReactNode; sentryDsn?: string }) => ( + SentryProvider: ({ + children, + sentryDsn, + sentryRelease, + }: { + children: React.ReactNode; + sentryDsn?: string; + sentryRelease?: string; + }) => (
SentryProvider: {sentryDsn} + {sentryRelease && ` - Release: ${sentryRelease}`} {children}
), @@ -81,7 +84,7 @@ describe("RootLayout", () => { process.env.VERCEL = "1"; }); - it("renders the layout with the correct structure and providers", async () => { + test("renders the layout with the correct structure and providers", async () => { const fakeLocale = "en-US"; // Mock getLocale to resolve to a fake locale vi.mocked(getLocale).mockResolvedValue(fakeLocale); @@ -95,10 +98,53 @@ describe("RootLayout", () => { const children =
Child Content
; const element = await RootLayout({ children }); - render(element); + const html = renderToString(element); + + // Create a container and set its innerHTML + const container = document.createElement("div"); + container.innerHTML = html; + document.body.appendChild(container); + + // Now we can use screen queries on the rendered content + expect(container.querySelector('[data-testid="tolgee-next-provider"]')).toBeInTheDocument(); + expect(container.querySelector('[data-testid="sentry-provider"]')).toBeInTheDocument(); + expect(container.querySelector('[data-testid="child"]')).toHaveTextContent("Child Content"); + + // Cleanup + document.body.removeChild(container); + }); + + test("renders with different locale", async () => { + const fakeLocale = "de-DE"; + vi.mocked(getLocale).mockResolvedValue(fakeLocale); + + const fakeStaticData = { key: "value" }; + const fakeTolgee = { + loadRequired: vi.fn().mockResolvedValue(fakeStaticData), + }; + vi.mocked(getTolgee).mockResolvedValue(fakeTolgee as unknown as TolgeeInstance); + + const children =
Child Content
; + const element = await RootLayout({ children }); + const html = renderToString(element); + + const container = document.createElement("div"); + container.innerHTML = html; + document.body.appendChild(container); + + const tolgeeProvider = container.querySelector('[data-testid="tolgee-next-provider"]'); + expect(tolgeeProvider).toHaveTextContent(fakeLocale); + + document.body.removeChild(container); + }); - expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument(); - expect(screen.getByTestId("sentry-provider")).toBeInTheDocument(); - expect(screen.getByTestId("child")).toHaveTextContent("Child Content"); + test("exports correct metadata", () => { + expect(metadata).toEqual({ + title: { + template: "%s | Formbricks", + default: "Formbricks", + }, + description: "Open-Source Survey Suite", + }); }); }); diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 6b541b9ddcbf..eb6eaef0aa7c 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,11 +1,11 @@ import { SentryProvider } from "@/app/sentry/SentryProvider"; +import { IS_PRODUCTION, SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants"; import { TolgeeNextProvider } from "@/tolgee/client"; import { getLocale } from "@/tolgee/language"; import { getTolgee } from "@/tolgee/server"; import { TolgeeStaticData } from "@tolgee/react"; import { Metadata } from "next"; import React from "react"; -import { SENTRY_DSN } from "@formbricks/lib/constants"; import "../modules/ui/globals.css"; export const metadata: Metadata = { @@ -25,7 +25,11 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => { return ( - + {children} diff --git a/apps/web/app/lib/actionClass/actionClass.test.ts b/apps/web/app/lib/actionClass/actionClass.test.ts new file mode 100644 index 000000000000..e851b1a230ac --- /dev/null +++ b/apps/web/app/lib/actionClass/actionClass.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { isValidCssSelector } from "./actionClass"; + +describe("isValidCssSelector", () => { + beforeEach(() => { + // Mock document.createElement and querySelector + const mockElement = { + querySelector: vi.fn(), + }; + global.document = { + createElement: vi.fn(() => mockElement), + } as any; + }); + + test("should return false for undefined selector", () => { + expect(isValidCssSelector(undefined)).toBe(false); + }); + + test("should return false for empty string", () => { + expect(isValidCssSelector("")).toBe(false); + }); + + test("should return true for valid CSS selector", () => { + const mockElement = { + querySelector: vi.fn(), + }; + (document.createElement as any).mockReturnValue(mockElement); + expect(isValidCssSelector(".class")).toBe(true); + expect(isValidCssSelector("#id")).toBe(true); + expect(isValidCssSelector("div")).toBe(true); + }); + + test("should return false for invalid CSS selector", () => { + const mockElement = { + querySelector: vi.fn(() => { + throw new Error("Invalid selector"); + }), + }; + (document.createElement as any).mockReturnValue(mockElement); + expect(isValidCssSelector("..invalid")).toBe(false); + expect(isValidCssSelector("##invalid")).toBe(false); + }); +}); diff --git a/apps/web/app/lib/api/response.test.ts b/apps/web/app/lib/api/response.test.ts new file mode 100644 index 000000000000..48700313d976 --- /dev/null +++ b/apps/web/app/lib/api/response.test.ts @@ -0,0 +1,366 @@ +import { NextApiResponse } from "next"; +import { describe, expect, test } from "vitest"; +import { responses } from "./response"; + +describe("API Response Utilities", () => { + describe("successResponse", () => { + test("should return a success response with data", () => { + const testData = { message: "test" }; + const response = responses.successResponse(testData); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + + return response.json().then((body) => { + expect(body).toEqual({ data: testData }); + }); + }); + + test("should include CORS headers when cors is true", () => { + const testData = { message: "test" }; + const response = responses.successResponse(testData, true); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS"); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization"); + }); + + test("should use custom cache control header when provided", () => { + const testData = { message: "test" }; + const customCache = "public, max-age=3600"; + const response = responses.successResponse(testData, false, customCache); + + expect(response.headers.get("Cache-Control")).toBe(customCache); + }); + }); + + describe("badRequestResponse", () => { + test("should return a bad request response", () => { + const message = "Invalid input"; + const details = { field: "email" }; + const response = responses.badRequestResponse(message, details); + + expect(response.status).toBe(400); + + return response.json().then((body) => { + expect(body).toEqual({ + code: "bad_request", + message, + details, + }); + }); + }); + + test("should handle undefined details", () => { + const message = "Invalid input"; + const response = responses.badRequestResponse(message); + + expect(response.status).toBe(400); + + return response.json().then((body) => { + expect(body).toEqual({ + code: "bad_request", + message, + details: {}, + }); + }); + }); + + test("should use custom cache control header when provided", () => { + const message = "Invalid input"; + const customCache = "no-cache"; + const response = responses.badRequestResponse(message, undefined, false, customCache); + + expect(response.headers.get("Cache-Control")).toBe(customCache); + }); + }); + + describe("notFoundResponse", () => { + test("should return a not found response", () => { + const resourceType = "User"; + const resourceId = "123"; + const response = responses.notFoundResponse(resourceType, resourceId); + + expect(response.status).toBe(404); + + return response.json().then((body) => { + expect(body).toEqual({ + code: "not_found", + message: `${resourceType} not found`, + details: { + resource_id: resourceId, + resource_type: resourceType, + }, + }); + }); + }); + + test("should handle null resourceId", () => { + const resourceType = "User"; + const response = responses.notFoundResponse(resourceType, null); + + expect(response.status).toBe(404); + + return response.json().then((body) => { + expect(body).toEqual({ + code: "not_found", + message: `${resourceType} not found`, + details: { + resource_id: null, + resource_type: resourceType, + }, + }); + }); + }); + + test("should use custom cache control header when provided", () => { + const resourceType = "User"; + const resourceId = "123"; + const customCache = "no-cache"; + const response = responses.notFoundResponse(resourceType, resourceId, false, customCache); + + expect(response.headers.get("Cache-Control")).toBe(customCache); + }); + }); + + describe("internalServerErrorResponse", () => { + test("should return an internal server error response", () => { + const message = "Something went wrong"; + const response = responses.internalServerErrorResponse(message); + + expect(response.status).toBe(500); + + return response.json().then((body) => { + expect(body).toEqual({ + code: "internal_server_error", + message, + details: {}, + }); + }); + }); + + test("should include CORS headers when cors is true", () => { + const message = "Something went wrong"; + const response = responses.internalServerErrorResponse(message, true); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS"); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization"); + }); + + test("should use custom cache control header when provided", () => { + const message = "Something went wrong"; + const customCache = "no-cache"; + const response = responses.internalServerErrorResponse(message, false, customCache); + + expect(response.headers.get("Cache-Control")).toBe(customCache); + }); + }); + + describe("goneResponse", () => { + test("should return a gone response", () => { + const message = "Resource no longer available"; + const details = { reason: "deleted" }; + const response = responses.goneResponse(message, details); + + expect(response.status).toBe(410); + + return response.json().then((body) => { + expect(body).toEqual({ + code: "gone", + message, + details, + }); + }); + }); + + test("should handle undefined details", () => { + const message = "Resource no longer available"; + const response = responses.goneResponse(message); + + expect(response.status).toBe(410); + + return response.json().then((body) => { + expect(body).toEqual({ + code: "gone", + message, + details: {}, + }); + }); + }); + + test("should use custom cache control header when provided", () => { + const message = "Resource no longer available"; + const customCache = "no-cache"; + const response = responses.goneResponse(message, undefined, false, customCache); + + expect(response.headers.get("Cache-Control")).toBe(customCache); + }); + }); + + describe("methodNotAllowedResponse", () => { + test("should return a method not allowed response", () => { + const mockRes = { + req: { method: "PUT" }, + } as NextApiResponse; + const allowedMethods = ["GET", "POST"]; + const response = responses.methodNotAllowedResponse(mockRes, allowedMethods); + + expect(response.status).toBe(405); + + return response.json().then((body) => { + expect(body).toEqual({ + code: "method_not_allowed", + message: "The HTTP PUT method is not supported by this route.", + details: { + allowed_methods: allowedMethods, + }, + }); + }); + }); + + test("should handle missing request method", () => { + const mockRes = {} as NextApiResponse; + const allowedMethods = ["GET", "POST"]; + const response = responses.methodNotAllowedResponse(mockRes, allowedMethods); + + expect(response.status).toBe(405); + + return response.json().then((body) => { + expect(body).toEqual({ + code: "method_not_allowed", + message: "The HTTP undefined method is not supported by this route.", + details: { + allowed_methods: allowedMethods, + }, + }); + }); + }); + + test("should use custom cache control header when provided", () => { + const mockRes = { + req: { method: "PUT" }, + } as NextApiResponse; + const allowedMethods = ["GET", "POST"]; + const customCache = "no-cache"; + const response = responses.methodNotAllowedResponse(mockRes, allowedMethods, false, customCache); + + expect(response.headers.get("Cache-Control")).toBe(customCache); + }); + }); + + describe("notAuthenticatedResponse", () => { + test("should return a not authenticated response", () => { + const response = responses.notAuthenticatedResponse(); + + expect(response.status).toBe(401); + + return response.json().then((body) => { + expect(body).toEqual({ + code: "not_authenticated", + message: "Not authenticated", + details: { + "x-Api-Key": "Header not provided or API Key invalid", + }, + }); + }); + }); + + test("should use custom cache control header when provided", () => { + const customCache = "no-cache"; + const response = responses.notAuthenticatedResponse(false, customCache); + + expect(response.headers.get("Cache-Control")).toBe(customCache); + }); + }); + + describe("unauthorizedResponse", () => { + test("should return an unauthorized response", () => { + const response = responses.unauthorizedResponse(); + + expect(response.status).toBe(401); + + return response.json().then((body) => { + expect(body).toEqual({ + code: "unauthorized", + message: "You are not authorized to access this resource", + details: {}, + }); + }); + }); + + test("should use custom cache control header when provided", () => { + const customCache = "no-cache"; + const response = responses.unauthorizedResponse(false, customCache); + + expect(response.headers.get("Cache-Control")).toBe(customCache); + }); + }); + + describe("forbiddenResponse", () => { + test("should return a forbidden response", () => { + const message = "Access denied"; + const details = { reason: "insufficient_permissions" }; + const response = responses.forbiddenResponse(message, false, details); + + expect(response.status).toBe(403); + + return response.json().then((body) => { + expect(body).toEqual({ + code: "forbidden", + message, + details, + }); + }); + }); + + test("should handle undefined details", () => { + const message = "Access denied"; + const response = responses.forbiddenResponse(message); + + expect(response.status).toBe(403); + + return response.json().then((body) => { + expect(body).toEqual({ + code: "forbidden", + message, + details: {}, + }); + }); + }); + + test("should use custom cache control header when provided", () => { + const message = "Access denied"; + const customCache = "no-cache"; + const response = responses.forbiddenResponse(message, false, undefined, customCache); + + expect(response.headers.get("Cache-Control")).toBe(customCache); + }); + }); + + describe("tooManyRequestsResponse", () => { + test("should return a too many requests response", () => { + const message = "Rate limit exceeded"; + const response = responses.tooManyRequestsResponse(message); + + expect(response.status).toBe(429); + + return response.json().then((body) => { + expect(body).toEqual({ + code: "too_many_requests", + message, + details: {}, + }); + }); + }); + + test("should use custom cache control header when provided", () => { + const message = "Rate limit exceeded"; + const customCache = "no-cache"; + const response = responses.tooManyRequestsResponse(message, false, customCache); + + expect(response.headers.get("Cache-Control")).toBe(customCache); + }); + }); +}); diff --git a/apps/web/app/lib/api/validator.test.ts b/apps/web/app/lib/api/validator.test.ts new file mode 100644 index 000000000000..c43605248f3c --- /dev/null +++ b/apps/web/app/lib/api/validator.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "vitest"; +import { ZodError, ZodIssueCode } from "zod"; +import { transformErrorToDetails } from "./validator"; + +describe("transformErrorToDetails", () => { + test("should transform ZodError with a single issue to details object", () => { + const error = new ZodError([ + { + code: ZodIssueCode.invalid_type, + expected: "string", + received: "number", + path: ["name"], + message: "Expected string, received number", + }, + ]); + const details = transformErrorToDetails(error); + expect(details).toEqual({ + name: "Expected string, received number", + }); + }); + + test("should transform ZodError with multiple issues to details object", () => { + const error = new ZodError([ + { + code: ZodIssueCode.invalid_type, + expected: "string", + received: "number", + path: ["name"], + message: "Expected string, received number", + }, + { + code: ZodIssueCode.too_small, + minimum: 5, + type: "string", + inclusive: true, + exact: false, + message: "String must contain at least 5 character(s)", + path: ["address", "street"], + }, + ]); + const details = transformErrorToDetails(error); + expect(details).toEqual({ + name: "Expected string, received number", + "address.street": "String must contain at least 5 character(s)", + }); + }); + + test("should return an empty object if ZodError has no issues", () => { + const error = new ZodError([]); + const details = transformErrorToDetails(error); + expect(details).toEqual({}); + }); + + test("should handle issues with empty paths", () => { + const error = new ZodError([ + { + code: ZodIssueCode.custom, + path: [], + message: "Global error", + }, + ]); + const details = transformErrorToDetails(error); + expect(details).toEqual({ + "": "Global error", + }); + }); + + test("should handle issues with multi-level paths", () => { + const error = new ZodError([ + { + code: ZodIssueCode.invalid_type, + expected: "string", + received: "undefined", + path: ["user", "profile", "firstName"], + message: "Required", + }, + ]); + const details = transformErrorToDetails(error); + expect(details).toEqual({ + "user.profile.firstName": "Required", + }); + }); +}); diff --git a/apps/web/app/lib/api/with-api-logging.test.ts b/apps/web/app/lib/api/with-api-logging.test.ts new file mode 100644 index 000000000000..3eed910d58be --- /dev/null +++ b/apps/web/app/lib/api/with-api-logging.test.ts @@ -0,0 +1,543 @@ +import { AuthenticationMethod } from "@/app/middleware/endpoint-validator"; +import * as Sentry from "@sentry/nextjs"; +import { NextRequest } from "next/server"; +import { Mock, beforeEach, describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; +import { TAuthenticationApiKey } from "@formbricks/types/auth"; +import { responses } from "./response"; + +// Mocks +vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ + __esModule: true, + queueAuditEvent: vi.fn(), +})); + +vi.mock("@sentry/nextjs", () => ({ + captureException: vi.fn(), +})); + +// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks. +const mockContextualLoggerError = vi.fn(); +const mockContextualLoggerWarn = vi.fn(); +const mockContextualLoggerInfo = vi.fn(); + +vi.mock("@formbricks/logger", () => { + const mockWithContextInstance = vi.fn(() => ({ + error: mockContextualLoggerError, + warn: mockContextualLoggerWarn, + info: mockContextualLoggerInfo, + })); + return { + logger: { + withContext: mockWithContextInstance, + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, + }; +}); + +vi.mock("@/app/api/v1/auth", () => ({ + authenticateRequest: vi.fn(), +})); + +vi.mock("@/modules/auth/lib/authOptions", () => ({ + authOptions: {}, +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("@/app/middleware/endpoint-validator", async () => { + const original = await vi.importActual("@/app/middleware/endpoint-validator"); + return { + ...original, + isClientSideApiRoute: vi.fn().mockReturnValue({ isClientSideApi: false, isRateLimited: true }), + isManagementApiRoute: vi.fn().mockReturnValue({ isManagementApi: false, authenticationMethod: "apiKey" }), + isIntegrationRoute: vi.fn().mockReturnValue(false), + isSyncWithUserIdentificationEndpoint: vi.fn().mockReturnValue(null), + }; +}); + +vi.mock("@/modules/core/rate-limit/helpers", () => ({ + applyIPRateLimit: vi.fn(), + applyRateLimit: vi.fn(), +})); + +vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({ + rateLimitConfigs: { + api: { + client: { windowMs: 60000, max: 100 }, + v1: { windowMs: 60000, max: 1000 }, + syncUserIdentification: { windowMs: 60000, max: 50 }, + }, + }, +})); + +function createMockRequest({ method = "GET", url = "https://api.test/endpoint", headers = new Map() } = {}) { + // Parse the URL to get the pathname + const parsedUrl = url.startsWith("/") ? new URL(url, "http://localhost:3000") : new URL(url); + + return { + method, + url, + headers: { + get: (key: string) => headers.get(key), + }, + nextUrl: { + pathname: parsedUrl.pathname, + }, + } as unknown as NextRequest; +} + +const mockApiAuthentication = { + hashedApiKey: "test-api-key", + apiKeyId: "api-key-1", + organizationId: "org-1", +} as TAuthenticationApiKey; + +describe("withV1ApiWrapper", () => { + beforeEach(() => { + vi.resetModules(); + + vi.doMock("@/lib/constants", () => ({ + AUDIT_LOG_ENABLED: true, + IS_PRODUCTION: true, + SENTRY_DSN: "dsn", + ENCRYPTION_KEY: "test-key", + REDIS_URL: "redis://localhost:6379", + })); + + vi.clearAllMocks(); + }); + + test("logs and audits on error response with API key authentication", async () => { + const { queueAuditEvent: mockedQueueAuditEvent } = (await import( + "@/modules/ee/audit-logs/lib/handler" + )) as unknown as { queueAuditEvent: Mock }; + const { authenticateRequest } = await import("@/app/api/v1/auth"); + const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( + "@/app/middleware/endpoint-validator" + ); + + vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); + vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); + vi.mocked(isManagementApiRoute).mockReturnValue({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + vi.mocked(isIntegrationRoute).mockReturnValue(false); + + const handler = vi.fn().mockImplementation(async ({ auditLog }) => { + if (auditLog) { + auditLog.targetId = "target-1"; + } + return { + response: responses.internalServerErrorResponse("fail"), + }; + }); + + const req = createMockRequest({ + url: "https://api.test/v1/management/surveys", + headers: new Map([["x-request-id", "abc-123"]]), + }); + const { withV1ApiWrapper } = await import("./with-api-logging"); + const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" }); + await wrapped(req, undefined); + + expect(logger.withContext).toHaveBeenCalled(); + expect(mockContextualLoggerError).toHaveBeenCalled(); + expect(mockedQueueAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "abc-123", + userType: "api", + apiUrl: req.url, + action: "created", + status: "failure", + targetType: "survey", + userId: "api-key-1", + targetId: "target-1", + organizationId: "org-1", + }) + ); + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ extra: expect.objectContaining({ correlationId: "abc-123" }) }) + ); + }); + + test("does not log Sentry if not 500", async () => { + const { queueAuditEvent: mockedQueueAuditEvent } = (await import( + "@/modules/ee/audit-logs/lib/handler" + )) as unknown as { queueAuditEvent: Mock }; + const { authenticateRequest } = await import("@/app/api/v1/auth"); + const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( + "@/app/middleware/endpoint-validator" + ); + + vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); + vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); + vi.mocked(isManagementApiRoute).mockReturnValue({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + vi.mocked(isIntegrationRoute).mockReturnValue(false); + + const handler = vi.fn().mockImplementation(async ({ auditLog }) => { + if (auditLog) { + auditLog.targetId = "target-1"; + } + return { + response: responses.badRequestResponse("bad req"), + }; + }); + + const req = createMockRequest({ url: "https://api.test/v1/management/surveys" }); + const { withV1ApiWrapper } = await import("./with-api-logging"); + const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" }); + await wrapped(req, undefined); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + expect(logger.withContext).toHaveBeenCalled(); + expect(mockContextualLoggerError).toHaveBeenCalled(); + expect(mockedQueueAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + userType: "api", + apiUrl: req.url, + action: "created", + status: "failure", + targetType: "survey", + userId: "api-key-1", + targetId: "target-1", + organizationId: "org-1", + }) + ); + }); + + test("logs and audits on thrown error", async () => { + const { queueAuditEvent: mockedQueueAuditEvent } = (await import( + "@/modules/ee/audit-logs/lib/handler" + )) as unknown as { queueAuditEvent: Mock }; + const { authenticateRequest } = await import("@/app/api/v1/auth"); + const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( + "@/app/middleware/endpoint-validator" + ); + + vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); + vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); + vi.mocked(isManagementApiRoute).mockReturnValue({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + vi.mocked(isIntegrationRoute).mockReturnValue(false); + + const handler = vi.fn().mockImplementation(async ({ auditLog }) => { + if (auditLog) { + auditLog.targetId = "target-1"; + } + throw new Error("fail!"); + }); + + const req = createMockRequest({ + url: "https://api.test/v1/management/surveys", + headers: new Map([["x-request-id", "err-1"]]), + }); + const { withV1ApiWrapper } = await import("./with-api-logging"); + const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" }); + const res = await wrapped(req, undefined); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body).toEqual({ + code: "internal_server_error", + message: "An unexpected error occurred.", + details: {}, + }); + expect(logger.withContext).toHaveBeenCalled(); + expect(mockContextualLoggerError).toHaveBeenCalled(); + expect(mockedQueueAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "err-1", + userType: "api", + apiUrl: req.url, + action: "created", + status: "failure", + targetType: "survey", + userId: "api-key-1", + targetId: "target-1", + organizationId: "org-1", + }) + ); + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ extra: expect.objectContaining({ correlationId: "err-1" }) }) + ); + }); + + test("does not log on success response but still audits", async () => { + const { queueAuditEvent: mockedQueueAuditEvent } = (await import( + "@/modules/ee/audit-logs/lib/handler" + )) as unknown as { queueAuditEvent: Mock }; + const { authenticateRequest } = await import("@/app/api/v1/auth"); + const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( + "@/app/middleware/endpoint-validator" + ); + + vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); + vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); + vi.mocked(isManagementApiRoute).mockReturnValue({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + vi.mocked(isIntegrationRoute).mockReturnValue(false); + + const handler = vi.fn().mockImplementation(async ({ auditLog }) => { + if (auditLog) { + auditLog.targetId = "target-1"; + } + return { + response: responses.successResponse({ ok: true }), + }; + }); + + const req = createMockRequest({ url: "https://api.test/v1/management/surveys" }); + const { withV1ApiWrapper } = await import("./with-api-logging"); + const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" }); + await wrapped(req, undefined); + + expect(logger.withContext).not.toHaveBeenCalled(); + expect(mockContextualLoggerError).not.toHaveBeenCalled(); + expect(mockedQueueAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + userType: "api", + apiUrl: req.url, + action: "created", + status: "success", + targetType: "survey", + userId: "api-key-1", + targetId: "target-1", + organizationId: "org-1", + }) + ); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + test("does not call audit if AUDIT_LOG_ENABLED is false", async () => { + vi.doMock("@/lib/constants", () => ({ + AUDIT_LOG_ENABLED: false, + IS_PRODUCTION: true, + SENTRY_DSN: "dsn", + ENCRYPTION_KEY: "test-key", + REDIS_URL: "redis://localhost:6379", + })); + + const { queueAuditEvent: mockedQueueAuditEvent } = (await import( + "@/modules/ee/audit-logs/lib/handler" + )) as unknown as { queueAuditEvent: Mock }; + const { authenticateRequest } = await import("@/app/api/v1/auth"); + const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( + "@/app/middleware/endpoint-validator" + ); + const { withV1ApiWrapper } = await import("./with-api-logging"); + + vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); + vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); + vi.mocked(isManagementApiRoute).mockReturnValue({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + vi.mocked(isIntegrationRoute).mockReturnValue(false); + + const handler = vi.fn().mockResolvedValue({ + response: responses.internalServerErrorResponse("fail"), + }); + + const req = createMockRequest({ url: "https://api.test/v1/management/surveys" }); + const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" }); + await wrapped(req, undefined); + + expect(mockedQueueAuditEvent).not.toHaveBeenCalled(); + }); + + test("handles client-side API routes without authentication", async () => { + const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( + "@/app/middleware/endpoint-validator" + ); + const { authenticateRequest } = await import("@/app/api/v1/auth"); + const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers"); + + vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true }); + vi.mocked(isManagementApiRoute).mockReturnValue({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.None, + }); + vi.mocked(isIntegrationRoute).mockReturnValue(false); + vi.mocked(authenticateRequest).mockResolvedValue(null); + vi.mocked(applyIPRateLimit).mockResolvedValue(undefined); + + const handler = vi.fn().mockResolvedValue({ + response: responses.successResponse({ data: "test" }), + }); + + const req = createMockRequest({ url: "/api/v1/client/displays" }); + const { withV1ApiWrapper } = await import("./with-api-logging"); + const wrapped = withV1ApiWrapper({ handler }); + const res = await wrapped(req, undefined); + + expect(res.status).toBe(200); + expect(handler).toHaveBeenCalledWith({ + req, + props: undefined, + auditLog: undefined, + authentication: null, + }); + }); + + test("returns authentication error for non-client routes without auth", async () => { + const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( + "@/app/middleware/endpoint-validator" + ); + const { authenticateRequest } = await import("@/app/api/v1/auth"); + + vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); + vi.mocked(isManagementApiRoute).mockReturnValue({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + vi.mocked(isIntegrationRoute).mockReturnValue(false); + vi.mocked(authenticateRequest).mockResolvedValue(null); + + const handler = vi.fn(); + const req = createMockRequest({ url: "https://api.test/v1/management/surveys" }); + const { withV1ApiWrapper } = await import("./with-api-logging"); + const wrapped = withV1ApiWrapper({ handler }); + const res = await wrapped(req, undefined); + + expect(res.status).toBe(401); + expect(handler).not.toHaveBeenCalled(); + }); + + test("handles rate limiting errors", async () => { + const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers"); + const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( + "@/app/middleware/endpoint-validator" + ); + const { authenticateRequest } = await import("@/app/api/v1/auth"); + + vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); + vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); + vi.mocked(isManagementApiRoute).mockReturnValue({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + vi.mocked(isIntegrationRoute).mockReturnValue(false); + const rateLimitError = new Error("Rate limit exceeded"); + rateLimitError.message = "Rate limit exceeded"; + vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError); + + const handler = vi.fn(); + const req = createMockRequest({ url: "https://api.test/v1/management/surveys" }); + const { withV1ApiWrapper } = await import("./with-api-logging"); + const wrapped = withV1ApiWrapper({ handler }); + const res = await wrapped(req, undefined); + + expect(res.status).toBe(429); + expect(handler).not.toHaveBeenCalled(); + }); + + test("handles sync user identification rate limiting", async () => { + const { applyRateLimit, applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers"); + const { + isClientSideApiRoute, + isManagementApiRoute, + isIntegrationRoute, + isSyncWithUserIdentificationEndpoint, + } = await import("@/app/middleware/endpoint-validator"); + const { authenticateRequest } = await import("@/app/api/v1/auth"); + + vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true }); + vi.mocked(isManagementApiRoute).mockReturnValue({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.None, + }); + vi.mocked(isIntegrationRoute).mockReturnValue(false); + vi.mocked(isSyncWithUserIdentificationEndpoint).mockReturnValue({ + userId: "user-123", + environmentId: "env-123", + }); + vi.mocked(authenticateRequest).mockResolvedValue(null); + vi.mocked(applyIPRateLimit).mockResolvedValue(undefined); + const rateLimitError = new Error("Sync rate limit exceeded"); + rateLimitError.message = "Sync rate limit exceeded"; + vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError); + + const handler = vi.fn(); + const req = createMockRequest({ url: "/api/v1/client/env-123/app/sync/user-123" }); + const { withV1ApiWrapper } = await import("./with-api-logging"); + const wrapped = withV1ApiWrapper({ handler }); + const res = await wrapped(req, undefined); + + expect(res.status).toBe(429); + expect(applyRateLimit).toHaveBeenCalledWith( + expect.objectContaining({ windowMs: 60000, max: 50 }), + "user-123" + ); + }); + + test("skips audit log creation when no action/targetType provided", async () => { + const { queueAuditEvent: mockedQueueAuditEvent } = (await import( + "@/modules/ee/audit-logs/lib/handler" + )) as unknown as { queueAuditEvent: Mock }; + const { authenticateRequest } = await import("@/app/api/v1/auth"); + const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import( + "@/app/middleware/endpoint-validator" + ); + + vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication); + vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true }); + vi.mocked(isManagementApiRoute).mockReturnValue({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + vi.mocked(isIntegrationRoute).mockReturnValue(false); + + const handler = vi.fn().mockResolvedValue({ + response: responses.successResponse({ data: "test" }), + }); + + const req = createMockRequest({ url: "https://api.test/v1/management/surveys" }); + const { withV1ApiWrapper } = await import("./with-api-logging"); + const wrapped = withV1ApiWrapper({ handler }); + await wrapped(req, undefined); + + expect(handler).toHaveBeenCalledWith({ + req, + props: undefined, + auditLog: undefined, + authentication: mockApiAuthentication, + }); + expect(mockedQueueAuditEvent).not.toHaveBeenCalled(); + }); +}); + +describe("buildAuditLogBaseObject", () => { + test("creates audit log base object with correct structure", async () => { + const { buildAuditLogBaseObject } = await import("./with-api-logging"); + + const result = buildAuditLogBaseObject("created", "survey", "https://api.test/v1/management/surveys"); + + expect(result).toEqual({ + action: "created", + targetType: "survey", + userId: "unknown", + targetId: "unknown", + organizationId: "unknown", + status: "failure", + oldObject: undefined, + newObject: undefined, + userType: "api", + apiUrl: "https://api.test/v1/management/surveys", + }); + }); +}); diff --git a/apps/web/app/lib/api/with-api-logging.ts b/apps/web/app/lib/api/with-api-logging.ts new file mode 100644 index 000000000000..b6ab7c75c55e --- /dev/null +++ b/apps/web/app/lib/api/with-api-logging.ts @@ -0,0 +1,347 @@ +import { authenticateRequest } from "@/app/api/v1/auth"; +import { responses } from "@/app/lib/api/response"; +import { + AuthenticationMethod, + isClientSideApiRoute, + isIntegrationRoute, + isManagementApiRoute, + isSyncWithUserIdentificationEndpoint, +} from "@/app/middleware/endpoint-validator"; +import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants"; +import { authOptions } from "@/modules/auth/lib/authOptions"; +import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler"; +import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; +import * as Sentry from "@sentry/nextjs"; +import { Session, getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { logger } from "@formbricks/logger"; +import { TAuthenticationApiKey } from "@formbricks/types/auth"; + +export type TApiAuditLog = Parameters[0]; +export type TApiV1Authentication = TAuthenticationApiKey | Session | null; +export type TApiKeyAuthentication = TAuthenticationApiKey | null; +export type TSessionAuthentication = Session | null; + +// Interface for handler function parameters +export interface THandlerParams { + req?: NextRequest; + props?: TProps; + auditLog?: TApiAuditLog; + authentication?: TApiKeyAuthentication | TSessionAuthentication; +} + +// Interface for wrapper function parameters +export interface TWithV1ApiWrapperParams { + handler: (params: THandlerParams) => Promise; + action?: TAuditAction; + targetType?: TAuditTarget; +} + +enum ApiV1RouteTypeEnum { + Client = "client", + General = "general", + Integration = "integration", +} + +/** + * Apply client-side API rate limiting (IP-based or sync-specific) + */ +const applyClientRateLimit = async (url: string): Promise => { + const syncEndpoint = isSyncWithUserIdentificationEndpoint(url); + if (syncEndpoint) { + const syncRateLimitConfig = rateLimitConfigs.api.syncUserIdentification; + await applyRateLimit(syncRateLimitConfig, syncEndpoint.userId); + } else { + await applyIPRateLimit(rateLimitConfigs.api.client); + } +}; + +/** + * Handle rate limiting based on authentication and API type + */ +const handleRateLimiting = async ( + url: string, + authentication: TApiV1Authentication, + routeType: ApiV1RouteTypeEnum +): Promise => { + try { + if (authentication) { + if ("user" in authentication) { + // Session-based authentication for integration routes + await applyRateLimit(rateLimitConfigs.api.v1, authentication.user.id); + } else if ("hashedApiKey" in authentication) { + // API key authentication for general routes + await applyRateLimit(rateLimitConfigs.api.v1, authentication.hashedApiKey); + } else { + logger.error({ authentication }, "Unknown authentication type"); + return responses.internalServerErrorResponse("Invalid authentication configuration"); + } + } + + if (routeType === ApiV1RouteTypeEnum.Client) { + await applyClientRateLimit(url); + } + } catch (error) { + return responses.tooManyRequestsResponse(error.message); + } + + return null; +}; + +/** + * Execute handler with error handling + */ +const executeHandler = async ( + handler: (params: THandlerParams) => Promise, + req: NextRequest, + props: TProps, + auditLog: TApiAuditLog | undefined, + authentication: TApiV1Authentication +): Promise<{ result: TResult; error?: unknown }> => { + try { + const result = await handler({ req, props, auditLog, authentication }); + return { result }; + } catch (err) { + const result = { + response: responses.internalServerErrorResponse("An unexpected error occurred."), + } as TResult; + return { result, error: err }; + } +}; + +/** + * Set up audit log with authentication details + */ +const setupAuditLog = ( + authentication: TApiV1Authentication, + auditLog: TApiAuditLog | undefined, + routeType: ApiV1RouteTypeEnum +): void => { + if ( + authentication && + auditLog && + routeType === ApiV1RouteTypeEnum.General && + "apiKeyId" in authentication + ) { + auditLog.userId = authentication.apiKeyId; + auditLog.organizationId = authentication.organizationId; + } + + if (authentication && auditLog && "user" in authentication) { + auditLog.userId = authentication.user.id; + } +}; + +/** + * Handle authentication based on method + */ +const handleAuthentication = async ( + authenticationMethod: AuthenticationMethod, + req: NextRequest +): Promise => { + switch (authenticationMethod) { + case AuthenticationMethod.ApiKey: + return await authenticateRequest(req); + case AuthenticationMethod.Session: + return await getServerSession(authOptions); + case AuthenticationMethod.Both: { + const session = await getServerSession(authOptions); + return session ?? (await authenticateRequest(req)); + } + case AuthenticationMethod.None: + return null; + } +}; + +/** + * Log error details to system logger and Sentry + */ +const logErrorDetails = (res: Response, req: NextRequest, correlationId: string, error?: any): void => { + const logContext = { + correlationId, + method: req.method, + path: req.url, + status: res.status, + ...(error && { error }), + }; + + logger.withContext(logContext).error("V1 API Error Details"); + + if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) { + const err = new Error(`API V1 error, id: ${correlationId}`); + Sentry.captureException(err, { extra: { error, correlationId } }); + } +}; + +/** + * Handle response processing and logging + */ +const processResponse = async ( + res: Response, + req: NextRequest, + auditLog?: TApiAuditLog, + error?: any +): Promise => { + const correlationId = req.headers.get("x-request-id") ?? ""; + + // Handle audit logging + if (auditLog) { + if (res.ok) { + auditLog.status = "success"; + } else { + auditLog.eventId = correlationId; + } + } + + // Handle error logging + if (!res.ok) { + logErrorDetails(res, req, correlationId, error); + } + + // Queue audit event if enabled and audit log exists + if (AUDIT_LOG_ENABLED && auditLog) { + queueAuditEvent(auditLog); + } +}; + +const getRouteType = ( + req: NextRequest +): { routeType: ApiV1RouteTypeEnum; isRateLimited: boolean; authenticationMethod: AuthenticationMethod } => { + const pathname = req.nextUrl.pathname; + + const { isClientSideApi, isRateLimited } = isClientSideApiRoute(pathname); + const { isManagementApi, authenticationMethod } = isManagementApiRoute(pathname); + const isIntegration = isIntegrationRoute(pathname); + + if (isClientSideApi) + return { + routeType: ApiV1RouteTypeEnum.Client, + isRateLimited, + authenticationMethod: AuthenticationMethod.None, + }; + if (isManagementApi) + return { routeType: ApiV1RouteTypeEnum.General, isRateLimited: true, authenticationMethod }; + if (isIntegration) + return { + routeType: ApiV1RouteTypeEnum.Integration, + isRateLimited: true, + authenticationMethod: AuthenticationMethod.Session, + }; + + throw new Error(`Unknown route type: ${pathname}`); +}; + +/** + * withV1ApiWrapper wraps a V1 API handler to provide unified authentication, rate limiting, and optional audit/system logging. + * + * Features: + * - Performs authentication once and passes result to handler + * - Applies API key-based rate limiting with differentiated limits for client vs management APIs + * - Includes additional sync user identification rate limiting for client-side sync endpoints + * - Sets userId and organizationId in audit log automatically when audit logging is enabled + * - System and Sentry logs are always called for non-success responses + * - Uses function overloads to provide type safety without requiring type guards + * + * @param params - Configuration object for the wrapper + * @param params.handler - The API handler function that processes the request, receives an object with: + * - req: The incoming HTTP request object + * - props: Optional route parameters (e.g., { params: { id: string } }) + * - auditLog: Optional audit log object for tracking API actions (only present when action/targetType provided) + * - authentication: Authentication result (type determined by route - API key for general, session for integration) + * @param params.action - Optional audit action type (e.g., "created", "updated", "deleted"). Required for audit logging + * @param params.targetType - Optional audit target type (e.g., "webhook", "survey", "response"). Required for audit logging + * @returns Wrapped handler function that returns the final HTTP response + * + */ +export const withV1ApiWrapper: { + ( + params: TWithV1ApiWrapperParams & { + handler: ( + params: THandlerParams & { authentication?: TApiKeyAuthentication } + ) => Promise; + } + ): (req: NextRequest, props: TProps) => Promise; + + ( + params: TWithV1ApiWrapperParams & { + handler: ( + params: THandlerParams & { authentication?: TSessionAuthentication } + ) => Promise; + } + ): (req: NextRequest, props: TProps) => Promise; + + ( + params: TWithV1ApiWrapperParams & { + handler: ( + params: THandlerParams & { authentication?: TApiV1Authentication } + ) => Promise; + } + ): (req: NextRequest, props: TProps) => Promise; +} = ( + params: TWithV1ApiWrapperParams +): ((req: NextRequest, props: TProps) => Promise) => { + const { handler, action, targetType } = params; + return async (req: NextRequest, props: TProps): Promise => { + // === Audit Log Setup === + const saveAuditLog = action && targetType; + const auditLog = saveAuditLog ? buildAuditLogBaseObject(action, targetType, req.url) : undefined; + + let routeType: ApiV1RouteTypeEnum; + let isRateLimited: boolean; + let authenticationMethod: AuthenticationMethod; + + // === Route Classification === + try { + ({ routeType, isRateLimited, authenticationMethod } = getRouteType(req)); + } catch (error) { + logger.error({ error }, "Error getting route type"); + return responses.internalServerErrorResponse("An unexpected error occurred."); + } + + // === Authentication === + const authentication = await handleAuthentication(authenticationMethod, req); + + if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) { + return responses.notAuthenticatedResponse(); + } + + // === Audit Log Enhancement === + setupAuditLog(authentication, auditLog, routeType); + + // === Rate Limiting === + if (isRateLimited) { + const rateLimitResponse = await handleRateLimiting(req.nextUrl.pathname, authentication, routeType); + if (rateLimitResponse) return rateLimitResponse; + } + + // === Handler Execution === + const { result, error } = await executeHandler(handler, req, props, auditLog, authentication); + const res = result.response; + + // === Response Processing & Logging === + await processResponse(res, req, auditLog, error); + + return res; + }; +}; + +export const buildAuditLogBaseObject = ( + action: TAuditAction, + targetType: TAuditTarget, + apiUrl: string +): TApiAuditLog => { + return { + action, + targetType, + userId: UNKNOWN_DATA, + targetId: UNKNOWN_DATA, + organizationId: UNKNOWN_DATA, + status: "failure", + oldObject: undefined, + newObject: undefined, + userType: "api", + apiUrl, + }; +}; diff --git a/apps/web/app/lib/fileUpload.test.ts b/apps/web/app/lib/fileUpload.test.ts new file mode 100644 index 000000000000..2bf8b049be12 --- /dev/null +++ b/apps/web/app/lib/fileUpload.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import * as fileUploadModule from "./fileUpload"; + +// Mock global fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +const mockAtoB = vi.fn(); +global.atob = mockAtoB; + +// Mock FileReader +const mockFileReader = { + readAsDataURL: vi.fn(), + result: "data:image/jpeg;base64,test", + onload: null as any, + onerror: null as any, +}; + +// Mock File object +const createMockFile = (name: string, type: string, size: number) => { + const file = new File([], name, { type }); + Object.defineProperty(file, "size", { + value: size, + writable: false, + }); + return file; +}; + +describe("fileUpload", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Mock FileReader + global.FileReader = vi.fn(() => mockFileReader) as any; + global.atob = (base64) => Buffer.from(base64, "base64").toString("binary"); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should return error when no file is provided", async () => { + const result = await fileUploadModule.handleFileUpload(null as any, "test-env"); + expect(result.error).toBe(fileUploadModule.FileUploadError.NO_FILE); + expect(result.url).toBe(""); + }); + + test("should return error when file is not an image", async () => { + const file = createMockFile("test.pdf", "application/pdf", 1000); + const result = await fileUploadModule.handleFileUpload(file, "test-env"); + expect(result.error).toBe("Please upload an image file."); + expect(result.url).toBe(""); + }); + + test("should return FILE_SIZE_EXCEEDED if arrayBuffer is > 10MB even if file.size is OK", async () => { + const file = createMockFile("test.jpg", "image/jpeg", 1000); // file.size = 1KB + + // Mock arrayBuffer to return >10MB buffer + file.arrayBuffer = vi.fn().mockResolvedValueOnce(new ArrayBuffer(11 * 1024 * 1024)); // 11MB + + const result = await fileUploadModule.handleFileUpload(file, "env-oversize-buffer"); + + expect(result.error).toBe(fileUploadModule.FileUploadError.FILE_SIZE_EXCEEDED); + expect(result.url).toBe(""); + }); + + test("should handle API error when getting signed URL", async () => { + const file = createMockFile("test.jpg", "image/jpeg", 1000); + + // Mock failed API response + mockFetch.mockResolvedValueOnce({ + ok: false, + }); + + const result = await fileUploadModule.handleFileUpload(file, "test-env"); + expect(result.error).toBe("Upload failed. Please try again."); + expect(result.url).toBe(""); + }); + + test("should handle successful file upload with presigned fields", async () => { + const file = createMockFile("test.jpg", "image/jpeg", 1000); + + // Mock successful API response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + signedUrl: "https://s3.example.com/upload", + fileUrl: "https://s3.example.com/file.jpg", + presignedFields: { + key: "value", + }, + }, + }), + }); + + // Mock successful upload response + mockFetch.mockResolvedValueOnce({ + ok: true, + }); + + // Simulate FileReader onload + setTimeout(() => { + mockFileReader.onload(); + }, 0); + + const result = await fileUploadModule.handleFileUpload(file, "test-env"); + expect(result.error).toBeUndefined(); + expect(result.url).toBe("https://s3.example.com/file.jpg"); + }); + + test("should handle successful file upload without presigned fields", async () => { + const file = createMockFile("test.jpg", "image/jpeg", 1000); + + // Mock successful API response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + signedUrl: "https://s3.example.com/upload", + fileUrl: "https://s3.example.com/file.jpg", + signingData: { + signature: "test-signature", + timestamp: 1234567890, + uuid: "test-uuid", + }, + }, + }), + }); + + // Mock successful upload response + mockFetch.mockResolvedValueOnce({ + ok: true, + }); + + // Simulate FileReader onload + setTimeout(() => { + mockFileReader.onload(); + }, 0); + + const result = await fileUploadModule.handleFileUpload(file, "test-env"); + expect(result.error).toBeUndefined(); + expect(result.url).toBe("https://s3.example.com/file.jpg"); + }); + + test("should handle upload error with presigned fields", async () => { + const file = createMockFile("test.jpg", "image/jpeg", 1000); + // Mock successful API response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + signedUrl: "https://s3.example.com/upload", + fileUrl: "https://s3.example.com/file.jpg", + presignedFields: { + key: "value", + }, + }, + }), + }); + + global.atob = vi.fn(() => { + throw new Error("Failed to decode base64 string"); + }); + + // Simulate FileReader onload + setTimeout(() => { + mockFileReader.onload(); + }, 0); + + const result = await fileUploadModule.handleFileUpload(file, "test-env"); + expect(result.error).toBe("Upload failed. Please try again."); + expect(result.url).toBe(""); + }); + + test("should handle upload error", async () => { + const file = createMockFile("test.jpg", "image/jpeg", 1000); + + // Mock successful API response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + signedUrl: "https://s3.example.com/upload", + fileUrl: "https://s3.example.com/file.jpg", + presignedFields: { + key: "value", + }, + }, + }), + }); + + // Mock failed upload response + mockFetch.mockResolvedValueOnce({ + ok: false, + }); + + // Simulate FileReader onload + setTimeout(() => { + mockFileReader.onload(); + }, 0); + + const result = await fileUploadModule.handleFileUpload(file, "test-env"); + expect(result.error).toBe("Upload failed. Please try again."); + expect(result.url).toBe(""); + }); + + test("should catch unexpected errors and return UPLOAD_FAILED", async () => { + const file = createMockFile("test.jpg", "image/jpeg", 1000); + + // Force arrayBuffer() to throw + file.arrayBuffer = vi.fn().mockImplementation(() => { + throw new Error("Unexpected crash in arrayBuffer"); + }); + + const result = await fileUploadModule.handleFileUpload(file, "env-crash"); + + expect(result.error).toBe(fileUploadModule.FileUploadError.UPLOAD_FAILED); + expect(result.url).toBe(""); + }); +}); + +describe("fileUploadModule.toBase64", () => { + test("resolves with base64 string when FileReader succeeds", async () => { + const dummyFile = new File(["hello"], "hello.txt", { type: "text/plain" }); + + // Mock FileReader + const mockReadAsDataURL = vi.fn(); + const mockFileReaderInstance = { + readAsDataURL: mockReadAsDataURL, + onload: null as ((this: FileReader, ev: ProgressEvent) => any) | null, + onerror: null, + result: "data:text/plain;base64,aGVsbG8=", + }; + + globalThis.FileReader = vi.fn(() => mockFileReaderInstance as unknown as FileReader) as any; + + const promise = fileUploadModule.toBase64(dummyFile); + + // Trigger the onload manually + mockFileReaderInstance.onload?.call(mockFileReaderInstance as unknown as FileReader, new Error("load")); + + const result = await promise; + expect(result).toBe("data:text/plain;base64,aGVsbG8="); + }); + + test("rejects when FileReader errors", async () => { + const dummyFile = new File(["oops"], "oops.txt", { type: "text/plain" }); + + const mockReadAsDataURL = vi.fn(); + const mockFileReaderInstance = { + readAsDataURL: mockReadAsDataURL, + onload: null, + onerror: null as ((this: FileReader, ev: ProgressEvent) => any) | null, + result: null, + }; + + globalThis.FileReader = vi.fn(() => mockFileReaderInstance as unknown as FileReader) as any; + + const promise = fileUploadModule.toBase64(dummyFile); + + // Simulate error + mockFileReaderInstance.onerror?.call(mockFileReaderInstance as unknown as FileReader, new Error("error")); + + await expect(promise).rejects.toThrow(); + }); +}); diff --git a/apps/web/app/lib/fileUpload.ts b/apps/web/app/lib/fileUpload.ts index 7d9913ec4c6c..007ee428477a 100644 --- a/apps/web/app/lib/fileUpload.ts +++ b/apps/web/app/lib/fileUpload.ts @@ -1,90 +1,146 @@ +export enum FileUploadError { + NO_FILE = "No file provided or invalid file type. Expected a File or Blob.", + INVALID_FILE_TYPE = "Please upload an image file.", + FILE_SIZE_EXCEEDED = "File size must be less than 10 MB.", + UPLOAD_FAILED = "Upload failed. Please try again.", +} + +export const toBase64 = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + resolve(reader.result); + }; + reader.onerror = reject; + }); + export const handleFileUpload = async ( file: File, - environmentId: string + environmentId: string, + allowedFileExtensions?: string[] ): Promise<{ - error?: string; + error?: FileUploadError; url: string; }> => { - if (!file) return { error: "No file provided", url: "" }; - - if (!file.type.startsWith("image/")) { - return { error: "Please upload an image file.", url: "" }; - } - - if (file.size > 10 * 1024 * 1024) { - return { - error: "File size must be less than 10 MB.", - url: "", - }; - } - - const payload = { - fileName: file.name, - fileType: file.type, - environmentId, - }; - - const response = await fetch("/api/v1/management/storage", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - // throw new Error(`Upload failed with status: ${response.status}`); - return { - error: "Upload failed. Please try again.", - url: "", + try { + if (!(file instanceof File)) { + return { + error: FileUploadError.NO_FILE, + url: "", + }; + } + + if (!file.type.startsWith("image/")) { + return { error: FileUploadError.INVALID_FILE_TYPE, url: "" }; + } + + const fileBuffer = await file.arrayBuffer(); + + const bufferBytes = fileBuffer.byteLength; + const bufferKB = bufferBytes / 1024; + + if (bufferKB > 10240) { + return { + error: FileUploadError.FILE_SIZE_EXCEEDED, + url: "", + }; + } + + const payload = { + fileName: file.name, + fileType: file.type, + allowedFileExtensions, + environmentId, }; - } - - const json = await response.json(); - - const { data } = json; - const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data; - - let requestHeaders: Record = {}; - - if (signingData) { - const { signature, timestamp, uuid } = signingData; - - requestHeaders = { - "X-File-Type": file.type, - "X-File-Name": encodeURIComponent(updatedFileName), - "X-Environment-ID": environmentId ?? "", - "X-Signature": signature, - "X-Timestamp": String(timestamp), - "X-UUID": uuid, - }; - } - const formData = new FormData(); - - if (presignedFields) { - Object.keys(presignedFields).forEach((key) => { - formData.append(key, presignedFields[key]); + const response = await fetch("/api/v1/management/storage", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), }); - } - // Add the actual file to be uploaded - formData.append("file", file); + if (!response.ok) { + return { + error: FileUploadError.UPLOAD_FAILED, + url: "", + }; + } + + const json = await response.json(); + const { data } = json; + + const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data; + + let localUploadDetails: Record = {}; + + if (signingData) { + const { signature, timestamp, uuid } = signingData; + + localUploadDetails = { + fileType: file.type, + fileName: encodeURIComponent(updatedFileName), + environmentId, + signature, + timestamp: String(timestamp), + uuid, + }; + } + + const fileBase64 = (await toBase64(file)) as string; + + const formData: Record = {}; + const formDataForS3 = new FormData(); + + if (presignedFields) { + Object.entries(presignedFields as Record).forEach(([key, value]) => { + formDataForS3.append(key, value); + }); + + try { + const binaryString = atob(fileBase64.split(",")[1]); + const uint8Array = Uint8Array.from([...binaryString].map((char) => char.charCodeAt(0))); + const blob = new Blob([uint8Array], { type: file.type }); + + formDataForS3.append("file", blob); + } catch (err) { + console.error(err); + return { + error: FileUploadError.UPLOAD_FAILED, + url: "", + }; + } + } + + formData.fileBase64String = fileBase64; + + const uploadResponse = await fetch(signedUrl, { + method: "POST", + body: presignedFields + ? formDataForS3 + : JSON.stringify({ + ...formData, + ...localUploadDetails, + }), + }); - const uploadResponse = await fetch(signedUrl, { - method: "POST", - ...(signingData ? { headers: requestHeaders } : {}), - body: formData, - }); + if (!uploadResponse.ok) { + return { + error: FileUploadError.UPLOAD_FAILED, + url: "", + }; + } - if (!uploadResponse.ok) { return { - error: "Upload failed. Please try again.", + url: fileUrl, + }; + } catch (error) { + console.error("Error in uploading file: ", error); + return { + error: FileUploadError.UPLOAD_FAILED, url: "", }; } - - return { - url: fileUrl, - }; }; diff --git a/apps/web/app/lib/formbricks.ts b/apps/web/app/lib/formbricks.ts deleted file mode 100644 index b1838e5f3c2d..000000000000 --- a/apps/web/app/lib/formbricks.ts +++ /dev/null @@ -1,11 +0,0 @@ -import formbricks from "@formbricks/js"; -import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage"; - -export const formbricksLogout = async () => { - const loggedInWith = localStorage.getItem(FORMBRICKS_LOGGED_IN_WITH_LS); - localStorage.clear(); - if (loggedInWith) { - localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, loggedInWith); - } - return await formbricks.logout(); -}; diff --git a/apps/web/app/lib/pipelines.test.ts b/apps/web/app/lib/pipelines.test.ts index 306a4260d5f0..73e0f9bee74c 100644 --- a/apps/web/app/lib/pipelines.test.ts +++ b/apps/web/app/lib/pipelines.test.ts @@ -6,7 +6,7 @@ import { TResponse } from "@formbricks/types/responses"; import { sendToPipeline } from "./pipelines"; // Mock the constants module -vi.mock("@formbricks/lib/constants", () => ({ +vi.mock("@/lib/constants", () => ({ CRON_SECRET: "mocked-cron-secret", WEBAPP_URL: "https://test.formbricks.com", })); @@ -91,10 +91,10 @@ describe("pipelines", () => { test("sendToPipeline should throw error if CRON_SECRET is not set", async () => { // For this test, we need to mock CRON_SECRET as undefined // Let's use a more compatible approach to reset the mocks - const originalModule = await import("@formbricks/lib/constants"); + const originalModule = await import("@/lib/constants"); const mockConstants = { ...originalModule, CRON_SECRET: undefined }; - vi.doMock("@formbricks/lib/constants", () => mockConstants); + vi.doMock("@/lib/constants", () => mockConstants); // Re-import the module to get the new mocked values const { sendToPipeline: sendToPipelineNoSecret } = await import("./pipelines"); diff --git a/apps/web/app/lib/pipelines.ts b/apps/web/app/lib/pipelines.ts index d1f040efa204..b80bf59ef718 100644 --- a/apps/web/app/lib/pipelines.ts +++ b/apps/web/app/lib/pipelines.ts @@ -1,5 +1,5 @@ import { TPipelineInput } from "@/app/lib/types/pipelines"; -import { CRON_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; +import { CRON_SECRET, WEBAPP_URL } from "@/lib/constants"; import { logger } from "@formbricks/logger"; export const sendToPipeline = async ({ event, surveyId, environmentId, response }: TPipelineInput) => { diff --git a/apps/web/app/lib/singleUseSurveys.test.ts b/apps/web/app/lib/singleUseSurveys.test.ts index c941c135d444..b9505ce72c3a 100644 --- a/apps/web/app/lib/singleUseSurveys.test.ts +++ b/apps/web/app/lib/singleUseSurveys.test.ts @@ -1,19 +1,17 @@ +import * as crypto from "@/lib/crypto"; import cuid2 from "@paralleldrive/cuid2"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as crypto from "@formbricks/lib/crypto"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUseSurveys"; // Mock the crypto module -vi.mock("@formbricks/lib/crypto", () => ({ +vi.mock("@/lib/crypto", () => ({ symmetricEncrypt: vi.fn(), symmetricDecrypt: vi.fn(), - decryptAES128: vi.fn(), })); // Mock constants -vi.mock("@formbricks/lib/constants", () => ({ +vi.mock("@/lib/constants", () => ({ ENCRYPTION_KEY: "test-encryption-key", - FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key", })); // Mock cuid2 @@ -45,21 +43,21 @@ describe("generateSurveySingleUseId", () => { vi.resetAllMocks(); }); - it("returns unencrypted cuid when isEncrypted is false", () => { + test("returns unencrypted cuid when isEncrypted is false", () => { const result = generateSurveySingleUseId(false); expect(result).toBe(mockCuid); expect(crypto.symmetricEncrypt).not.toHaveBeenCalled(); }); - it("returns encrypted cuid when isEncrypted is true", () => { + test("returns encrypted cuid when isEncrypted is true", () => { const result = generateSurveySingleUseId(true); expect(result).toBe(mockEncryptedCuid); expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockCuid, "test-encryption-key"); }); - it("returns undefined when cuid is not valid", () => { + test("returns undefined when cuid is not valid", () => { vi.mocked(cuid2.isCuid).mockReturnValue(false); const result = validateSurveySingleUseId(mockEncryptedCuid); @@ -67,7 +65,7 @@ describe("generateSurveySingleUseId", () => { expect(result).toBeUndefined(); }); - it("returns undefined when decryption fails", () => { + test("returns undefined when decryption fails", () => { vi.mocked(crypto.symmetricDecrypt).mockImplementation(() => { throw new Error("Decryption failed"); }); @@ -77,11 +75,10 @@ describe("generateSurveySingleUseId", () => { expect(result).toBeUndefined(); }); - it("throws error when ENCRYPTION_KEY is not set in generateSurveySingleUseId", async () => { + test("throws error when ENCRYPTION_KEY is not set in generateSurveySingleUseId", async () => { // Temporarily mock ENCRYPTION_KEY as undefined - vi.doMock("@formbricks/lib/constants", () => ({ + vi.doMock("@/lib/constants", () => ({ ENCRYPTION_KEY: undefined, - FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key", })); // Re-import to get the new mock values @@ -90,11 +87,10 @@ describe("generateSurveySingleUseId", () => { expect(() => generateSurveySingleUseIdNoKey(true)).toThrow("ENCRYPTION_KEY is not set"); }); - it("throws error when ENCRYPTION_KEY is not set in validateSurveySingleUseId for symmetric encryption", async () => { + test("throws error when ENCRYPTION_KEY is not set in validateSurveySingleUseId for symmetric encryption", async () => { // Temporarily mock ENCRYPTION_KEY as undefined - vi.doMock("@formbricks/lib/constants", () => ({ + vi.doMock("@/lib/constants", () => ({ ENCRYPTION_KEY: undefined, - FORMBRICKS_ENCRYPTION_KEY: "test-formbricks-encryption-key", })); // Re-import to get the new mock values @@ -102,19 +98,4 @@ describe("generateSurveySingleUseId", () => { expect(() => validateSurveySingleUseIdNoKey(mockEncryptedCuid)).toThrow("ENCRYPTION_KEY is not set"); }); - - it("throws error when FORMBRICKS_ENCRYPTION_KEY is not set in validateSurveySingleUseId for AES128", async () => { - // Temporarily mock FORMBRICKS_ENCRYPTION_KEY as undefined - vi.doMock("@formbricks/lib/constants", () => ({ - ENCRYPTION_KEY: "test-encryption-key", - FORMBRICKS_ENCRYPTION_KEY: undefined, - })); - - // Re-import to get the new mock values - const { validateSurveySingleUseId: validateSurveySingleUseIdNoKey } = await import("./singleUseSurveys"); - - expect(() => - validateSurveySingleUseIdNoKey("M(.Bob=dS1!wUSH2lb,E7hxO=He1cnnitmXrG|Su/DKYZrPy~zgS)u?dgI53sfs/") - ).toThrow("FORMBRICKS_ENCRYPTION_KEY is not defined"); - }); }); diff --git a/apps/web/app/lib/singleUseSurveys.ts b/apps/web/app/lib/singleUseSurveys.ts index aaceacd6d909..eee1005fe5a5 100644 --- a/apps/web/app/lib/singleUseSurveys.ts +++ b/apps/web/app/lib/singleUseSurveys.ts @@ -1,6 +1,6 @@ +import { ENCRYPTION_KEY } from "@/lib/constants"; +import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto"; import cuid2 from "@paralleldrive/cuid2"; -import { ENCRYPTION_KEY, FORMBRICKS_ENCRYPTION_KEY } from "@formbricks/lib/constants"; -import { decryptAES128, symmetricDecrypt, symmetricEncrypt } from "@formbricks/lib/crypto"; // generate encrypted single use id for the survey export const generateSurveySingleUseId = (isEncrypted: boolean): string => { @@ -21,25 +21,13 @@ export const generateSurveySingleUseId = (isEncrypted: boolean): string => { export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => { let decryptedCuid: string | null = null; - if (surveySingleUseId.length === 64) { - if (!FORMBRICKS_ENCRYPTION_KEY) { - throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined"); - } - - try { - decryptedCuid = decryptAES128(FORMBRICKS_ENCRYPTION_KEY, surveySingleUseId); - } catch (error) { - return undefined; - } - } else { - if (!ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); - } - try { - decryptedCuid = symmetricDecrypt(surveySingleUseId, ENCRYPTION_KEY); - } catch (error) { - return undefined; - } + if (!ENCRYPTION_KEY) { + throw new Error("ENCRYPTION_KEY is not set"); + } + try { + decryptedCuid = symmetricDecrypt(surveySingleUseId, ENCRYPTION_KEY); + } catch (error) { + return undefined; } if (cuid2.isCuid(decryptedCuid)) { diff --git a/apps/web/app/lib/survey-builder.test.ts b/apps/web/app/lib/survey-builder.test.ts new file mode 100644 index 000000000000..6bd9ecf23377 --- /dev/null +++ b/apps/web/app/lib/survey-builder.test.ts @@ -0,0 +1,612 @@ +import { describe, expect, test } from "vitest"; +import { TShuffleOption, TSurveyLogic, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { TTemplateRole } from "@formbricks/types/templates"; +import { + buildCTAQuestion, + buildConsentQuestion, + buildMultipleChoiceQuestion, + buildNPSQuestion, + buildOpenTextQuestion, + buildRatingQuestion, + buildSurvey, + createChoiceJumpLogic, + createJumpLogic, + getDefaultEndingCard, + getDefaultSurveyPreset, + getDefaultWelcomeCard, + hiddenFieldsDefault, +} from "./survey-builder"; + +// Mock the TFnType from @tolgee/react +const mockT = (props: any): string => (typeof props === "string" ? props : props.key); + +describe("Survey Builder", () => { + describe("buildMultipleChoiceQuestion", () => { + test("creates a single choice question with required fields", () => { + const question = buildMultipleChoiceQuestion({ + headline: "Test Question", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + choices: ["Option 1", "Option 2", "Option 3"], + t: mockT, + }); + + expect(question).toMatchObject({ + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Test Question" }, + choices: expect.arrayContaining([ + expect.objectContaining({ label: { default: "Option 1" } }), + expect.objectContaining({ label: { default: "Option 2" } }), + expect.objectContaining({ label: { default: "Option 3" } }), + ]), + buttonLabel: { default: "common.next" }, + backButtonLabel: { default: "common.back" }, + shuffleOption: "none", + required: false, + }); + expect(question.choices.length).toBe(3); + expect(question.id).toBeDefined(); + }); + + test("creates a multiple choice question with provided ID", () => { + const customId = "custom-id-123"; + const question = buildMultipleChoiceQuestion({ + id: customId, + headline: "Test Question", + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + choices: ["Option 1", "Option 2"], + t: mockT, + }); + + expect(question.id).toBe(customId); + expect(question.type).toBe(TSurveyQuestionTypeEnum.MultipleChoiceMulti); + }); + + test("handles 'other' option correctly", () => { + const choices = ["Option 1", "Option 2", "Other"]; + const question = buildMultipleChoiceQuestion({ + headline: "Test Question", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + choices, + containsOther: true, + t: mockT, + }); + + expect(question.choices.length).toBe(3); + expect(question.choices[2].id).toBe("other"); + }); + + test("uses provided choice IDs when available", () => { + const choiceIds = ["id1", "id2", "id3"]; + const question = buildMultipleChoiceQuestion({ + headline: "Test Question", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + choices: ["Option 1", "Option 2", "Option 3"], + choiceIds, + t: mockT, + }); + + expect(question.choices[0].id).toBe(choiceIds[0]); + expect(question.choices[1].id).toBe(choiceIds[1]); + expect(question.choices[2].id).toBe(choiceIds[2]); + }); + + test("applies all optional parameters correctly", () => { + const logic: TSurveyLogic[] = [ + { + id: "logic-1", + conditions: { + id: "cond-1", + connector: "and", + conditions: [], + }, + actions: [], + }, + ]; + + const shuffleOption: TShuffleOption = "all"; + + const question = buildMultipleChoiceQuestion({ + headline: "Test Question", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + subheader: "This is a subheader", + choices: ["Option 1", "Option 2"], + buttonLabel: "Custom Next", + backButtonLabel: "Custom Back", + shuffleOption, + required: false, + logic, + t: mockT, + }); + + expect(question.subheader).toEqual({ default: "This is a subheader" }); + expect(question.buttonLabel).toEqual({ default: "Custom Next" }); + expect(question.backButtonLabel).toEqual({ default: "Custom Back" }); + expect(question.shuffleOption).toBe("all"); + expect(question.required).toBe(false); + expect(question.logic).toBe(logic); + }); + }); + + describe("buildOpenTextQuestion", () => { + test("creates an open text question with required fields", () => { + const question = buildOpenTextQuestion({ + headline: "Open Question", + inputType: "text", + t: mockT, + }); + + expect(question).toMatchObject({ + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Open Question" }, + inputType: "text", + buttonLabel: { default: "common.next" }, + backButtonLabel: { default: "common.back" }, + required: false, + charLimit: { + enabled: false, + }, + }); + expect(question.id).toBeDefined(); + }); + + test("applies all optional parameters correctly", () => { + const logic: TSurveyLogic[] = [ + { + id: "logic-1", + conditions: { + id: "cond-1", + connector: "and", + conditions: [], + }, + actions: [], + }, + ]; + + const question = buildOpenTextQuestion({ + id: "custom-id", + headline: "Open Question", + subheader: "Answer this question", + placeholder: "Type here", + buttonLabel: "Submit", + backButtonLabel: "Previous", + required: false, + longAnswer: true, + inputType: "email", + logic, + t: mockT, + }); + + expect(question.id).toBe("custom-id"); + expect(question.subheader).toEqual({ default: "Answer this question" }); + expect(question.placeholder).toEqual({ default: "Type here" }); + expect(question.buttonLabel).toEqual({ default: "Submit" }); + expect(question.backButtonLabel).toEqual({ default: "Previous" }); + expect(question.required).toBe(false); + expect(question.longAnswer).toBe(true); + expect(question.inputType).toBe("email"); + expect(question.logic).toBe(logic); + }); + }); + + describe("buildRatingQuestion", () => { + test("creates a rating question with required fields", () => { + const question = buildRatingQuestion({ + headline: "Rating Question", + scale: "number", + range: 5, + t: mockT, + }); + + expect(question).toMatchObject({ + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rating Question" }, + scale: "number", + range: 5, + buttonLabel: { default: "common.next" }, + backButtonLabel: { default: "common.back" }, + required: false, + isColorCodingEnabled: false, + }); + expect(question.id).toBeDefined(); + }); + + test("applies all optional parameters correctly", () => { + const logic: TSurveyLogic[] = [ + { + id: "logic-1", + conditions: { + id: "cond-1", + connector: "and", + conditions: [], + }, + actions: [], + }, + ]; + + const question = buildRatingQuestion({ + id: "custom-id", + headline: "Rating Question", + subheader: "Rate us", + scale: "star", + range: 10, + lowerLabel: "Poor", + upperLabel: "Excellent", + buttonLabel: "Submit", + backButtonLabel: "Previous", + required: false, + isColorCodingEnabled: true, + logic, + t: mockT, + }); + + expect(question.id).toBe("custom-id"); + expect(question.subheader).toEqual({ default: "Rate us" }); + expect(question.scale).toBe("star"); + expect(question.range).toBe(10); + expect(question.lowerLabel).toEqual({ default: "Poor" }); + expect(question.upperLabel).toEqual({ default: "Excellent" }); + expect(question.buttonLabel).toEqual({ default: "Submit" }); + expect(question.backButtonLabel).toEqual({ default: "Previous" }); + expect(question.required).toBe(false); + expect(question.isColorCodingEnabled).toBe(true); + expect(question.logic).toBe(logic); + }); + }); + + describe("buildNPSQuestion", () => { + test("creates an NPS question with required fields", () => { + const question = buildNPSQuestion({ + headline: "NPS Question", + t: mockT, + }); + + expect(question).toMatchObject({ + type: TSurveyQuestionTypeEnum.NPS, + headline: { default: "NPS Question" }, + buttonLabel: { default: "common.next" }, + backButtonLabel: { default: "common.back" }, + required: false, + isColorCodingEnabled: false, + }); + expect(question.id).toBeDefined(); + }); + + test("applies all optional parameters correctly", () => { + const logic: TSurveyLogic[] = [ + { + id: "logic-1", + conditions: { + id: "cond-1", + connector: "and", + conditions: [], + }, + actions: [], + }, + ]; + + const question = buildNPSQuestion({ + id: "custom-id", + headline: "NPS Question", + subheader: "How likely are you to recommend us?", + lowerLabel: "Not likely", + upperLabel: "Very likely", + buttonLabel: "Submit", + backButtonLabel: "Previous", + required: false, + isColorCodingEnabled: true, + logic, + t: mockT, + }); + + expect(question.id).toBe("custom-id"); + expect(question.subheader).toEqual({ default: "How likely are you to recommend us?" }); + expect(question.lowerLabel).toEqual({ default: "Not likely" }); + expect(question.upperLabel).toEqual({ default: "Very likely" }); + expect(question.buttonLabel).toEqual({ default: "Submit" }); + expect(question.backButtonLabel).toEqual({ default: "Previous" }); + expect(question.required).toBe(false); + expect(question.isColorCodingEnabled).toBe(true); + expect(question.logic).toBe(logic); + }); + }); + + describe("buildConsentQuestion", () => { + test("creates a consent question with required fields", () => { + const question = buildConsentQuestion({ + headline: "Consent Question", + label: "I agree to terms", + t: mockT, + }); + + expect(question).toMatchObject({ + type: TSurveyQuestionTypeEnum.Consent, + headline: { default: "Consent Question" }, + label: { default: "I agree to terms" }, + buttonLabel: { default: "common.next" }, + backButtonLabel: { default: "common.back" }, + required: false, + }); + expect(question.id).toBeDefined(); + }); + + test("applies all optional parameters correctly", () => { + const logic: TSurveyLogic[] = [ + { + id: "logic-1", + conditions: { + id: "cond-1", + connector: "and", + conditions: [], + }, + actions: [], + }, + ]; + + const question = buildConsentQuestion({ + id: "custom-id", + headline: "Consent Question", + subheader: "Please read the terms", + label: "I agree to terms", + buttonLabel: "Submit", + backButtonLabel: "Previous", + required: false, + logic, + t: mockT, + }); + + expect(question.id).toBe("custom-id"); + expect(question.subheader).toEqual({ default: "Please read the terms" }); + expect(question.label).toEqual({ default: "I agree to terms" }); + expect(question.buttonLabel).toEqual({ default: "Submit" }); + expect(question.backButtonLabel).toEqual({ default: "Previous" }); + expect(question.required).toBe(false); + expect(question.logic).toBe(logic); + }); + }); + + describe("buildCTAQuestion", () => { + test("creates a CTA question with required fields", () => { + const question = buildCTAQuestion({ + headline: "CTA Question", + buttonExternal: false, + t: mockT, + }); + + expect(question).toMatchObject({ + type: TSurveyQuestionTypeEnum.CTA, + headline: { default: "CTA Question" }, + buttonLabel: { default: "common.next" }, + backButtonLabel: { default: "common.back" }, + required: false, + buttonExternal: false, + }); + expect(question.id).toBeDefined(); + }); + + test("applies all optional parameters correctly", () => { + const logic: TSurveyLogic[] = [ + { + id: "logic-1", + conditions: { + id: "cond-1", + connector: "and", + conditions: [], + }, + actions: [], + }, + ]; + + const question = buildCTAQuestion({ + id: "custom-id", + headline: "CTA Question", + html: "

Click the button

", + buttonLabel: "Click me", + buttonExternal: true, + buttonUrl: "https://example.com", + backButtonLabel: "Previous", + required: false, + dismissButtonLabel: "No thanks", + logic, + t: mockT, + }); + + expect(question.id).toBe("custom-id"); + expect(question.html).toEqual({ default: "

Click the button

" }); + expect(question.buttonLabel).toEqual({ default: "Click me" }); + expect(question.buttonExternal).toBe(true); + expect(question.buttonUrl).toBe("https://example.com"); + expect(question.backButtonLabel).toEqual({ default: "Previous" }); + expect(question.required).toBe(false); + expect(question.dismissButtonLabel).toEqual({ default: "No thanks" }); + expect(question.logic).toBe(logic); + }); + + test("handles external button with URL", () => { + const question = buildCTAQuestion({ + headline: "CTA Question", + buttonExternal: true, + buttonUrl: "https://formbricks.com", + t: mockT, + }); + + expect(question.buttonExternal).toBe(true); + expect(question.buttonUrl).toBe("https://formbricks.com"); + }); + }); + + // Test combinations of parameters for edge cases + describe("Edge cases", () => { + test("multiple choice question with empty choices array", () => { + const question = buildMultipleChoiceQuestion({ + headline: "Test Question", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + choices: [], + t: mockT, + }); + + expect(question.choices).toEqual([]); + }); + + test("open text question with all parameters", () => { + const question = buildOpenTextQuestion({ + id: "custom-id", + headline: "Open Question", + subheader: "Answer this question", + placeholder: "Type here", + buttonLabel: "Submit", + backButtonLabel: "Previous", + required: false, + longAnswer: true, + inputType: "email", + logic: [], + t: mockT, + }); + + expect(question).toMatchObject({ + id: "custom-id", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Open Question" }, + subheader: { default: "Answer this question" }, + placeholder: { default: "Type here" }, + buttonLabel: { default: "Submit" }, + backButtonLabel: { default: "Previous" }, + required: false, + longAnswer: true, + inputType: "email", + logic: [], + }); + }); + }); +}); + +describe("Helper Functions", () => { + test("createJumpLogic returns valid jump logic", () => { + const sourceId = "q1"; + const targetId = "q2"; + const operator: "isClicked" = "isClicked"; + const logic = createJumpLogic(sourceId, targetId, operator); + + // Check structure + expect(logic).toHaveProperty("id"); + expect(logic).toHaveProperty("conditions"); + expect(logic.conditions).toHaveProperty("conditions"); + expect(Array.isArray(logic.conditions.conditions)).toBe(true); + + // Check one of the inner conditions + const condition = logic.conditions.conditions[0]; + // Need to use type checking to ensure condition is a TSingleCondition not a TConditionGroup + if (!("connector" in condition)) { + expect(condition.leftOperand.value).toBe(sourceId); + expect(condition.operator).toBe(operator); + } + + // Check actions + expect(Array.isArray(logic.actions)).toBe(true); + const action = logic.actions[0]; + if (action.objective === "jumpToQuestion") { + expect(action.target).toBe(targetId); + } + }); + + test("createChoiceJumpLogic returns valid jump logic based on choice selection", () => { + const sourceId = "q1"; + const choiceId = "choice1"; + const targetId = "q2"; + const logic = createChoiceJumpLogic(sourceId, choiceId, targetId); + + expect(logic).toHaveProperty("id"); + expect(logic.conditions).toHaveProperty("conditions"); + + const condition = logic.conditions.conditions[0]; + if (!("connector" in condition)) { + expect(condition.leftOperand.value).toBe(sourceId); + expect(condition.operator).toBe("equals"); + expect(condition.rightOperand?.value).toBe(choiceId); + } + + const action = logic.actions[0]; + if (action.objective === "jumpToQuestion") { + expect(action.target).toBe(targetId); + } + }); + + test("getDefaultWelcomeCard returns expected welcome card", () => { + const card = getDefaultWelcomeCard(mockT); + expect(card.enabled).toBe(false); + expect(card.headline).toEqual({ default: "templates.default_welcome_card_headline" }); + expect(card.html).toEqual({ default: "templates.default_welcome_card_html" }); + expect(card.buttonLabel).toEqual({ default: "templates.default_welcome_card_button_label" }); + // boolean flags + expect(card.timeToFinish).toBe(false); + expect(card.showResponseCount).toBe(false); + }); + + test("getDefaultEndingCard returns expected end screen card", () => { + // Pass empty languages array to simulate no languages + const card = getDefaultEndingCard([], mockT); + expect(card).toHaveProperty("id"); + expect(card.type).toBe("endScreen"); + expect(card.headline).toEqual({ default: "templates.default_ending_card_headline" }); + expect(card.subheader).toEqual({ default: "templates.default_ending_card_subheader" }); + expect(card.buttonLabel).toEqual({ default: "templates.default_ending_card_button_label" }); + expect(card.buttonLink).toBe("https://formbricks.com"); + }); + + test("getDefaultSurveyPreset returns expected default survey preset", () => { + const preset = getDefaultSurveyPreset(mockT); + expect(preset.name).toBe("New Survey"); + expect(preset.questions).toEqual([]); + // test welcomeCard and endings + expect(preset.welcomeCard).toHaveProperty("headline"); + expect(Array.isArray(preset.endings)).toBe(true); + expect(preset.hiddenFields).toEqual(hiddenFieldsDefault); + }); + + test("buildSurvey returns built survey with overridden preset properties", () => { + const config = { + name: "Custom Survey", + role: "productManager" as TTemplateRole, + industries: ["eCommerce"] as string[], + channels: ["link"], + description: "Test survey", + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, // changed from "OpenText" + headline: { default: "Question 1" }, + inputType: "text", + buttonLabel: { default: "Next" }, + backButtonLabel: { default: "Back" }, + required: true, + }, + ], + endings: [ + { + id: "end1", + type: "endScreen", + headline: { default: "End Screen" }, + subheader: { default: "Thanks" }, + buttonLabel: { default: "Finish" }, + buttonLink: "https://formbricks.com", + }, + ], + hiddenFields: { enabled: false, fieldIds: ["f1"] }, + }; + + const survey = buildSurvey(config as any, mockT); + expect(survey.name).toBe(config.name); + expect(survey.role).toBe(config.role); + expect(survey.industries).toEqual(config.industries); + expect(survey.channels).toEqual(config.channels); + expect(survey.description).toBe(config.description); + // preset overrides + expect(survey.preset.name).toBe(config.name); + expect(survey.preset.questions).toEqual(config.questions); + expect(survey.preset.endings).toEqual(config.endings); + expect(survey.preset.hiddenFields).toEqual(config.hiddenFields); + }); + + test("hiddenFieldsDefault has expected default configuration", () => { + expect(hiddenFieldsDefault).toEqual({ enabled: true, fieldIds: [] }); + }); +}); diff --git a/apps/web/app/lib/survey-builder.ts b/apps/web/app/lib/survey-builder.ts new file mode 100644 index 000000000000..e842b737ea83 --- /dev/null +++ b/apps/web/app/lib/survey-builder.ts @@ -0,0 +1,417 @@ +import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; +import { createId } from "@paralleldrive/cuid2"; +import { TFnType } from "@tolgee/react"; +import { + TShuffleOption, + TSurveyCTAQuestion, + TSurveyConsentQuestion, + TSurveyEndScreenCard, + TSurveyEnding, + TSurveyHiddenFields, + TSurveyLanguage, + TSurveyLogic, + TSurveyMultipleChoiceQuestion, + TSurveyNPSQuestion, + TSurveyOpenTextQuestion, + TSurveyOpenTextQuestionInputType, + TSurveyQuestion, + TSurveyQuestionTypeEnum, + TSurveyRatingQuestion, + TSurveyWelcomeCard, +} from "@formbricks/types/surveys/types"; +import { TTemplate, TTemplateRole } from "@formbricks/types/templates"; + +const getDefaultButtonLabel = (label: string | undefined, t: TFnType) => + createI18nString(label || t("common.next"), []); + +const getDefaultBackButtonLabel = (label: string | undefined, t: TFnType) => + createI18nString(label || t("common.back"), []); + +export const buildMultipleChoiceQuestion = ({ + id, + headline, + type, + subheader, + choices, + choiceIds, + buttonLabel, + backButtonLabel, + shuffleOption, + required, + logic, + containsOther = false, + t, +}: { + id?: string; + headline: string; + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti | TSurveyQuestionTypeEnum.MultipleChoiceSingle; + subheader?: string; + choices: string[]; + choiceIds?: string[]; + buttonLabel?: string; + backButtonLabel?: string; + shuffleOption?: TShuffleOption; + required?: boolean; + logic?: TSurveyLogic[]; + containsOther?: boolean; + t: TFnType; +}): TSurveyMultipleChoiceQuestion => { + return { + id: id ?? createId(), + type, + subheader: subheader ? createI18nString(subheader, []) : undefined, + headline: createI18nString(headline, []), + choices: choices.map((choice, index) => { + const isLastIndex = index === choices.length - 1; + const id = containsOther && isLastIndex ? "other" : choiceIds ? choiceIds[index] : createId(); + return { id, label: createI18nString(choice, []) }; + }), + buttonLabel: getDefaultButtonLabel(buttonLabel, t), + backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t), + shuffleOption: shuffleOption || "none", + required: required ?? false, + logic, + }; +}; + +export const buildOpenTextQuestion = ({ + id, + headline, + subheader, + placeholder, + inputType, + buttonLabel, + backButtonLabel, + required, + logic, + longAnswer, + t, +}: { + id?: string; + headline: string; + subheader?: string; + placeholder?: string; + buttonLabel?: string; + backButtonLabel?: string; + required?: boolean; + logic?: TSurveyLogic[]; + inputType: TSurveyOpenTextQuestionInputType; + longAnswer?: boolean; + t: TFnType; +}): TSurveyOpenTextQuestion => { + return { + id: id ?? createId(), + type: TSurveyQuestionTypeEnum.OpenText, + inputType, + subheader: subheader ? createI18nString(subheader, []) : undefined, + placeholder: placeholder ? createI18nString(placeholder, []) : undefined, + headline: createI18nString(headline, []), + buttonLabel: getDefaultButtonLabel(buttonLabel, t), + backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t), + required: required ?? false, + longAnswer, + logic, + charLimit: { + enabled: false, + }, + }; +}; + +export const buildRatingQuestion = ({ + id, + headline, + subheader, + scale, + range, + lowerLabel, + upperLabel, + buttonLabel, + backButtonLabel, + required, + logic, + isColorCodingEnabled = false, + t, +}: { + id?: string; + headline: string; + scale: TSurveyRatingQuestion["scale"]; + range: TSurveyRatingQuestion["range"]; + lowerLabel?: string; + upperLabel?: string; + subheader?: string; + placeholder?: string; + buttonLabel?: string; + backButtonLabel?: string; + required?: boolean; + logic?: TSurveyLogic[]; + isColorCodingEnabled?: boolean; + t: TFnType; +}): TSurveyRatingQuestion => { + return { + id: id ?? createId(), + type: TSurveyQuestionTypeEnum.Rating, + subheader: subheader ? createI18nString(subheader, []) : undefined, + headline: createI18nString(headline, []), + scale, + range, + buttonLabel: getDefaultButtonLabel(buttonLabel, t), + backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t), + required: required ?? false, + isColorCodingEnabled, + lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined, + upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined, + logic, + }; +}; + +export const buildNPSQuestion = ({ + id, + headline, + subheader, + lowerLabel, + upperLabel, + buttonLabel, + backButtonLabel, + required, + logic, + isColorCodingEnabled = false, + t, +}: { + id?: string; + headline: string; + lowerLabel?: string; + upperLabel?: string; + subheader?: string; + placeholder?: string; + buttonLabel?: string; + backButtonLabel?: string; + required?: boolean; + logic?: TSurveyLogic[]; + isColorCodingEnabled?: boolean; + t: TFnType; +}): TSurveyNPSQuestion => { + return { + id: id ?? createId(), + type: TSurveyQuestionTypeEnum.NPS, + subheader: subheader ? createI18nString(subheader, []) : undefined, + headline: createI18nString(headline, []), + buttonLabel: getDefaultButtonLabel(buttonLabel, t), + backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t), + required: required ?? false, + isColorCodingEnabled, + lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined, + upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined, + logic, + }; +}; + +export const buildConsentQuestion = ({ + id, + headline, + subheader, + label, + buttonLabel, + backButtonLabel, + required, + logic, + t, +}: { + id?: string; + headline: string; + subheader?: string; + buttonLabel?: string; + backButtonLabel?: string; + required?: boolean; + logic?: TSurveyLogic[]; + label: string; + t: TFnType; +}): TSurveyConsentQuestion => { + return { + id: id ?? createId(), + type: TSurveyQuestionTypeEnum.Consent, + subheader: subheader ? createI18nString(subheader, []) : undefined, + headline: createI18nString(headline, []), + buttonLabel: getDefaultButtonLabel(buttonLabel, t), + backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t), + required: required ?? false, + label: createI18nString(label, []), + logic, + }; +}; + +export const buildCTAQuestion = ({ + id, + headline, + html, + buttonLabel, + buttonExternal, + backButtonLabel, + required, + logic, + dismissButtonLabel, + buttonUrl, + t, +}: { + id?: string; + headline: string; + buttonExternal: boolean; + html?: string; + buttonLabel?: string; + backButtonLabel?: string; + required?: boolean; + logic?: TSurveyLogic[]; + dismissButtonLabel?: string; + buttonUrl?: string; + t: TFnType; +}): TSurveyCTAQuestion => { + return { + id: id ?? createId(), + type: TSurveyQuestionTypeEnum.CTA, + html: html ? createI18nString(html, []) : undefined, + headline: createI18nString(headline, []), + buttonLabel: getDefaultButtonLabel(buttonLabel, t), + backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t), + dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined, + required: required ?? false, + buttonExternal, + buttonUrl, + logic, + }; +}; + +// Helper function to create standard jump logic based on operator +export const createJumpLogic = ( + sourceQuestionId: string, + targetId: string, + operator: "isSkipped" | "isSubmitted" | "isClicked" +): TSurveyLogic => ({ + id: createId(), + conditions: { + id: createId(), + connector: "and", + conditions: [ + { + id: createId(), + leftOperand: { + value: sourceQuestionId, + type: "question", + }, + operator: operator, + }, + ], + }, + actions: [ + { + id: createId(), + objective: "jumpToQuestion", + target: targetId, + }, + ], +}); + +// Helper function to create jump logic based on choice selection +export const createChoiceJumpLogic = ( + sourceQuestionId: string, + choiceId: string | number, + targetId: string +): TSurveyLogic => ({ + id: createId(), + conditions: { + id: createId(), + connector: "and", + conditions: [ + { + id: createId(), + leftOperand: { + value: sourceQuestionId, + type: "question", + }, + operator: "equals", + rightOperand: { + type: "static", + value: choiceId, + }, + }, + ], + }, + actions: [ + { + id: createId(), + objective: "jumpToQuestion", + target: targetId, + }, + ], +}); + +export const getDefaultEndingCard = (languages: TSurveyLanguage[], t: TFnType): TSurveyEndScreenCard => { + const languageCodes = extractLanguageCodes(languages); + return { + id: createId(), + type: "endScreen", + headline: createI18nString(t("templates.default_ending_card_headline"), languageCodes), + subheader: createI18nString(t("templates.default_ending_card_subheader"), languageCodes), + buttonLabel: createI18nString(t("templates.default_ending_card_button_label"), languageCodes), + buttonLink: "https://formbricks.com", + }; +}; + +export const hiddenFieldsDefault: TSurveyHiddenFields = { + enabled: true, + fieldIds: [], +}; + +export const getDefaultWelcomeCard = (t: TFnType): TSurveyWelcomeCard => { + return { + enabled: false, + headline: createI18nString(t("templates.default_welcome_card_headline"), []), + html: createI18nString(t("templates.default_welcome_card_html"), []), + buttonLabel: createI18nString(t("templates.default_welcome_card_button_label"), []), + timeToFinish: false, + showResponseCount: false, + }; +}; + +export const getDefaultSurveyPreset = (t: TFnType): TTemplate["preset"] => { + return { + name: "New Survey", + welcomeCard: getDefaultWelcomeCard(t), + endings: [getDefaultEndingCard([], t)], + hiddenFields: hiddenFieldsDefault, + questions: [], + }; +}; + +/** + * Generic builder for survey. + * @param config - The configuration for survey settings and questions. + * @param t - The translation function. + */ +export const buildSurvey = ( + config: { + name: string; + role: TTemplateRole; + industries: ("eCommerce" | "saas" | "other")[]; + channels: ("link" | "app" | "website")[]; + description: string; + questions: TSurveyQuestion[]; + endings?: TSurveyEnding[]; + hiddenFields?: TSurveyHiddenFields; + }, + t: TFnType +): TTemplate => { + const localSurvey = getDefaultSurveyPreset(t); + return { + name: config.name, + role: config.role, + industries: config.industries, + channels: config.channels, + description: config.description, + preset: { + ...localSurvey, + name: config.name, + questions: config.questions, + endings: config.endings ?? localSurvey.endings, + hiddenFields: config.hiddenFields ?? hiddenFieldsDefault, + }, + }; +}; diff --git a/apps/web/app/lib/surveys/surveys.test.ts b/apps/web/app/lib/surveys/surveys.test.ts new file mode 100644 index 000000000000..2db92b55e2a3 --- /dev/null +++ b/apps/web/app/lib/surveys/surveys.test.ts @@ -0,0 +1,736 @@ +import { + DateRange, + SelectedFilterValue, +} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; +import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox"; +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { TLanguage } from "@formbricks/types/project"; +import { + TSurvey, + TSurveyLanguage, + TSurveyQuestion, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; +import { TTag } from "@formbricks/types/tags"; +import { generateQuestionAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys"; + +describe("surveys", () => { + afterEach(() => { + cleanup(); + }); + + describe("generateQuestionAndFilterOptions", () => { + test("should return question options for basic survey without additional options", () => { + const survey = { + id: "survey1", + name: "Test Survey", + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Open Text Question" }, + } as unknown as TSurveyQuestion, + ], + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + status: "draft", + } as unknown as TSurvey; + + const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}); + + expect(result.questionOptions.length).toBeGreaterThan(0); + expect(result.questionOptions[0].header).toBe(OptionsType.QUESTIONS); + expect(result.questionFilterOptions.length).toBe(1); + expect(result.questionFilterOptions[0].id).toBe("q1"); + }); + + test("should include tags in options when provided", () => { + const survey = { + id: "survey1", + name: "Test Survey", + questions: [], + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + status: "draft", + } as unknown as TSurvey; + + const tags: TTag[] = [ + { id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() }, + ]; + + const result = generateQuestionAndFilterOptions(survey, tags, {}, {}, {}); + + const tagsHeader = result.questionOptions.find((opt) => opt.header === OptionsType.TAGS); + expect(tagsHeader).toBeDefined(); + expect(tagsHeader?.option.length).toBe(1); + expect(tagsHeader?.option[0].label).toBe("Tag 1"); + }); + + test("should include attributes in options when provided", () => { + const survey = { + id: "survey1", + name: "Test Survey", + questions: [], + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + status: "draft", + } as unknown as TSurvey; + + const attributes = { + role: ["admin", "user"], + }; + + const result = generateQuestionAndFilterOptions(survey, undefined, attributes, {}, {}); + + const attributesHeader = result.questionOptions.find((opt) => opt.header === OptionsType.ATTRIBUTES); + expect(attributesHeader).toBeDefined(); + expect(attributesHeader?.option.length).toBe(1); + expect(attributesHeader?.option[0].label).toBe("role"); + }); + + test("should include meta in options when provided", () => { + const survey = { + id: "survey1", + name: "Test Survey", + questions: [], + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + status: "draft", + } as unknown as TSurvey; + + const meta = { + source: ["web", "mobile"], + }; + + const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {}); + + const metaHeader = result.questionOptions.find((opt) => opt.header === OptionsType.META); + expect(metaHeader).toBeDefined(); + expect(metaHeader?.option.length).toBe(1); + expect(metaHeader?.option[0].label).toBe("source"); + }); + + test("should include hidden fields in options when provided", () => { + const survey = { + id: "survey1", + name: "Test Survey", + questions: [], + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + status: "draft", + } as unknown as TSurvey; + + const hiddenFields = { + segment: ["free", "paid"], + }; + + const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, hiddenFields); + + const hiddenFieldsHeader = result.questionOptions.find( + (opt) => opt.header === OptionsType.HIDDEN_FIELDS + ); + expect(hiddenFieldsHeader).toBeDefined(); + expect(hiddenFieldsHeader?.option.length).toBe(1); + expect(hiddenFieldsHeader?.option[0].label).toBe("segment"); + }); + + test("should include language options when survey has languages", () => { + const survey = { + id: "survey1", + name: "Test Survey", + questions: [], + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + status: "draft", + languages: [{ language: { code: "en" } as unknown as TLanguage } as unknown as TSurveyLanguage], + } as unknown as TSurvey; + + const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}); + + const othersHeader = result.questionOptions.find((opt) => opt.header === OptionsType.OTHERS); + expect(othersHeader).toBeDefined(); + expect(othersHeader?.option.some((o) => o.label === "Language")).toBeTruthy(); + }); + + test("should handle all question types correctly", () => { + const survey = { + id: "survey1", + name: "Test Survey", + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Open Text" }, + } as unknown as TSurveyQuestion, + { + id: "q2", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Multiple Choice Single" }, + choices: [{ id: "c1", label: "Choice 1" }], + } as unknown as TSurveyQuestion, + { + id: "q3", + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + headline: { default: "Multiple Choice Multi" }, + choices: [ + { id: "c1", label: "Choice 1" }, + { id: "other", label: "Other" }, + ], + } as unknown as TSurveyQuestion, + { + id: "q4", + type: TSurveyQuestionTypeEnum.NPS, + headline: { default: "NPS" }, + } as unknown as TSurveyQuestion, + { + id: "q5", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rating" }, + } as unknown as TSurveyQuestion, + { + id: "q6", + type: TSurveyQuestionTypeEnum.CTA, + headline: { default: "CTA" }, + } as unknown as TSurveyQuestion, + { + id: "q7", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: { default: "Picture Selection" }, + choices: [ + { id: "p1", imageUrl: "url1" }, + { id: "p2", imageUrl: "url2" }, + ], + } as unknown as TSurveyQuestion, + { + id: "q8", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Matrix" }, + rows: [{ id: "r1", label: "Row 1" }], + columns: [{ id: "c1", label: "Column 1" }], + } as unknown as TSurveyQuestion, + ], + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + status: "draft", + } as unknown as TSurvey; + + const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}); + + expect(result.questionFilterOptions.length).toBe(8); + expect(result.questionFilterOptions.some((o) => o.id === "q1")).toBeTruthy(); + expect(result.questionFilterOptions.some((o) => o.id === "q2")).toBeTruthy(); + expect(result.questionFilterOptions.some((o) => o.id === "q7")).toBeTruthy(); + expect(result.questionFilterOptions.some((o) => o.id === "q8")).toBeTruthy(); + }); + }); + + describe("getFormattedFilters", () => { + const survey = { + id: "survey1", + name: "Test Survey", + questions: [ + { + id: "openTextQ", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Open Text" }, + } as unknown as TSurveyQuestion, + { + id: "mcSingleQ", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Multiple Choice Single" }, + choices: [{ id: "c1", label: "Choice 1" }], + } as unknown as TSurveyQuestion, + { + id: "mcMultiQ", + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + headline: { default: "Multiple Choice Multi" }, + choices: [{ id: "c1", label: "Choice 1" }], + } as unknown as TSurveyQuestion, + { + id: "npsQ", + type: TSurveyQuestionTypeEnum.NPS, + headline: { default: "NPS" }, + } as unknown as TSurveyQuestion, + { + id: "ratingQ", + type: TSurveyQuestionTypeEnum.Rating, + headline: { default: "Rating" }, + } as unknown as TSurveyQuestion, + { + id: "ctaQ", + type: TSurveyQuestionTypeEnum.CTA, + headline: { default: "CTA" }, + } as unknown as TSurveyQuestion, + { + id: "consentQ", + type: TSurveyQuestionTypeEnum.Consent, + headline: { default: "Consent" }, + } as unknown as TSurveyQuestion, + { + id: "pictureQ", + type: TSurveyQuestionTypeEnum.PictureSelection, + headline: { default: "Picture Selection" }, + choices: [ + { id: "p1", imageUrl: "url1" }, + { id: "p2", imageUrl: "url2" }, + ], + } as unknown as TSurveyQuestion, + { + id: "matrixQ", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Matrix" }, + rows: [{ id: "r1", label: "Row 1" }], + columns: [{ id: "c1", label: "Column 1" }], + } as unknown as TSurveyQuestion, + { + id: "addressQ", + type: TSurveyQuestionTypeEnum.Address, + headline: { default: "Address" }, + } as unknown as TSurveyQuestion, + { + id: "contactQ", + type: TSurveyQuestionTypeEnum.ContactInfo, + headline: { default: "Contact Info" }, + } as unknown as TSurveyQuestion, + { + id: "rankingQ", + type: TSurveyQuestionTypeEnum.Ranking, + headline: { default: "Ranking" }, + } as unknown as TSurveyQuestion, + ], + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + status: "draft", + } as unknown as TSurvey; + + const dateRange: DateRange = { + from: new Date("2023-01-01"), + to: new Date("2023-01-31"), + }; + + test("should return empty filters when no selections", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [], + }; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(Object.keys(result).length).toBe(0); + }); + + test("should filter by completed responses", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "complete", + filter: [], + }; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.finished).toBe(true); + }); + + test("should filter by date range", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [], + }; + + const result = getFormattedFilters(survey, selectedFilter, dateRange); + + expect(result.createdAt).toBeDefined(); + expect(result.createdAt?.min).toEqual(dateRange.from); + expect(result.createdAt?.max).toEqual(dateRange.to); + }); + + test("should filter by tags", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { type: "Tags", label: "Tag 1", id: "tag1" }, + filterType: { filterComboBoxValue: "Applied" }, + }, + { + questionType: { type: "Tags", label: "Tag 2", id: "tag2" }, + filterType: { filterComboBoxValue: "Not applied" }, + }, + ] as any, + }; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.tags?.applied).toContain("Tag 1"); + expect(result.tags?.notApplied).toContain("Tag 2"); + }); + + test("should filter by open text questions", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { + type: "Questions", + label: "Open Text", + id: "openTextQ", + questionType: TSurveyQuestionTypeEnum.OpenText, + }, + filterType: { filterComboBoxValue: "Filled out" }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.data?.openTextQ).toEqual({ op: "filledOut" }); + }); + + test("should filter by address questions", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { + type: "Questions", + label: "Address", + id: "addressQ", + questionType: TSurveyQuestionTypeEnum.Address, + }, + filterType: { filterComboBoxValue: "Skipped" }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.data?.addressQ).toEqual({ op: "skipped" }); + }); + + test("should filter by contact info questions", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { + type: "Questions", + label: "Contact Info", + id: "contactQ", + questionType: TSurveyQuestionTypeEnum.ContactInfo, + }, + filterType: { filterComboBoxValue: "Filled out" }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.data?.contactQ).toEqual({ op: "filledOut" }); + }); + + test("should filter by ranking questions", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { + type: "Questions", + label: "Ranking", + id: "rankingQ", + questionType: TSurveyQuestionTypeEnum.Ranking, + }, + filterType: { filterComboBoxValue: "Filled out" }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.data?.rankingQ).toEqual({ op: "submitted" }); + }); + + test("should filter by multiple choice single questions", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { + type: "Questions", + label: "MC Single", + id: "mcSingleQ", + questionType: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + }, + filterType: { filterValue: "Includes either", filterComboBoxValue: ["Choice 1"] }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.data?.mcSingleQ).toEqual({ op: "includesOne", value: ["Choice 1"] }); + }); + + test("should filter by multiple choice multi questions", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { + type: "Questions", + label: "MC Multi", + id: "mcMultiQ", + questionType: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + }, + filterType: { filterValue: "Includes all", filterComboBoxValue: ["Choice 1", "Choice 2"] }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.data?.mcMultiQ).toEqual({ op: "includesAll", value: ["Choice 1", "Choice 2"] }); + }); + + test("should filter by NPS questions with different operations", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { + type: "Questions", + label: "NPS", + id: "npsQ", + questionType: TSurveyQuestionTypeEnum.NPS, + }, + filterType: { filterValue: "Is equal to", filterComboBoxValue: "7" }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.data?.npsQ).toEqual({ op: "equals", value: 7 }); + }); + + test("should filter by rating questions with less than operation", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { + type: "Questions", + label: "Rating", + id: "ratingQ", + questionType: TSurveyQuestionTypeEnum.Rating, + }, + filterType: { filterValue: "Is less than", filterComboBoxValue: "4" }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.data?.ratingQ).toEqual({ op: "lessThan", value: 4 }); + }); + + test("should filter by CTA questions", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { + type: "Questions", + label: "CTA", + id: "ctaQ", + questionType: TSurveyQuestionTypeEnum.CTA, + }, + filterType: { filterComboBoxValue: "Clicked" }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.data?.ctaQ).toEqual({ op: "clicked" }); + }); + + test("should filter by consent questions", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { + type: "Questions", + label: "Consent", + id: "consentQ", + questionType: TSurveyQuestionTypeEnum.Consent, + }, + filterType: { filterComboBoxValue: "Accepted" }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.data?.consentQ).toEqual({ op: "accepted" }); + }); + + test("should filter by picture selection questions", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { + type: "Questions", + label: "Picture", + id: "pictureQ", + questionType: TSurveyQuestionTypeEnum.PictureSelection, + }, + filterType: { filterValue: "Includes either", filterComboBoxValue: ["Picture 1"] }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.data?.pictureQ).toEqual({ op: "includesOne", value: ["p1"] }); + }); + + test("should filter by matrix questions", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { + type: "Questions", + label: "Matrix", + id: "matrixQ", + questionType: TSurveyQuestionTypeEnum.Matrix, + }, + filterType: { filterValue: "Row 1", filterComboBoxValue: "Column 1" }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.data?.matrixQ).toEqual({ op: "matrix", value: { "Row 1": "Column 1" } }); + }); + + test("should filter by hidden fields", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { type: "Hidden Fields", label: "plan", id: "plan" }, + filterType: { filterValue: "Equals", filterComboBoxValue: "pro" }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.data?.plan).toEqual({ op: "equals", value: "pro" }); + }); + + test("should filter by attributes", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { type: "Attributes", label: "role", id: "role" }, + filterType: { filterValue: "Not equals", filterComboBoxValue: "admin" }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.contactAttributes?.role).toEqual({ op: "notEquals", value: "admin" }); + }); + + test("should filter by other filters", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { type: "Other Filters", label: "Language", id: "language" }, + filterType: { filterValue: "Equals", filterComboBoxValue: "en" }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.others?.Language).toEqual({ op: "equals", value: "en" }); + }); + + test("should filter by meta fields", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "all", + filter: [ + { + questionType: { type: "Meta", label: "source", id: "source" }, + filterType: { filterValue: "Not equals", filterComboBoxValue: "web" }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, {} as any); + + expect(result.meta?.source).toEqual({ op: "notEquals", value: "web" }); + }); + + test("should handle multiple filters together", () => { + const selectedFilter: SelectedFilterValue = { + responseStatus: "complete", + filter: [ + { + questionType: { + type: "Questions", + label: "NPS", + id: "npsQ", + questionType: TSurveyQuestionTypeEnum.NPS, + }, + filterType: { filterValue: "Is more than", filterComboBoxValue: "7" }, + }, + { + questionType: { type: "Tags", label: "Tag 1", id: "tag1" }, + filterType: { filterComboBoxValue: "Applied" }, + }, + ], + } as any; + + const result = getFormattedFilters(survey, selectedFilter, dateRange); + + expect(result.finished).toBe(true); + expect(result.createdAt).toBeDefined(); + expect(result.data?.npsQ).toEqual({ op: "greaterThan", value: 7 }); + expect(result.tags?.applied).toContain("Tag 1"); + }); + }); + + describe("getTodayDate", () => { + test("should return today's date with time set to end of day", () => { + const today = new Date(); + const result = getTodayDate(); + + expect(result.getFullYear()).toBe(today.getFullYear()); + expect(result.getMonth()).toBe(today.getMonth()); + expect(result.getDate()).toBe(today.getDate()); + expect(result.getHours()).toBe(23); + expect(result.getMinutes()).toBe(59); + expect(result.getSeconds()).toBe(59); + expect(result.getMilliseconds()).toBe(999); + }); + }); +}); diff --git a/apps/web/app/lib/surveys/surveys.ts b/apps/web/app/lib/surveys/surveys.ts index 5e5ee6d84fb4..065f8c18c4fe 100644 --- a/apps/web/app/lib/surveys/surveys.ts +++ b/apps/web/app/lib/surveys/surveys.ts @@ -242,8 +242,10 @@ export const getFormattedFilters = ( }); // for completed responses - if (selectedFilter.onlyComplete) { + if (selectedFilter.responseStatus === "complete") { filters["finished"] = true; + } else if (selectedFilter.responseStatus === "partial") { + filters["finished"] = false; } // for date range responses diff --git a/apps/web/app/lib/templates.ts b/apps/web/app/lib/templates.ts index f8042c6ad5d7..75022dda4046 100644 --- a/apps/web/app/lib/templates.ts +++ b/apps/web/app/lib/templates.ts @@ -1,1288 +1,639 @@ +import { + buildCTAQuestion, + buildConsentQuestion, + buildMultipleChoiceQuestion, + buildNPSQuestion, + buildOpenTextQuestion, + buildRatingQuestion, + buildSurvey, + createChoiceJumpLogic, + createJumpLogic, + getDefaultEndingCard, + getDefaultSurveyPreset, + hiddenFieldsDefault, +} from "@/app/lib/survey-builder"; +import { createI18nString } from "@/lib/i18n/utils"; import { createId } from "@paralleldrive/cuid2"; import { TFnType } from "@tolgee/react"; -import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils"; -import { - TSurvey, - TSurveyEndScreenCard, - TSurveyHiddenFields, - TSurveyLanguage, - TSurveyOpenTextQuestion, - TSurveyQuestionTypeEnum, - TSurveyWelcomeCard, -} from "@formbricks/types/surveys/types"; +import { TSurvey, TSurveyOpenTextQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { TTemplate } from "@formbricks/types/templates"; -export const getDefaultEndingCard = (languages: TSurveyLanguage[], t: TFnType): TSurveyEndScreenCard => { - const languageCodes = extractLanguageCodes(languages); - return { - id: createId(), - type: "endScreen", - headline: createI18nString(t("templates.default_ending_card_headline"), languageCodes), - subheader: createI18nString(t("templates.default_ending_card_subheader"), languageCodes), - buttonLabel: createI18nString(t("templates.default_ending_card_button_label"), languageCodes), - buttonLink: "https://formbricks.com", - }; -}; - -const hiddenFieldsDefault: TSurveyHiddenFields = { - enabled: true, - fieldIds: [], -}; - -export const getDefaultWelcomeCard = (t: TFnType): TSurveyWelcomeCard => { - return { - enabled: false, - headline: { default: t("templates.default_welcome_card_headline") }, - html: { default: t("templates.default_welcome_card_html") }, - buttonLabel: { default: t("templates.default_welcome_card_button_label") }, - timeToFinish: false, - showResponseCount: false, - }; -}; - -export const getDefaultSurveyPreset = (t: TFnType): TTemplate["preset"] => { - return { - name: "New Survey", - welcomeCard: getDefaultWelcomeCard(t), - endings: [getDefaultEndingCard([], t)], - hiddenFields: hiddenFieldsDefault, - questions: [], - }; -}; - const cartAbandonmentSurvey = (t: TFnType): TTemplate => { const reusableQuestionIds = [createId(), createId(), createId()]; const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.card_abandonment_survey"), - role: "productManager", - industries: ["eCommerce"], - channels: ["app", "website", "link"], - description: t("templates.card_abandonment_survey_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.card_abandonment_survey"), + role: "productManager", + industries: ["eCommerce"], + channels: ["app", "website", "link"], + description: t("templates.card_abandonment_survey_description"), + endings: localSurvey.endings, questions: [ - { + buildCTAQuestion({ id: reusableQuestionIds[0], - html: { - default: t("templates.card_abandonment_survey_question_1_html"), - }, - type: TSurveyQuestionTypeEnum.CTA, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "isSkipped", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.card_abandonment_survey_question_1_headline") }, + html: t("templates.card_abandonment_survey_question_1_html"), + logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")], + headline: t("templates.card_abandonment_survey_question_1_headline"), required: false, - buttonLabel: { default: t("templates.card_abandonment_survey_question_1_button_label") }, + buttonLabel: t("templates.card_abandonment_survey_question_1_button_label"), buttonExternal: false, - dismissButtonLabel: { - default: t("templates.card_abandonment_survey_question_1_dismiss_button_label"), - }, - }, - { - id: createId(), + dismissButtonLabel: t("templates.card_abandonment_survey_question_1_dismiss_button_label"), + t, + }), + buildMultipleChoiceQuestion({ + headline: t("templates.card_abandonment_survey_question_2_headline"), type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.card_abandonment_survey_question_2_headline") }, - subheader: { default: t("templates.card_abandonment_survey_question_2_subheader") }, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - required: true, - shuffleOption: "none", + subheader: t("templates.card_abandonment_survey_question_2_subheader"), choices: [ - { - id: createId(), - label: { default: t("templates.card_abandonment_survey_question_2_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.card_abandonment_survey_question_2_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.card_abandonment_survey_question_2_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.card_abandonment_survey_question_2_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.card_abandonment_survey_question_2_choice_5") }, - }, - { - id: "other", - label: { default: t("templates.card_abandonment_survey_question_2_choice_6") }, - }, - ], - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { - default: t("templates.card_abandonment_survey_question_3_headline"), - }, + t("templates.card_abandonment_survey_question_2_choice_1"), + t("templates.card_abandonment_survey_question_2_choice_2"), + t("templates.card_abandonment_survey_question_2_choice_3"), + t("templates.card_abandonment_survey_question_2_choice_4"), + t("templates.card_abandonment_survey_question_2_choice_5"), + t("templates.card_abandonment_survey_question_2_choice_6"), + ], + containsOther: true, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.card_abandonment_survey_question_3_headline"), required: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, inputType: "text", - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { default: t("templates.card_abandonment_survey_question_4_headline") }, + t, + }), + buildRatingQuestion({ + headline: t("templates.card_abandonment_survey_question_4_headline"), required: true, scale: "number", range: 5, - lowerLabel: { default: t("templates.card_abandonment_survey_question_4_lower_label") }, - upperLabel: { default: t("templates.card_abandonment_survey_question_4_upper_label") }, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - isColorCodingEnabled: false, - }, - { - id: createId(), + lowerLabel: t("templates.card_abandonment_survey_question_4_lower_label"), + upperLabel: t("templates.card_abandonment_survey_question_4_upper_label"), + t, + }), + buildMultipleChoiceQuestion({ type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, - headline: { - default: t("templates.card_abandonment_survey_question_5_headline"), - }, - subheader: { default: t("templates.card_abandonment_survey_question_5_subheader") }, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, + headline: t("templates.card_abandonment_survey_question_5_headline"), + subheader: t("templates.card_abandonment_survey_question_5_subheader"), + required: true, choices: [ - { - id: createId(), - label: { default: t("templates.card_abandonment_survey_question_5_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.card_abandonment_survey_question_5_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.card_abandonment_survey_question_5_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.card_abandonment_survey_question_5_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.card_abandonment_survey_question_5_choice_5") }, - }, - { - id: "other", - label: { default: t("templates.card_abandonment_survey_question_5_choice_6") }, - }, - ], - }, - { + t("templates.card_abandonment_survey_question_5_choice_1"), + t("templates.card_abandonment_survey_question_5_choice_2"), + t("templates.card_abandonment_survey_question_5_choice_3"), + t("templates.card_abandonment_survey_question_5_choice_4"), + t("templates.card_abandonment_survey_question_5_choice_5"), + t("templates.card_abandonment_survey_question_5_choice_6"), + ], + containsOther: true, + t, + }), + buildConsentQuestion({ id: reusableQuestionIds[1], - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSkipped", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[2], - }, - ], - }, - ], - type: TSurveyQuestionTypeEnum.Consent, - headline: { default: t("templates.card_abandonment_survey_question_6_headline") }, + logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[2], "isSkipped")], + headline: t("templates.card_abandonment_survey_question_6_headline"), required: false, - label: { default: t("templates.card_abandonment_survey_question_6_label") }, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.card_abandonment_survey_question_7_headline") }, + label: t("templates.card_abandonment_survey_question_6_label"), + t, + }), + buildOpenTextQuestion({ + headline: t("templates.card_abandonment_survey_question_7_headline"), required: true, inputType: "email", longAnswer: false, - placeholder: { default: "example@email.com" }, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + placeholder: "example@email.com", + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.card_abandonment_survey_question_8_headline") }, + headline: t("templates.card_abandonment_survey_question_8_headline"), required: false, inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, + buttonLabel: t("templates.finish"), + t, + }), ], }, - }; + t + ); }; const siteAbandonmentSurvey = (t: TFnType): TTemplate => { const reusableQuestionIds = [createId(), createId(), createId()]; const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.site_abandonment_survey"), - role: "productManager", - industries: ["eCommerce"], - channels: ["app", "website"], - description: t("templates.site_abandonment_survey_description"), - preset: { - ...localSurvey, + + return buildSurvey( + { name: t("templates.site_abandonment_survey"), + role: "productManager", + industries: ["eCommerce"], + channels: ["app", "website"], + description: t("templates.site_abandonment_survey_description"), + endings: localSurvey.endings, questions: [ - { + buildCTAQuestion({ id: reusableQuestionIds[0], - html: { - default: t("templates.site_abandonment_survey_question_1_html"), - }, - type: TSurveyQuestionTypeEnum.CTA, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "isSkipped", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.site_abandonment_survey_question_2_headline") }, + html: t("templates.site_abandonment_survey_question_1_html"), + logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")], + headline: t("templates.site_abandonment_survey_question_2_headline"), required: false, - buttonLabel: { default: t("templates.site_abandonment_survey_question_2_button_label") }, - backButtonLabel: { default: t("templates.back") }, + buttonLabel: t("templates.site_abandonment_survey_question_2_button_label"), buttonExternal: false, - dismissButtonLabel: { - default: t("templates.site_abandonment_survey_question_2_dismiss_button_label"), - }, - }, - { - id: createId(), + dismissButtonLabel: t("templates.site_abandonment_survey_question_2_dismiss_button_label"), + t, + }), + buildMultipleChoiceQuestion({ type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.site_abandonment_survey_question_3_headline") }, - subheader: { default: t("templates.site_abandonment_survey_question_3_subheader") }, + headline: t("templates.site_abandonment_survey_question_3_headline"), + subheader: t("templates.site_abandonment_survey_question_3_subheader"), required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, shuffleOption: "none", choices: [ - { - id: createId(), - label: { default: t("templates.site_abandonment_survey_question_3_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.site_abandonment_survey_question_3_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.site_abandonment_survey_question_3_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.site_abandonment_survey_question_3_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.site_abandonment_survey_question_3_choice_5") }, - }, - { - id: "other", - label: { default: t("templates.site_abandonment_survey_question_3_choice_6") }, - }, - ], - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { - default: t("templates.site_abandonment_survey_question_4_headline"), - }, + t("templates.site_abandonment_survey_question_3_choice_1"), + t("templates.site_abandonment_survey_question_3_choice_2"), + t("templates.site_abandonment_survey_question_3_choice_3"), + t("templates.site_abandonment_survey_question_3_choice_4"), + t("templates.site_abandonment_survey_question_3_choice_5"), + t("templates.site_abandonment_survey_question_3_choice_6"), + ], + containsOther: true, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.site_abandonment_survey_question_4_headline"), required: false, inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { default: t("templates.site_abandonment_survey_question_5_headline") }, + t, + }), + buildRatingQuestion({ + headline: t("templates.site_abandonment_survey_question_5_headline"), required: true, scale: "number", range: 5, - lowerLabel: { default: t("templates.site_abandonment_survey_question_5_lower_label") }, - upperLabel: { default: t("templates.site_abandonment_survey_question_5_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), + lowerLabel: t("templates.site_abandonment_survey_question_5_lower_label"), + upperLabel: t("templates.site_abandonment_survey_question_5_upper_label"), + t, + }), + buildMultipleChoiceQuestion({ type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, - headline: { - default: t("templates.site_abandonment_survey_question_6_headline"), - }, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - subheader: { default: t("templates.site_abandonment_survey_question_6_subheader") }, + headline: t("templates.site_abandonment_survey_question_6_headline"), + subheader: t("templates.site_abandonment_survey_question_6_subheader"), required: true, choices: [ - { - id: createId(), - label: { default: t("templates.site_abandonment_survey_question_6_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.site_abandonment_survey_question_6_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.site_abandonment_survey_question_6_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.site_abandonment_survey_question_6_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.site_abandonment_survey_question_6_choice_5") }, - }, - { - id: "other", - label: { default: t("templates.site_abandonment_survey_question_6_choice_6") }, - }, - ], - }, - { + t("templates.site_abandonment_survey_question_6_choice_1"), + t("templates.site_abandonment_survey_question_6_choice_2"), + t("templates.site_abandonment_survey_question_6_choice_3"), + t("templates.site_abandonment_survey_question_6_choice_4"), + t("templates.site_abandonment_survey_question_6_choice_5"), + ], + t, + }), + buildConsentQuestion({ id: reusableQuestionIds[1], - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSkipped", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[2], - }, - ], - }, - ], - type: TSurveyQuestionTypeEnum.Consent, - headline: { default: t("templates.site_abandonment_survey_question_7_headline") }, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, + logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[2], "isSkipped")], + headline: t("templates.site_abandonment_survey_question_7_headline"), required: false, - label: { default: t("templates.site_abandonment_survey_question_7_label") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.site_abandonment_survey_question_8_headline") }, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, + label: t("templates.site_abandonment_survey_question_7_label"), + t, + }), + buildOpenTextQuestion({ + headline: t("templates.site_abandonment_survey_question_8_headline"), required: true, inputType: "email", longAnswer: false, - placeholder: { default: "example@email.com" }, - }, - { + placeholder: "example@email.com", + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.site_abandonment_survey_question_9_headline") }, - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, + headline: t("templates.site_abandonment_survey_question_9_headline"), required: false, inputType: "text", - }, + t, + }), ], }, - }; + t + ); }; const productMarketFitSuperhuman = (t: TFnType): TTemplate => { const reusableQuestionIds = [createId()]; const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.product_market_fit_superhuman"), - role: "productManager", - industries: ["saas"], - channels: ["app", "link"], - description: t("templates.product_market_fit_superhuman_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.product_market_fit_superhuman"), + role: "productManager", + industries: ["saas"], + channels: ["app", "link"], + description: t("templates.product_market_fit_superhuman_description"), + endings: localSurvey.endings, questions: [ - { + buildCTAQuestion({ id: reusableQuestionIds[0], - html: { - default: t("templates.product_market_fit_superhuman_question_1_html"), - }, - type: TSurveyQuestionTypeEnum.CTA, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "isSkipped", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.product_market_fit_superhuman_question_1_headline") }, + html: t("templates.product_market_fit_superhuman_question_1_html"), + logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")], + headline: t("templates.product_market_fit_superhuman_question_1_headline"), required: false, - buttonLabel: { - default: t("templates.product_market_fit_superhuman_question_1_button_label"), - }, - backButtonLabel: { default: t("templates.back") }, + buttonLabel: t("templates.product_market_fit_superhuman_question_1_button_label"), buttonExternal: false, - dismissButtonLabel: { - default: t("templates.product_market_fit_superhuman_question_1_dismiss_button_label"), - }, - }, - { - id: createId(), + dismissButtonLabel: t("templates.product_market_fit_superhuman_question_1_dismiss_button_label"), + t, + }), + buildMultipleChoiceQuestion({ type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.product_market_fit_superhuman_question_2_headline") }, - subheader: { default: t("templates.product_market_fit_superhuman_question_2_subheader") }, + headline: t("templates.product_market_fit_superhuman_question_2_headline"), + subheader: t("templates.product_market_fit_superhuman_question_2_subheader"), required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, shuffleOption: "none", choices: [ - { - id: createId(), - label: { default: t("templates.product_market_fit_superhuman_question_2_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.product_market_fit_superhuman_question_2_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.product_market_fit_superhuman_question_2_choice_3") }, - }, + t("templates.product_market_fit_superhuman_question_2_choice_1"), + t("templates.product_market_fit_superhuman_question_2_choice_2"), + t("templates.product_market_fit_superhuman_question_2_choice_3"), ], - }, - { - id: createId(), + t, + }), + buildMultipleChoiceQuestion({ type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.product_market_fit_superhuman_question_3_headline") }, - subheader: { default: t("templates.product_market_fit_superhuman_question_3_subheader") }, + headline: t("templates.product_market_fit_superhuman_question_3_headline"), + subheader: t("templates.product_market_fit_superhuman_question_3_subheader"), required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, shuffleOption: "none", choices: [ - { - id: createId(), - label: { default: t("templates.product_market_fit_superhuman_question_3_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.product_market_fit_superhuman_question_3_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.product_market_fit_superhuman_question_3_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.product_market_fit_superhuman_question_3_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.product_market_fit_superhuman_question_3_choice_5") }, - }, + t("templates.product_market_fit_superhuman_question_3_choice_1"), + t("templates.product_market_fit_superhuman_question_3_choice_2"), + t("templates.product_market_fit_superhuman_question_3_choice_3"), + t("templates.product_market_fit_superhuman_question_3_choice_4"), + t("templates.product_market_fit_superhuman_question_3_choice_5"), ], - }, - { + t, + }), + buildOpenTextQuestion({ id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.product_market_fit_superhuman_question_4_headline") }, + headline: t("templates.product_market_fit_superhuman_question_4_headline"), required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, inputType: "text", - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.product_market_fit_superhuman_question_5_headline") }, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.product_market_fit_superhuman_question_5_headline"), required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, inputType: "text", - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.product_market_fit_superhuman_question_6_headline") }, - subheader: { default: t("templates.product_market_fit_superhuman_question_6_subheader") }, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.product_market_fit_superhuman_question_6_headline"), + subheader: t("templates.product_market_fit_superhuman_question_6_subheader"), required: true, - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, inputType: "text", - }, + t, + }), ], }, - }; + t + ); }; const onboardingSegmentation = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.onboarding_segmentation"), - role: "productManager", - industries: ["saas"], - channels: ["app", "link"], - description: t("templates.onboarding_segmentation_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.onboarding_segmentation"), + role: "productManager", + industries: ["saas"], + channels: ["app", "link"], + description: t("templates.onboarding_segmentation_description"), questions: [ - { - id: createId(), + buildMultipleChoiceQuestion({ type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.onboarding_segmentation_question_1_headline") }, - subheader: { default: t("templates.onboarding_segmentation_question_1_subheader") }, + headline: t("templates.onboarding_segmentation_question_1_headline"), + subheader: t("templates.onboarding_segmentation_question_1_subheader"), required: true, shuffleOption: "none", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, choices: [ - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_1_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_1_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_1_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_1_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_1_choice_5") }, - }, - ], - }, - { - id: createId(), + t("templates.onboarding_segmentation_question_1_choice_1"), + t("templates.onboarding_segmentation_question_1_choice_2"), + t("templates.onboarding_segmentation_question_1_choice_3"), + t("templates.onboarding_segmentation_question_1_choice_4"), + t("templates.onboarding_segmentation_question_1_choice_5"), + ], + t, + }), + buildMultipleChoiceQuestion({ type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.onboarding_segmentation_question_2_headline") }, - subheader: { default: t("templates.onboarding_segmentation_question_2_subheader") }, + headline: t("templates.onboarding_segmentation_question_2_headline"), + subheader: t("templates.onboarding_segmentation_question_2_subheader"), required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, shuffleOption: "none", choices: [ - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_2_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_2_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_2_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_2_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_2_choice_5") }, - }, - ], - }, - { - id: createId(), + t("templates.onboarding_segmentation_question_2_choice_1"), + t("templates.onboarding_segmentation_question_2_choice_2"), + t("templates.onboarding_segmentation_question_2_choice_3"), + t("templates.onboarding_segmentation_question_2_choice_4"), + t("templates.onboarding_segmentation_question_2_choice_5"), + ], + t, + }), + buildMultipleChoiceQuestion({ type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.onboarding_segmentation_question_3_headline") }, - subheader: { default: t("templates.onboarding_segmentation_question_3_subheader") }, + headline: t("templates.onboarding_segmentation_question_3_headline"), + subheader: t("templates.onboarding_segmentation_question_3_subheader"), required: true, - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, + buttonLabel: t("templates.finish"), shuffleOption: "none", choices: [ - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_3_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_3_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_3_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_3_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.onboarding_segmentation_question_3_choice_5") }, - }, - ], - }, + t("templates.onboarding_segmentation_question_3_choice_1"), + t("templates.onboarding_segmentation_question_3_choice_2"), + t("templates.onboarding_segmentation_question_3_choice_3"), + t("templates.onboarding_segmentation_question_3_choice_4"), + t("templates.onboarding_segmentation_question_3_choice_5"), + ], + t, + }), ], }, - }; + t + ); }; const churnSurvey = (t: TFnType): TTemplate => { const reusableQuestionIds = [createId(), createId(), createId(), createId(), createId()]; const reusableOptionIds = [createId(), createId(), createId(), createId(), createId()]; const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.churn_survey"), - role: "sales", - industries: ["saas", "eCommerce", "other"], - channels: ["app", "link"], - description: t("templates.churn_survey_description"), - preset: { - ...localSurvey, - name: "Churn Survey", + return buildSurvey( + { + name: t("templates.churn_survey"), + role: "sales", + industries: ["saas", "eCommerce", "other"], + channels: ["app", "link"], + description: t("templates.churn_survey_description"), + endings: localSurvey.endings, questions: [ - { + buildMultipleChoiceQuestion({ id: reusableQuestionIds[0], type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, shuffleOption: "none", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[0], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[1], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[1], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[2], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[2], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[3], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[3], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[4], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[4], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[0], reusableQuestionIds[1]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[1], reusableQuestionIds[2]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[2], reusableQuestionIds[3]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[3], reusableQuestionIds[4]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[4], localSurvey.endings[0].id), ], choices: [ - { - id: reusableOptionIds[0], - label: { default: t("templates.churn_survey_question_1_choice_1") }, - }, - { - id: reusableOptionIds[1], - label: { default: t("templates.churn_survey_question_1_choice_2") }, - }, - { - id: reusableOptionIds[2], - label: { default: t("templates.churn_survey_question_1_choice_3") }, - }, - { - id: reusableOptionIds[3], - label: { default: t("templates.churn_survey_question_1_choice_4") }, - }, - { - id: reusableOptionIds[4], - label: { default: t("templates.churn_survey_question_1_choice_5") }, - }, - ], - headline: { default: t("templates.churn_survey_question_1_headline") }, - required: true, - subheader: { default: t("templates.churn_survey_question_1_subheader") }, - }, - { + t("templates.churn_survey_question_1_choice_1"), + t("templates.churn_survey_question_1_choice_2"), + t("templates.churn_survey_question_1_choice_3"), + t("templates.churn_survey_question_1_choice_4"), + t("templates.churn_survey_question_1_choice_5"), + ], + choiceIds: [ + reusableOptionIds[0], + reusableOptionIds[1], + reusableOptionIds[2], + reusableOptionIds[3], + reusableOptionIds[4], + ], + headline: t("templates.churn_survey_question_1_headline"), + required: true, + subheader: t("templates.churn_survey_question_1_subheader"), + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.churn_survey_question_2_headline") }, - backButtonLabel: { default: t("templates.back") }, + logic: [createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isSubmitted")], + headline: t("templates.churn_survey_question_2_headline"), required: true, - buttonLabel: { default: t("templates.churn_survey_question_2_button_label") }, + buttonLabel: t("templates.churn_survey_question_2_button_label"), inputType: "text", - }, - { + t, + }), + buildCTAQuestion({ id: reusableQuestionIds[2], - html: { - default: t("templates.churn_survey_question_3_html"), - }, - type: TSurveyQuestionTypeEnum.CTA, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[2], - type: "question", - }, - operator: "isClicked", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.churn_survey_question_3_headline") }, + html: t("templates.churn_survey_question_3_html"), + logic: [createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isClicked")], + headline: t("templates.churn_survey_question_3_headline"), required: true, buttonUrl: "https://formbricks.com", - buttonLabel: { default: t("templates.churn_survey_question_3_button_label") }, + buttonLabel: t("templates.churn_survey_question_3_button_label"), buttonExternal: true, - backButtonLabel: { default: t("templates.back") }, - dismissButtonLabel: { default: t("templates.churn_survey_question_3_dismiss_button_label") }, - }, - { + dismissButtonLabel: t("templates.churn_survey_question_3_dismiss_button_label"), + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[3], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[3], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.churn_survey_question_4_headline") }, + logic: [createJumpLogic(reusableQuestionIds[3], localSurvey.endings[0].id, "isSubmitted")], + headline: t("templates.churn_survey_question_4_headline"), required: true, inputType: "text", - }, - { + t, + }), + buildCTAQuestion({ id: reusableQuestionIds[4], - html: { - default: t("templates.churn_survey_question_5_html"), - }, - type: TSurveyQuestionTypeEnum.CTA, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[4], - type: "question", - }, - operator: "isClicked", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.churn_survey_question_5_headline") }, + html: t("templates.churn_survey_question_5_html"), + logic: [createJumpLogic(reusableQuestionIds[4], localSurvey.endings[0].id, "isClicked")], + headline: t("templates.churn_survey_question_5_headline"), required: true, buttonUrl: "mailto:ceo@company.com", - buttonLabel: { default: t("templates.churn_survey_question_5_button_label") }, + buttonLabel: t("templates.churn_survey_question_5_button_label"), buttonExternal: true, - dismissButtonLabel: { default: t("templates.churn_survey_question_5_dismiss_button_label") }, - backButtonLabel: { default: t("templates.back") }, - }, + dismissButtonLabel: t("templates.churn_survey_question_5_dismiss_button_label"), + t, + }), ], }, - }; + t + ); }; const earnedAdvocacyScore = (t: TFnType): TTemplate => { const reusableQuestionIds = [createId(), createId(), createId(), createId()]; const reusableOptionIds = [createId(), createId(), createId(), createId()]; const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.earned_advocacy_score_name"), - role: "customerSuccess", - industries: ["saas", "eCommerce", "other"], - channels: ["app", "link"], - description: t("templates.earned_advocacy_score_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.earned_advocacy_score_name"), + role: "customerSuccess", + industries: ["saas", "eCommerce", "other"], + channels: ["app", "link"], + description: t("templates.earned_advocacy_score_description"), + endings: localSurvey.endings, questions: [ - { + buildMultipleChoiceQuestion({ id: reusableQuestionIds[0], type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[1], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[2], - }, - ], - }, + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[1], reusableQuestionIds[2]), ], shuffleOption: "none", choices: [ - { - id: reusableOptionIds[0], - label: { default: t("templates.earned_advocacy_score_question_1_choice_1") }, - }, - { - id: reusableOptionIds[1], - label: { default: t("templates.earned_advocacy_score_question_1_choice_2") }, - }, + t("templates.earned_advocacy_score_question_1_choice_1"), + t("templates.earned_advocacy_score_question_1_choice_2"), ], - headline: { default: t("templates.earned_advocacy_score_question_1_headline") }, + choiceIds: [reusableOptionIds[0], reusableOptionIds[1]], + headline: t("templates.earned_advocacy_score_question_1_headline"), required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[3], - }, - ], - }, - ], - headline: { default: t("templates.earned_advocacy_score_question_2_headline") }, + logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[3], "isSubmitted")], + headline: t("templates.earned_advocacy_score_question_2_headline"), required: true, - placeholder: { default: t("templates.earned_advocacy_score_question_2_placeholder") }, + placeholder: t("templates.earned_advocacy_score_question_2_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.earned_advocacy_score_question_3_headline") }, + headline: t("templates.earned_advocacy_score_question_3_headline"), required: true, - placeholder: { default: t("templates.earned_advocacy_score_question_3_placeholder") }, + placeholder: t("templates.earned_advocacy_score_question_3_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + t, + }), + buildMultipleChoiceQuestion({ id: reusableQuestionIds[3], type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[3], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[3], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, + createChoiceJumpLogic(reusableQuestionIds[3], reusableOptionIds[3], localSurvey.endings[0].id), ], shuffleOption: "none", choices: [ - { - id: reusableOptionIds[2], - label: { default: t("templates.earned_advocacy_score_question_4_choice_1") }, - }, - { - id: reusableOptionIds[3], - label: { default: t("templates.earned_advocacy_score_question_4_choice_2") }, - }, + t("templates.earned_advocacy_score_question_4_choice_1"), + t("templates.earned_advocacy_score_question_4_choice_2"), ], - headline: { default: t("templates.earned_advocacy_score_question_4_headline") }, + choiceIds: [reusableOptionIds[2], reusableOptionIds[3]], + headline: t("templates.earned_advocacy_score_question_4_headline"), required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.earned_advocacy_score_question_5_headline") }, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.earned_advocacy_score_question_5_headline"), required: true, - placeholder: { default: t("templates.earned_advocacy_score_question_5_placeholder") }, + placeholder: t("templates.earned_advocacy_score_question_5_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, + buttonLabel: t("templates.finish"), + t, + }), ], }, - }; + t + ); +}; + +const usabilityScoreRatingSurvey = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.usability_score_name"), + role: "customerSuccess", + industries: ["saas"], + channels: ["app", "link"], + description: t("templates.usability_rating_description"), + questions: [ + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.usability_question_1_headline"), + required: true, + lowerLabel: t("templates.strongly_disagree"), + upperLabel: t("templates.strongly_agree"), + isColorCodingEnabled: false, + t, + }), + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.usability_question_2_headline"), + required: true, + lowerLabel: t("templates.strongly_disagree"), + upperLabel: t("templates.strongly_agree"), + isColorCodingEnabled: false, + t, + }), + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.usability_question_3_headline"), + required: true, + lowerLabel: t("templates.strongly_disagree"), + upperLabel: t("templates.strongly_agree"), + isColorCodingEnabled: false, + t, + }), + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.usability_question_4_headline"), + required: true, + lowerLabel: t("templates.strongly_disagree"), + upperLabel: t("templates.strongly_agree"), + isColorCodingEnabled: false, + t, + }), + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.usability_question_5_headline"), + required: true, + lowerLabel: t("templates.strongly_disagree"), + upperLabel: t("templates.strongly_agree"), + isColorCodingEnabled: false, + t, + }), + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.usability_question_6_headline"), + required: true, + lowerLabel: t("templates.strongly_disagree"), + upperLabel: t("templates.strongly_agree"), + isColorCodingEnabled: false, + t, + }), + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.usability_question_7_headline"), + required: true, + lowerLabel: t("templates.strongly_disagree"), + upperLabel: t("templates.strongly_agree"), + isColorCodingEnabled: false, + t, + }), + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.usability_question_8_headline"), + required: true, + lowerLabel: t("templates.strongly_disagree"), + upperLabel: t("templates.strongly_agree"), + isColorCodingEnabled: false, + t, + }), + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.usability_question_9_headline"), + required: true, + lowerLabel: t("templates.strongly_disagree"), + upperLabel: t("templates.strongly_agree"), + isColorCodingEnabled: false, + t, + }), + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.usability_question_10_headline"), + required: true, + lowerLabel: t("templates.strongly_disagree"), + upperLabel: t("templates.strongly_agree"), + isColorCodingEnabled: false, + t, + }), + ], + }, + t + ); }; const improveTrialConversion = (t: TFnType): TTemplate => { @@ -1297,20 +648,119 @@ const improveTrialConversion = (t: TFnType): TTemplate => { createId(), ]; const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.improve_trial_conversion_name"), - role: "sales", - industries: ["saas"], - channels: ["link", "app"], - description: t("templates.improve_trial_conversion_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.improve_trial_conversion_name"), + role: "sales", + industries: ["saas"], + channels: ["link", "app"], + description: t("templates.improve_trial_conversion_description"), + endings: localSurvey.endings, questions: [ - { + buildMultipleChoiceQuestion({ id: reusableQuestionIds[0], type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, shuffleOption: "none", + logic: [ + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[0], reusableQuestionIds[1]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[1], reusableQuestionIds[2]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[2], reusableQuestionIds[3]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[3], reusableQuestionIds[4]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[4], localSurvey.endings[0].id), + ], + choices: [ + t("templates.improve_trial_conversion_question_1_choice_1"), + t("templates.improve_trial_conversion_question_1_choice_2"), + t("templates.improve_trial_conversion_question_1_choice_3"), + t("templates.improve_trial_conversion_question_1_choice_4"), + t("templates.improve_trial_conversion_question_1_choice_5"), + ], + choiceIds: [ + reusableOptionIds[0], + reusableOptionIds[1], + reusableOptionIds[2], + reusableOptionIds[3], + reusableOptionIds[4], + ], + headline: t("templates.improve_trial_conversion_question_1_headline"), + required: true, + subheader: t("templates.improve_trial_conversion_question_1_subheader"), + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[1], + logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[5], "isSubmitted")], + headline: t("templates.improve_trial_conversion_question_2_headline"), + required: true, + buttonLabel: t("templates.improve_trial_conversion_question_2_button_label"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[2], + logic: [createJumpLogic(reusableQuestionIds[2], reusableQuestionIds[5], "isSubmitted")], + headline: t("templates.improve_trial_conversion_question_2_headline"), + required: true, + buttonLabel: t("templates.improve_trial_conversion_question_2_button_label"), + inputType: "text", + t, + }), + buildCTAQuestion({ + id: reusableQuestionIds[3], + html: t("templates.improve_trial_conversion_question_4_html"), + logic: [createJumpLogic(reusableQuestionIds[3], localSurvey.endings[0].id, "isClicked")], + headline: t("templates.improve_trial_conversion_question_4_headline"), + required: true, + buttonUrl: "https://formbricks.com/github", + buttonLabel: t("templates.improve_trial_conversion_question_4_button_label"), + buttonExternal: true, + dismissButtonLabel: t("templates.improve_trial_conversion_question_4_dismiss_button_label"), + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[4], + logic: [createJumpLogic(reusableQuestionIds[4], reusableQuestionIds[5], "isSubmitted")], + headline: t("templates.improve_trial_conversion_question_5_headline"), + required: true, + subheader: t("templates.improve_trial_conversion_question_5_subheader"), + buttonLabel: t("templates.improve_trial_conversion_question_5_button_label"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[5], + logic: [ + createJumpLogic(reusableQuestionIds[5], localSurvey.endings[0].id, "isSubmitted"), + createJumpLogic(reusableQuestionIds[5], localSurvey.endings[0].id, "isSkipped"), + ], + headline: t("templates.improve_trial_conversion_question_6_headline"), + required: false, + subheader: t("templates.improve_trial_conversion_question_6_subheader"), + inputType: "text", + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const reviewPrompt = (t: TFnType): TTemplate => { + const localSurvey = getDefaultSurveyPreset(t); + const reusableQuestionIds = [createId(), createId(), createId()]; + + return buildSurvey( + { + name: t("templates.review_prompt_name"), + role: "marketing", + industries: ["saas", "eCommerce", "other"], + channels: ["link", "app"], + description: t("templates.review_prompt_description"), + endings: localSurvey.endings, + questions: [ + buildRatingQuestion({ + id: reusableQuestionIds[0], logic: [ { id: createId(), @@ -1324,38 +774,10 @@ const improveTrialConversion = (t: TFnType): TTemplate => { value: reusableQuestionIds[0], type: "question", }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[0], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[1], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", + operator: "isLessThanOrEqual", rightOperand: { type: "static", - value: reusableOptionIds[1], + value: 3, }, }, ], @@ -1368,4106 +790,600 @@ const improveTrialConversion = (t: TFnType): TTemplate => { }, ], }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[2], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[3], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[3], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[4], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[4], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - choices: [ - { - id: reusableOptionIds[0], - label: { default: t("templates.improve_trial_conversion_question_1_choice_1") }, - }, - { - id: reusableOptionIds[1], - label: { default: t("templates.improve_trial_conversion_question_1_choice_2") }, - }, - { - id: reusableOptionIds[2], - label: { default: t("templates.improve_trial_conversion_question_1_choice_3") }, - }, - { - id: reusableOptionIds[3], - label: { default: t("templates.improve_trial_conversion_question_1_choice_4") }, - }, - { - id: reusableOptionIds[4], - label: { default: t("templates.improve_trial_conversion_question_1_choice_5") }, - }, ], - headline: { default: t("templates.improve_trial_conversion_question_1_headline") }, + range: 5, + scale: "star", + headline: t("templates.review_prompt_question_1_headline"), required: true, - subheader: { default: t("templates.improve_trial_conversion_question_1_subheader") }, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + lowerLabel: t("templates.review_prompt_question_1_lower_label"), + upperLabel: t("templates.review_prompt_question_1_upper_label"), + isColorCodingEnabled: false, + t, + }), + buildCTAQuestion({ id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[5], - }, - ], - }, - ], - headline: { default: t("templates.improve_trial_conversion_question_2_headline") }, + html: t("templates.review_prompt_question_2_html"), + logic: [createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isClicked")], + headline: t("templates.review_prompt_question_2_headline"), required: true, - buttonLabel: { default: t("templates.improve_trial_conversion_question_2_button_label") }, - inputType: "text", - backButtonLabel: { default: t("templates.back") }, - }, - { + buttonUrl: "https://formbricks.com/github", + buttonLabel: t("templates.review_prompt_question_2_button_label"), + buttonExternal: true, + backButtonLabel: t("templates.back"), + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[2], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[5], - }, - ], - }, - ], - headline: { default: t("templates.improve_trial_conversion_question_2_headline") }, - required: true, - buttonLabel: { default: t("templates.improve_trial_conversion_question_2_button_label") }, - inputType: "text", - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[3], - html: { - default: t("templates.improve_trial_conversion_question_4_html"), - }, - type: TSurveyQuestionTypeEnum.CTA, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[3], - type: "question", - }, - operator: "isClicked", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.improve_trial_conversion_question_4_headline") }, - required: true, - buttonUrl: "https://formbricks.com/github", - buttonLabel: { default: t("templates.improve_trial_conversion_question_4_button_label") }, - buttonExternal: true, - dismissButtonLabel: { - default: t("templates.improve_trial_conversion_question_4_dismiss_button_label"), - }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[4], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[4], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[5], - }, - ], - }, - ], - headline: { default: t("templates.improve_trial_conversion_question_5_headline") }, - required: true, - subheader: { default: t("templates.improve_trial_conversion_question_5_subheader") }, - buttonLabel: { default: t("templates.improve_trial_conversion_question_5_button_label") }, - inputType: "text", - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[5], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[5], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[5], - type: "question", - }, - operator: "isSkipped", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.improve_trial_conversion_question_6_headline") }, - required: false, - subheader: { default: t("templates.improve_trial_conversion_question_6_subheader") }, - inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const reviewPrompt = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - const reusableQuestionIds = [createId(), createId(), createId()]; - - return { - name: t("templates.review_prompt_name"), - role: "marketing", - industries: ["saas", "eCommerce", "other"], - channels: ["link", "app"], - description: t("templates.review_prompt_description"), - preset: { - ...localSurvey, - name: t("templates.review_prompt_name"), - questions: [ - { - id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.Rating, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "isLessThanOrEqual", - rightOperand: { - type: "static", - value: 3, - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[2], - }, - ], - }, - ], - range: 5, - scale: "star", - headline: { default: t("templates.review_prompt_question_1_headline") }, - required: true, - lowerLabel: { default: t("templates.review_prompt_question_1_lower_label") }, - upperLabel: { default: t("templates.review_prompt_question_1_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[1], - html: { default: t("templates.review_prompt_question_2_html") }, - type: TSurveyQuestionTypeEnum.CTA, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isClicked", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.review_prompt_question_2_headline") }, - required: true, - buttonUrl: "https://formbricks.com/github", - buttonLabel: { default: t("templates.review_prompt_question_2_button_label") }, - buttonExternal: true, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.review_prompt_question_3_headline") }, + headline: t("templates.review_prompt_question_3_headline"), required: true, - subheader: { default: t("templates.review_prompt_question_3_subheader") }, - buttonLabel: { default: t("templates.review_prompt_question_3_button_label") }, - placeholder: { default: t("templates.review_prompt_question_3_placeholder") }, + subheader: t("templates.review_prompt_question_3_subheader"), + buttonLabel: t("templates.review_prompt_question_3_button_label"), + placeholder: t("templates.review_prompt_question_3_placeholder"), inputType: "text", - backButtonLabel: { default: t("templates.back") }, - }, + t, + }), ], }, - }; + t + ); }; const interviewPrompt = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.interview_prompt_name"), - role: "productManager", - industries: ["saas"], - channels: ["app"], - description: t("templates.interview_prompt_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.interview_prompt_name"), + role: "productManager", + industries: ["saas"], + channels: ["app"], + description: t("templates.interview_prompt_description"), questions: [ - { + buildCTAQuestion({ id: createId(), - type: TSurveyQuestionTypeEnum.CTA, - headline: { default: t("templates.interview_prompt_question_1_headline") }, - html: { default: t("templates.interview_prompt_question_1_html") }, - buttonLabel: { default: t("templates.interview_prompt_question_1_button_label") }, + headline: t("templates.interview_prompt_question_1_headline"), + html: t("templates.interview_prompt_question_1_html"), + buttonLabel: t("templates.interview_prompt_question_1_button_label"), buttonUrl: "https://cal.com/johannes", buttonExternal: true, required: false, - backButtonLabel: { default: t("templates.back") }, - }, + t, + }), ], }, - }; + t + ); }; const improveActivationRate = (t: TFnType): TTemplate => { const reusableQuestionIds = [createId(), createId(), createId(), createId(), createId(), createId()]; const reusableOptionIds = [createId(), createId(), createId(), createId(), createId()]; const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.improve_activation_rate_name"), - role: "productManager", - industries: ["saas"], - channels: ["link"], - description: t("templates.improve_activation_rate_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.improve_activation_rate_name"), + role: "productManager", + industries: ["saas"], + channels: ["link"], + description: t("templates.improve_activation_rate_description"), + endings: localSurvey.endings, questions: [ - { + buildMultipleChoiceQuestion({ id: reusableQuestionIds[0], type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, shuffleOption: "none", logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[1], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[2], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[2], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[3], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[3], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[4], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[4], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[5], - }, - ], - }, + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[1], reusableQuestionIds[2]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[2], reusableQuestionIds[3]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[3], reusableQuestionIds[4]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[4], reusableQuestionIds[5]), ], choices: [ - { - id: reusableOptionIds[0], - label: { default: t("templates.improve_activation_rate_question_1_choice_1") }, - }, - { - id: reusableOptionIds[1], - label: { default: t("templates.improve_activation_rate_question_1_choice_2") }, - }, - { - id: reusableOptionIds[2], - label: { default: t("templates.improve_activation_rate_question_1_choice_3") }, - }, - { - id: reusableOptionIds[3], - label: { default: t("templates.improve_activation_rate_question_1_choice_4") }, - }, - { - id: reusableOptionIds[4], - label: { default: t("templates.improve_activation_rate_question_1_choice_5") }, - }, - ], - headline: { - default: t("templates.improve_activation_rate_question_1_headline"), - }, - required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + t("templates.improve_activation_rate_question_1_choice_1"), + t("templates.improve_activation_rate_question_1_choice_2"), + t("templates.improve_activation_rate_question_1_choice_3"), + t("templates.improve_activation_rate_question_1_choice_4"), + t("templates.improve_activation_rate_question_1_choice_5"), + ], + choiceIds: [ + reusableOptionIds[0], + reusableOptionIds[1], + reusableOptionIds[2], + reusableOptionIds[3], + reusableOptionIds[4], + ], + headline: t("templates.improve_activation_rate_question_1_headline"), + required: true, + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.improve_activation_rate_question_2_headline") }, + logic: [createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isSubmitted")], + headline: t("templates.improve_activation_rate_question_2_headline"), required: true, - placeholder: { default: t("templates.improve_activation_rate_question_2_placeholder") }, + placeholder: t("templates.improve_activation_rate_question_2_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[2], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.improve_activation_rate_question_3_headline") }, + logic: [createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isSubmitted")], + headline: t("templates.improve_activation_rate_question_3_headline"), required: true, - placeholder: { default: t("templates.improve_activation_rate_question_3_placeholder") }, + placeholder: t("templates.improve_activation_rate_question_3_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[3], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[3], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.improve_activation_rate_question_4_headline") }, + logic: [createJumpLogic(reusableQuestionIds[3], localSurvey.endings[0].id, "isSubmitted")], + headline: t("templates.improve_activation_rate_question_4_headline"), required: true, - placeholder: { default: t("templates.improve_activation_rate_question_4_placeholder") }, + placeholder: t("templates.improve_activation_rate_question_4_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[4], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[4], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.improve_activation_rate_question_5_headline") }, + logic: [createJumpLogic(reusableQuestionIds[4], localSurvey.endings[0].id, "isSubmitted")], + headline: t("templates.improve_activation_rate_question_5_headline"), required: true, - placeholder: { default: t("templates.improve_activation_rate_question_5_placeholder") }, + placeholder: t("templates.improve_activation_rate_question_5_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[5], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, logic: [], - headline: { default: t("templates.improve_activation_rate_question_6_headline") }, + headline: t("templates.improve_activation_rate_question_6_headline"), required: false, - subheader: { default: t("templates.improve_activation_rate_question_6_subheader") }, - placeholder: { default: t("templates.improve_activation_rate_question_6_placeholder") }, + subheader: t("templates.improve_activation_rate_question_6_subheader"), + placeholder: t("templates.improve_activation_rate_question_6_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, + buttonLabel: t("templates.finish"), + t, + }), ], }, - }; + t + ); }; const employeeSatisfaction = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.employee_satisfaction_name"), - role: "peopleManager", - industries: ["saas", "eCommerce", "other"], - channels: ["app", "link"], - description: t("templates.employee_satisfaction_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.employee_satisfaction_name"), + role: "peopleManager", + industries: ["saas", "eCommerce", "other"], + channels: ["app", "link"], + description: t("templates.employee_satisfaction_description"), questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, + buildRatingQuestion({ range: 5, scale: "star", - headline: { default: t("templates.employee_satisfaction_question_1_headline") }, + headline: t("templates.employee_satisfaction_question_1_headline"), required: true, - lowerLabel: { default: t("templates.employee_satisfaction_question_1_lower_label") }, - upperLabel: { default: t("templates.employee_satisfaction_question_1_upper_label") }, + lowerLabel: t("templates.employee_satisfaction_question_1_lower_label"), + upperLabel: t("templates.employee_satisfaction_question_1_upper_label"), isColorCodingEnabled: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), + t, + }), + buildMultipleChoiceQuestion({ type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, shuffleOption: "none", choices: [ - { - id: createId(), - label: { default: t("templates.employee_satisfaction_question_2_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.employee_satisfaction_question_2_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.employee_satisfaction_question_2_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.employee_satisfaction_question_2_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.employee_satisfaction_question_2_choice_5") }, - }, - ], - headline: { default: t("templates.employee_satisfaction_question_2_headline") }, - required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.employee_satisfaction_question_3_headline") }, + t("templates.employee_satisfaction_question_2_choice_1"), + t("templates.employee_satisfaction_question_2_choice_2"), + t("templates.employee_satisfaction_question_2_choice_3"), + t("templates.employee_satisfaction_question_2_choice_4"), + t("templates.employee_satisfaction_question_2_choice_5"), + ], + headline: t("templates.employee_satisfaction_question_2_headline"), + required: true, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.employee_satisfaction_question_3_headline"), required: false, - placeholder: { default: t("templates.employee_satisfaction_question_3_placeholder") }, + placeholder: t("templates.employee_satisfaction_question_3_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, + t, + }), + buildRatingQuestion({ range: 5, scale: "number", - headline: { default: t("templates.employee_satisfaction_question_5_headline") }, + headline: t("templates.employee_satisfaction_question_5_headline"), required: true, - lowerLabel: { default: t("templates.employee_satisfaction_question_5_lower_label") }, - upperLabel: { default: t("templates.employee_satisfaction_question_5_upper_label") }, + lowerLabel: t("templates.employee_satisfaction_question_5_lower_label"), + upperLabel: t("templates.employee_satisfaction_question_5_upper_label"), isColorCodingEnabled: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.employee_satisfaction_question_6_headline") }, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.employee_satisfaction_question_6_headline"), required: false, - placeholder: { default: t("templates.employee_satisfaction_question_6_placeholder") }, + placeholder: t("templates.employee_satisfaction_question_6_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), + t, + }), + buildMultipleChoiceQuestion({ type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, shuffleOption: "none", choices: [ - { - id: createId(), - label: { default: t("templates.employee_satisfaction_question_7_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.employee_satisfaction_question_7_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.employee_satisfaction_question_7_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.employee_satisfaction_question_7_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.employee_satisfaction_question_7_choice_5") }, - }, + t("templates.employee_satisfaction_question_7_choice_1"), + t("templates.employee_satisfaction_question_7_choice_2"), + t("templates.employee_satisfaction_question_7_choice_3"), + t("templates.employee_satisfaction_question_7_choice_4"), + t("templates.employee_satisfaction_question_7_choice_5"), ], - headline: { default: t("templates.employee_satisfaction_question_7_headline") }, + headline: t("templates.employee_satisfaction_question_7_headline"), required: true, - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, + buttonLabel: t("templates.finish"), + t, + }), ], }, - }; + t + ); }; const uncoverStrengthsAndWeaknesses = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.uncover_strengths_and_weaknesses_name"), - role: "productManager", - industries: ["saas", "other"], - channels: ["app", "link"], - description: t("templates.uncover_strengths_and_weaknesses_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.uncover_strengths_and_weaknesses_name"), + role: "productManager", + industries: ["saas", "other"], + channels: ["app", "link"], + description: t("templates.uncover_strengths_and_weaknesses_description"), questions: [ - { - id: createId(), + buildMultipleChoiceQuestion({ type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, shuffleOption: "none", choices: [ - { - id: createId(), - label: { default: t("templates.uncover_strengths_and_weaknesses_question_1_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.uncover_strengths_and_weaknesses_question_1_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.uncover_strengths_and_weaknesses_question_1_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.uncover_strengths_and_weaknesses_question_1_choice_4") }, - }, - { - id: "other", - label: { default: t("templates.uncover_strengths_and_weaknesses_question_1_choice_5") }, - }, + t("templates.uncover_strengths_and_weaknesses_question_1_choice_1"), + t("templates.uncover_strengths_and_weaknesses_question_1_choice_2"), + t("templates.uncover_strengths_and_weaknesses_question_1_choice_3"), + t("templates.uncover_strengths_and_weaknesses_question_1_choice_4"), + t("templates.uncover_strengths_and_weaknesses_question_1_choice_5"), ], - headline: { default: t("templates.uncover_strengths_and_weaknesses_question_1_headline") }, + headline: t("templates.uncover_strengths_and_weaknesses_question_1_headline"), required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + containsOther: true, + t, + }), + buildMultipleChoiceQuestion({ id: createId(), type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, shuffleOption: "none", choices: [ - { - id: createId(), - label: { default: t("templates.uncover_strengths_and_weaknesses_question_2_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.uncover_strengths_and_weaknesses_question_2_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.uncover_strengths_and_weaknesses_question_2_choice_3") }, - }, - { - id: "other", - label: { default: t("templates.uncover_strengths_and_weaknesses_question_2_choice_4") }, - }, - ], - headline: { default: t("templates.uncover_strengths_and_weaknesses_question_2_headline") }, - required: true, - subheader: { default: t("templates.uncover_strengths_and_weaknesses_question_2_subheader") }, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.uncover_strengths_and_weaknesses_question_3_headline") }, + t("templates.uncover_strengths_and_weaknesses_question_2_choice_1"), + t("templates.uncover_strengths_and_weaknesses_question_2_choice_2"), + t("templates.uncover_strengths_and_weaknesses_question_2_choice_3"), + t("templates.uncover_strengths_and_weaknesses_question_2_choice_4"), + ], + headline: t("templates.uncover_strengths_and_weaknesses_question_2_headline"), + required: true, + subheader: t("templates.uncover_strengths_and_weaknesses_question_2_subheader"), + containsOther: true, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.uncover_strengths_and_weaknesses_question_3_headline"), required: false, - subheader: { default: t("templates.uncover_strengths_and_weaknesses_question_3_subheader") }, + subheader: t("templates.uncover_strengths_and_weaknesses_question_3_subheader"), inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, + buttonLabel: t("templates.finish"), + t, + }), ], }, - }; + t + ); }; const productMarketFitShort = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.product_market_fit_short_name"), - role: "productManager", - industries: ["saas"], - channels: ["app", "link"], - description: t("templates.product_market_fit_short_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.product_market_fit_short_name"), + role: "productManager", + industries: ["saas"], + channels: ["app", "link"], + description: t("templates.product_market_fit_short_description"), questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.product_market_fit_short_question_1_headline") }, - subheader: { default: t("templates.product_market_fit_short_question_1_subheader") }, - required: true, - shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: t("templates.product_market_fit_short_question_1_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.product_market_fit_short_question_1_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.product_market_fit_short_question_1_choice_3") }, - }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.product_market_fit_short_question_2_headline") }, - subheader: { default: t("templates.product_market_fit_short_question_2_subheader") }, - required: true, - inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const marketAttribution = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.market_attribution_name"), - role: "marketing", - industries: ["saas", "eCommerce"], - channels: ["website", "app", "link"], - description: t("templates.market_attribution_description"), - preset: { - ...localSurvey, - name: t("templates.market_attribution_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.market_attribution_question_1_headline") }, - subheader: { default: t("templates.market_attribution_question_1_subheader") }, - required: true, - shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: t("templates.market_attribution_question_1_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.market_attribution_question_1_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.market_attribution_question_1_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.market_attribution_question_1_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.market_attribution_question_1_choice_5") }, - }, - ], - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const changingSubscriptionExperience = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.changing_subscription_experience_name"), - role: "productManager", - industries: ["saas"], - channels: ["app"], - description: t("templates.changing_subscription_experience_description"), - preset: { - ...localSurvey, - name: t("templates.changing_subscription_experience_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.changing_subscription_experience_question_1_headline") }, - required: true, - shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: t("templates.changing_subscription_experience_question_1_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.changing_subscription_experience_question_1_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.changing_subscription_experience_question_1_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.changing_subscription_experience_question_1_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.changing_subscription_experience_question_1_choice_5") }, - }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.changing_subscription_experience_question_2_headline") }, - required: true, - shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: t("templates.changing_subscription_experience_question_2_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.changing_subscription_experience_question_2_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.changing_subscription_experience_question_2_choice_3") }, - }, - ], - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const identifyCustomerGoals = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.identify_customer_goals_name"), - role: "productManager", - industries: ["saas", "other"], - channels: ["app", "website"], - description: t("templates.identify_customer_goals_description"), - preset: { - ...localSurvey, - name: t("templates.identify_customer_goals_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: "What's your primary goal for using $[projectName]?" }, - required: true, - shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: "Understand my user base deeply" }, - }, - { - id: createId(), - label: { default: "Identify upselling opportunities" }, - }, - { - id: createId(), - label: { default: "Build the best possible product" }, - }, - { - id: createId(), - label: { default: "Rule the world to make everyone breakfast brussels sprouts." }, - }, - ], - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const featureChaser = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.feature_chaser_name"), - role: "productManager", - industries: ["saas"], - channels: ["app"], - description: t("templates.feature_chaser_description"), - preset: { - ...localSurvey, - name: t("templates.feature_chaser_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - range: 5, - scale: "number", - headline: { default: t("templates.feature_chaser_question_1_headline") }, - required: true, - lowerLabel: { default: t("templates.feature_chaser_question_1_lower_label") }, - upperLabel: { default: t("templates.feature_chaser_question_1_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + buildMultipleChoiceQuestion({ id: createId(), type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - shuffleOption: "none", - choices: [ - { id: createId(), label: { default: t("templates.feature_chaser_question_2_choice_1") } }, - { id: createId(), label: { default: t("templates.feature_chaser_question_2_choice_2") } }, - { id: createId(), label: { default: t("templates.feature_chaser_question_2_choice_3") } }, - { id: createId(), label: { default: t("templates.feature_chaser_question_2_choice_4") } }, - ], - headline: { default: t("templates.feature_chaser_question_2_headline") }, + headline: t("templates.product_market_fit_short_question_1_headline"), + subheader: t("templates.product_market_fit_short_question_1_subheader"), required: true, - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const fakeDoorFollowUp = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.fake_door_follow_up_name"), - role: "productManager", - industries: ["saas", "eCommerce"], - channels: ["app", "website"], - description: t("templates.fake_door_follow_up_description"), - preset: { - ...localSurvey, - name: t("templates.fake_door_follow_up_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { default: t("templates.fake_door_follow_up_question_1_headline") }, - required: true, - lowerLabel: { default: t("templates.fake_door_follow_up_question_1_lower_label") }, - upperLabel: { default: t("templates.fake_door_follow_up_question_1_upper_label") }, - range: 5, - scale: "number", - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, - headline: { default: t("templates.fake_door_follow_up_question_2_headline") }, - required: false, shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: t("templates.fake_door_follow_up_question_2_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.fake_door_follow_up_question_2_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.fake_door_follow_up_question_2_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.fake_door_follow_up_question_2_choice_4") }, - }, - ], - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const feedbackBox = (t: TFnType): TTemplate => { - const reusableQuestionIds = [createId(), createId(), createId(), createId()]; - const reusableOptionIds = [createId(), createId()]; - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.feedback_box_name"), - role: "productManager", - industries: ["saas"], - channels: ["app"], - description: t("templates.feedback_box_description"), - preset: { - ...localSurvey, - name: t("templates.feedback_box_name"), - questions: [ - { - id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - shuffleOption: "none", - - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[0], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[1], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[1], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[3], - }, - ], - }, - ], - choices: [ - { - id: reusableOptionIds[0], - label: { default: t("templates.feedback_box_question_1_choice_1") }, - }, - { - id: reusableOptionIds[1], - label: { default: t("templates.feedback_box_question_1_choice_2") }, - }, - ], - headline: { default: t("templates.feedback_box_question_1_headline") }, - required: true, - subheader: { default: t("templates.feedback_box_question_1_subheader") }, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[2], - }, - ], - }, - ], - headline: { default: t("templates.feedback_box_question_2_headline") }, - required: true, - subheader: { default: t("templates.feedback_box_question_2_subheader") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[2], - html: { - default: t("templates.feedback_box_question_3_html"), - }, - type: TSurveyQuestionTypeEnum.CTA, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[2], - type: "question", - }, - operator: "isClicked", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[2], - type: "question", - }, - operator: "isSkipped", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.feedback_box_question_3_headline") }, - required: false, - buttonLabel: { default: t("templates.feedback_box_question_3_button_label") }, - buttonExternal: false, - dismissButtonLabel: { default: t("templates.feedback_box_question_3_dismiss_button_label") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[3], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.feedback_box_question_4_headline") }, - required: true, - subheader: { default: t("templates.feedback_box_question_4_subheader") }, - buttonLabel: { default: t("templates.feedback_box_question_4_button_label") }, - placeholder: { default: t("templates.feedback_box_question_4_placeholder") }, - inputType: "text", - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const integrationSetupSurvey = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - const reusableQuestionIds = [createId(), createId(), createId()]; - - return { - name: t("templates.integration_setup_survey_name"), - role: "productManager", - industries: ["saas"], - channels: ["app"], - description: t("templates.integration_setup_survey_description"), - preset: { - ...localSurvey, - name: t("templates.integration_setup_survey_name"), - questions: [ - { - id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.Rating, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "isGreaterThanOrEqual", - rightOperand: { - type: "static", - value: 4, - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[2], - }, - ], - }, - ], - range: 5, - scale: "number", - headline: { default: t("templates.integration_setup_survey_question_1_headline") }, - required: true, - lowerLabel: { default: t("templates.integration_setup_survey_question_1_lower_label") }, - upperLabel: { default: t("templates.integration_setup_survey_question_1_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.integration_setup_survey_question_2_headline") }, - required: false, - placeholder: { default: t("templates.integration_setup_survey_question_2_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.integration_setup_survey_question_3_headline") }, - required: false, - subheader: { default: t("templates.integration_setup_survey_question_3_subheader") }, - inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const newIntegrationSurvey = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.new_integration_survey_name"), - role: "productManager", - industries: ["saas"], - channels: ["app"], - description: t("templates.new_integration_survey_description"), - preset: { - ...localSurvey, - name: t("templates.new_integration_survey_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.new_integration_survey_question_1_headline") }, - required: true, - shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: t("templates.new_integration_survey_question_1_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.new_integration_survey_question_1_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.new_integration_survey_question_1_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.new_integration_survey_question_1_choice_4") }, - }, - { - id: "other", - label: { default: t("templates.new_integration_survey_question_1_choice_5") }, - }, - ], - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const docsFeedback = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.docs_feedback_name"), - role: "productManager", - industries: ["saas"], - channels: ["app", "website", "link"], - description: t("templates.docs_feedback_description"), - preset: { - ...localSurvey, - name: t("templates.docs_feedback_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.docs_feedback_question_1_headline") }, - required: true, - shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: t("templates.docs_feedback_question_1_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.docs_feedback_question_1_choice_2") }, - }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.docs_feedback_question_2_headline") }, - required: false, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.docs_feedback_question_3_headline") }, - required: false, - inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const nps = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.nps_name"), - role: "customerSuccess", - industries: ["saas", "eCommerce", "other"], - channels: ["app", "link", "website"], - description: t("templates.nps_description"), - preset: { - ...localSurvey, - name: t("templates.nps_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.NPS, - headline: { default: t("templates.nps_question_1_headline") }, - required: false, - lowerLabel: { default: t("templates.nps_question_1_lower_label") }, - upperLabel: { default: t("templates.nps_question_1_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.nps_question_2_headline") }, - required: false, - inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const customerSatisfactionScore = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - const reusableQuestionIds = [ - createId(), - createId(), - createId(), - createId(), - createId(), - createId(), - createId(), - createId(), - createId(), - createId(), - ]; - return { - name: t("templates.csat_name"), - role: "customerSuccess", - industries: ["saas", "eCommerce", "other"], - channels: ["app", "link", "website"], - description: t("templates.csat_description"), - preset: { - ...localSurvey, - name: t("templates.csat_name"), - questions: [ - { - id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.Rating, - range: 10, - scale: "number", - headline: { - default: t("templates.csat_question_1_headline"), - }, - required: true, - lowerLabel: { default: t("templates.csat_question_1_lower_label") }, - upperLabel: { default: t("templates.csat_question_1_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.csat_question_2_headline") }, - subheader: { default: t("templates.csat_question_2_subheader") }, - required: true, - choices: [ - { id: createId(), label: { default: t("templates.csat_question_2_choice_1") } }, - { id: createId(), label: { default: t("templates.csat_question_2_choice_2") } }, - { id: createId(), label: { default: t("templates.csat_question_2_choice_3") } }, - { id: createId(), label: { default: t("templates.csat_question_2_choice_4") } }, - { id: createId(), label: { default: t("templates.csat_question_2_choice_5") } }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, - headline: { - default: t("templates.csat_question_3_headline"), - }, - subheader: { default: t("templates.csat_question_3_subheader") }, - required: true, - choices: [ - { id: createId(), label: { default: t("templates.csat_question_3_choice_1") } }, - { id: createId(), label: { default: t("templates.csat_question_3_choice_2") } }, - { id: createId(), label: { default: t("templates.csat_question_3_choice_3") } }, - { id: createId(), label: { default: t("templates.csat_question_3_choice_4") } }, - { id: createId(), label: { default: t("templates.csat_question_3_choice_5") } }, - { id: createId(), label: { default: t("templates.csat_question_3_choice_6") } }, - { id: createId(), label: { default: t("templates.csat_question_3_choice_7") } }, - { id: createId(), label: { default: t("templates.csat_question_3_choice_8") } }, - { id: createId(), label: { default: t("templates.csat_question_3_choice_9") } }, - { id: createId(), label: { default: t("templates.csat_question_3_choice_10") } }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[3], - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.csat_question_4_headline") }, - subheader: { default: t("templates.csat_question_4_subheader") }, - required: true, - choices: [ - { id: createId(), label: { default: t("templates.csat_question_4_choice_1") } }, - { id: createId(), label: { default: t("templates.csat_question_4_choice_2") } }, - { id: createId(), label: { default: t("templates.csat_question_4_choice_3") } }, - { id: createId(), label: { default: t("templates.csat_question_4_choice_4") } }, - { id: createId(), label: { default: t("templates.csat_question_4_choice_5") } }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[4], - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.csat_question_5_headline") }, - subheader: { default: t("templates.csat_question_5_subheader") }, - required: true, - choices: [ - { id: createId(), label: { default: t("templates.csat_question_5_choice_1") } }, - { id: createId(), label: { default: t("templates.csat_question_5_choice_2") } }, - { id: createId(), label: { default: t("templates.csat_question_5_choice_3") } }, - { id: createId(), label: { default: t("templates.csat_question_5_choice_4") } }, - { id: createId(), label: { default: t("templates.csat_question_5_choice_5") } }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[5], - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.csat_question_6_headline") }, - subheader: { default: t("templates.csat_question_6_subheader") }, - required: true, - choices: [ - { id: createId(), label: { default: t("templates.csat_question_6_choice_1") } }, - { id: createId(), label: { default: t("templates.csat_question_6_choice_2") } }, - { id: createId(), label: { default: t("templates.csat_question_6_choice_3") } }, - { id: createId(), label: { default: t("templates.csat_question_6_choice_4") } }, - { id: createId(), label: { default: t("templates.csat_question_6_choice_5") } }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[6], - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.csat_question_7_headline") }, - subheader: { default: t("templates.csat_question_7_subheader") }, - required: true, - choices: [ - { id: createId(), label: { default: t("templates.csat_question_7_choice_1") } }, - { id: createId(), label: { default: t("templates.csat_question_7_choice_2") } }, - { id: createId(), label: { default: t("templates.csat_question_7_choice_3") } }, - { id: createId(), label: { default: t("templates.csat_question_7_choice_4") } }, - { id: createId(), label: { default: t("templates.csat_question_7_choice_5") } }, - { id: createId(), label: { default: t("templates.csat_question_7_choice_6") } }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[7], - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.csat_question_8_headline") }, - subheader: { default: t("templates.csat_question_8_subheader") }, - required: true, - choices: [ - { id: createId(), label: { default: t("templates.csat_question_8_choice_1") } }, - { id: createId(), label: { default: t("templates.csat_question_8_choice_2") } }, - { id: createId(), label: { default: t("templates.csat_question_8_choice_3") } }, - { id: createId(), label: { default: t("templates.csat_question_8_choice_4") } }, - { id: createId(), label: { default: t("templates.csat_question_8_choice_5") } }, - { id: createId(), label: { default: t("templates.csat_question_8_choice_6") } }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[8], - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.csat_question_9_headline") }, - subheader: { default: t("templates.csat_question_9_subheader") }, - required: true, - choices: [ - { id: createId(), label: { default: t("templates.csat_question_9_choice_1") } }, - { id: createId(), label: { default: t("templates.csat_question_9_choice_2") } }, - { id: createId(), label: { default: t("templates.csat_question_9_choice_3") } }, - { id: createId(), label: { default: t("templates.csat_question_9_choice_4") } }, - { id: createId(), label: { default: t("templates.csat_question_9_choice_5") } }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[9], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.csat_question_10_headline") }, - required: false, - placeholder: { default: t("templates.csat_question_10_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const collectFeedback = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - const reusableQuestionIds = [ - createId(), - createId(), - createId(), - createId(), - createId(), - createId(), - createId(), - ]; - return { - name: t("templates.collect_feedback_name"), - role: "productManager", - industries: ["other", "eCommerce"], - channels: ["website", "link"], - description: t("templates.collect_feedback_description"), - preset: { - ...localSurvey, - name: t("templates.collect_feedback_name"), - questions: [ - { - id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.Rating, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "isLessThanOrEqual", - rightOperand: { - type: "static", - value: 3, - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[2], - }, - ], - }, - ], - range: 5, - scale: "star", - headline: { default: t("templates.collect_feedback_question_1_headline") }, - subheader: { default: t("templates.collect_feedback_question_1_subheader") }, - required: true, - lowerLabel: { default: t("templates.collect_feedback_question_1_lower_label") }, - upperLabel: { default: t("templates.collect_feedback_question_1_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[3], - }, - ], - }, - ], - headline: { default: t("templates.collect_feedback_question_2_headline") }, - required: true, - longAnswer: true, - placeholder: { default: t("templates.collect_feedback_question_2_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.collect_feedback_question_3_headline") }, - required: true, - longAnswer: true, - placeholder: { default: t("templates.collect_feedback_question_3_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[3], - type: TSurveyQuestionTypeEnum.Rating, - range: 5, - scale: "smiley", - headline: { default: t("templates.collect_feedback_question_4_headline") }, - required: true, - lowerLabel: { default: t("templates.collect_feedback_question_4_lower_label") }, - upperLabel: { default: t("templates.collect_feedback_question_4_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[4], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.collect_feedback_question_5_headline") }, - required: false, - longAnswer: true, - placeholder: { default: t("templates.collect_feedback_question_5_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[5], - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - choices: [ - { id: createId(), label: { default: t("templates.collect_feedback_question_6_choice_1") } }, - { id: createId(), label: { default: t("templates.collect_feedback_question_6_choice_2") } }, - { id: createId(), label: { default: t("templates.collect_feedback_question_6_choice_3") } }, - { id: createId(), label: { default: t("templates.collect_feedback_question_6_choice_4") } }, - { id: "other", label: { default: t("templates.collect_feedback_question_6_choice_5") } }, - ], - headline: { default: t("templates.collect_feedback_question_6_headline") }, - required: true, - shuffleOption: "none", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[6], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.collect_feedback_question_7_headline") }, - required: false, - inputType: "email", - longAnswer: false, - placeholder: { default: t("templates.collect_feedback_question_7_placeholder") }, - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const identifyUpsellOpportunities = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.identify_upsell_opportunities_name"), - role: "sales", - industries: ["saas"], - channels: ["app", "link"], - description: t("templates.identify_upsell_opportunities_description"), - preset: { - ...localSurvey, - name: t("templates.identify_upsell_opportunities_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.identify_upsell_opportunities_question_1_headline") }, - required: true, - shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: t("templates.identify_upsell_opportunities_question_1_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.identify_upsell_opportunities_question_1_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.identify_upsell_opportunities_question_1_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.identify_upsell_opportunities_question_1_choice_4") }, - }, - ], - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const prioritizeFeatures = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.prioritize_features_name"), - role: "productManager", - industries: ["saas"], - channels: ["app"], - description: t("templates.prioritize_features_description"), - preset: { - ...localSurvey, - name: t("templates.prioritize_features_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - logic: [], - shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: t("templates.prioritize_features_question_1_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.prioritize_features_question_1_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.prioritize_features_question_1_choice_3") }, - }, - { id: "other", label: { default: t("templates.prioritize_features_question_1_choice_4") } }, - ], - headline: { default: t("templates.prioritize_features_question_1_headline") }, - required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - logic: [], - shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: t("templates.prioritize_features_question_2_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.prioritize_features_question_2_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.prioritize_features_question_2_choice_3") }, - }, - ], - headline: { default: t("templates.prioritize_features_question_2_headline") }, - required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.prioritize_features_question_3_headline") }, - required: true, - placeholder: { default: t("templates.prioritize_features_question_3_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const gaugeFeatureSatisfaction = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.gauge_feature_satisfaction_name"), - role: "productManager", - industries: ["saas"], - channels: ["app"], - description: t("templates.gauge_feature_satisfaction_description"), - preset: { - ...localSurvey, - name: t("templates.gauge_feature_satisfaction_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { default: t("templates.gauge_feature_satisfaction_question_1_headline") }, - required: true, - lowerLabel: { default: t("templates.gauge_feature_satisfaction_question_1_lower_label") }, - upperLabel: { default: t("templates.gauge_feature_satisfaction_question_1_upper_label") }, - scale: "number", - range: 5, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.gauge_feature_satisfaction_question_2_headline") }, - required: false, - inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - endings: [getDefaultEndingCard([], t)], - hiddenFields: hiddenFieldsDefault, - }, - }; -}; - -const marketSiteClarity = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.market_site_clarity_name"), - role: "marketing", - industries: ["saas", "eCommerce", "other"], - channels: ["website"], - description: t("templates.market_site_clarity_description"), - preset: { - ...localSurvey, - name: t("templates.market_site_clarity_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.market_site_clarity_question_1_headline") }, - required: true, - shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: t("templates.market_site_clarity_question_1_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.market_site_clarity_question_1_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.market_site_clarity_question_1_choice_3") }, - }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.market_site_clarity_question_2_headline") }, - required: false, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.CTA, - headline: { default: t("templates.market_site_clarity_question_3_headline") }, - required: false, - buttonLabel: { default: t("templates.market_site_clarity_question_3_button_label") }, - buttonUrl: "https://app.formbricks.com/auth/signup", - buttonExternal: true, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const customerEffortScore = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.customer_effort_score_name"), - role: "productManager", - industries: ["saas"], - channels: ["app"], - description: t("templates.customer_effort_score_description"), - preset: { - ...localSurvey, - name: t("templates.customer_effort_score_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - range: 5, - scale: "number", - headline: { default: t("templates.customer_effort_score_question_1_headline") }, - required: true, - lowerLabel: { default: t("templates.customer_effort_score_question_1_lower_label") }, - upperLabel: { default: t("templates.customer_effort_score_question_1_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.customer_effort_score_question_2_headline") }, - required: true, - placeholder: { default: t("templates.customer_effort_score_question_2_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const careerDevelopmentSurvey = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.career_development_survey_name"), - role: "productManager", - industries: ["saas", "eCommerce", "other"], - channels: ["link"], - description: t("templates.career_development_survey_description"), - preset: { - ...localSurvey, - name: t("templates.career_development_survey_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - range: 5, - scale: "number", - headline: { - default: t("templates.career_development_survey_question_1_headline"), - }, - lowerLabel: { default: t("templates.career_development_survey_question_1_lower_label") }, - upperLabel: { default: t("templates.career_development_survey_question_1_upper_label") }, - required: true, - isColorCodingEnabled: false, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - range: 5, - scale: "number", - headline: { - default: t("templates.career_development_survey_question_2_headline"), - }, - lowerLabel: { default: t("templates.career_development_survey_question_2_lower_label") }, - upperLabel: { default: t("templates.career_development_survey_question_2_upper_label") }, - required: true, - isColorCodingEnabled: false, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - range: 5, - scale: "number", - headline: { - default: t("templates.career_development_survey_question_3_headline"), - }, - lowerLabel: { default: t("templates.career_development_survey_question_3_lower_label") }, - upperLabel: { default: t("templates.career_development_survey_question_3_upper_label") }, - required: true, - isColorCodingEnabled: false, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - range: 5, - scale: "number", - headline: { - default: t("templates.career_development_survey_question_4_headline"), - }, - lowerLabel: { default: t("templates.career_development_survey_question_4_lower_label") }, - upperLabel: { default: t("templates.career_development_survey_question_4_upper_label") }, - required: true, - isColorCodingEnabled: false, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.career_development_survey_question_5_headline") }, - subheader: { default: t("templates.career_development_survey_question_5_subheader") }, - required: true, - shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: t("templates.career_development_survey_question_5_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.career_development_survey_question_5_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.career_development_survey_question_5_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.career_development_survey_question_5_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.career_development_survey_question_5_choice_5") }, - }, - { - id: "other", - label: { default: t("templates.career_development_survey_question_5_choice_6") }, - }, - ], - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { default: t("templates.career_development_survey_question_6_headline") }, - subheader: { default: t("templates.career_development_survey_question_6_subheader") }, - required: true, - shuffleOption: "exceptLast", - choices: [ - { - id: createId(), - label: { default: t("templates.career_development_survey_question_6_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.career_development_survey_question_6_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.career_development_survey_question_6_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.career_development_survey_question_6_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.career_development_survey_question_6_choice_5") }, - }, - { - id: "other", - label: { default: t("templates.career_development_survey_question_6_choice_6") }, - }, - ], - }, - ], - }, - }; -}; - -const professionalDevelopmentSurvey = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.professional_development_survey_name"), - role: "productManager", - industries: ["saas", "eCommerce", "other"], - channels: ["link"], - description: t("templates.professional_development_survey_description"), - preset: { - ...localSurvey, - name: t("templates.professional_development_survey_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { - default: t("templates.professional_development_survey_question_1_headline"), - }, - required: true, - shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: t("templates.professional_development_survey_question_1_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.professional_development_survey_question_1_choice_2") }, - }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, - headline: { - default: t("templates.professional_development_survey_question_2_headline"), - }, - subheader: { default: t("templates.professional_development_survey_question_2_subheader") }, - required: true, - shuffleOption: "exceptLast", - choices: [ - { - id: createId(), - label: { default: t("templates.professional_development_survey_question_2_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.professional_development_survey_question_2_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.professional_development_survey_question_2_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.professional_development_survey_question_2_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.professional_development_survey_question_2_choice_5") }, - }, - { - id: "other", - label: { default: t("templates.professional_development_survey_question_2_choice_6") }, - }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - headline: { - default: t("templates.professional_development_survey_question_3_headline"), - }, - required: true, - shuffleOption: "none", - choices: [ - { - id: createId(), - label: { default: t("templates.professional_development_survey_question_3_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.professional_development_survey_question_3_choice_2") }, - }, - ], - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - range: 5, - scale: "number", - headline: { - default: t("templates.professional_development_survey_question_4_headline"), - }, - lowerLabel: { - default: t("templates.professional_development_survey_question_4_lower_label"), - }, - upperLabel: { - default: t("templates.professional_development_survey_question_4_upper_label"), - }, - required: true, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, - headline: { - default: t("templates.professional_development_survey_question_5_headline"), - }, - required: true, - shuffleOption: "exceptLast", - choices: [ - { - id: createId(), - label: { default: t("templates.professional_development_survey_question_5_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.professional_development_survey_question_5_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.professional_development_survey_question_5_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.professional_development_survey_question_5_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.professional_development_survey_question_5_choice_5") }, - }, - { - id: "other", - label: { default: t("templates.professional_development_survey_question_5_choice_6") }, - }, - ], - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const rateCheckoutExperience = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - const reusableQuestionIds = [createId(), createId(), createId()]; - return { - name: t("templates.rate_checkout_experience_name"), - role: "productManager", - industries: ["eCommerce"], - channels: ["website", "app"], - description: t("templates.rate_checkout_experience_description"), - preset: { - ...localSurvey, - name: t("templates.rate_checkout_experience_name"), - questions: [ - { - id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.Rating, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "isGreaterThanOrEqual", - rightOperand: { - type: "static", - value: 4, - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[2], - }, - ], - }, - ], - range: 5, - scale: "number", - headline: { default: t("templates.rate_checkout_experience_question_1_headline") }, - required: true, - lowerLabel: { default: t("templates.rate_checkout_experience_question_1_lower_label") }, - upperLabel: { default: t("templates.rate_checkout_experience_question_1_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.rate_checkout_experience_question_2_headline") }, - required: true, - placeholder: { default: t("templates.rate_checkout_experience_question_2_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.rate_checkout_experience_question_3_headline") }, - required: true, - placeholder: { default: t("templates.rate_checkout_experience_question_3_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const measureSearchExperience = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - const reusableQuestionIds = [createId(), createId(), createId()]; - return { - name: t("templates.measure_search_experience_name"), - role: "productManager", - industries: ["saas", "eCommerce"], - channels: ["app", "website"], - description: t("templates.measure_search_experience_description"), - preset: { - ...localSurvey, - name: t("templates.measure_search_experience_name"), - questions: [ - { - id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.Rating, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "isGreaterThanOrEqual", - rightOperand: { - type: "static", - value: 4, - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[2], - }, - ], - }, - ], - range: 5, - scale: "number", - headline: { default: t("templates.measure_search_experience_question_1_headline") }, - required: true, - lowerLabel: { default: t("templates.measure_search_experience_question_1_lower_label") }, - upperLabel: { default: t("templates.measure_search_experience_question_1_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.measure_search_experience_question_2_headline") }, - required: true, - placeholder: { default: t("templates.measure_search_experience_question_2_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.measure_search_experience_question_3_headline") }, - required: true, - placeholder: { default: t("templates.measure_search_experience_question_3_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const evaluateContentQuality = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - const reusableQuestionIds = [createId(), createId(), createId()]; - return { - name: t("templates.evaluate_content_quality_name"), - role: "marketing", - industries: ["other"], - channels: ["website"], - description: t("templates.evaluate_content_quality_description"), - preset: { - ...localSurvey, - name: t("templates.evaluate_content_quality_name"), - questions: [ - { - id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.Rating, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "isGreaterThanOrEqual", - rightOperand: { - type: "static", - value: 4, - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[2], - }, - ], - }, - ], - range: 5, - scale: "number", - headline: { default: t("templates.evaluate_content_quality_question_1_headline") }, - required: true, - lowerLabel: { default: t("templates.evaluate_content_quality_question_1_lower_label") }, - upperLabel: { default: t("templates.evaluate_content_quality_question_1_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.evaluate_content_quality_question_2_headline") }, - required: true, - placeholder: { default: t("templates.evaluate_content_quality_question_2_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.evaluate_content_quality_question_3_headline") }, - required: true, - placeholder: { default: t("templates.evaluate_content_quality_question_3_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const measureTaskAccomplishment = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - const reusableQuestionIds = [createId(), createId(), createId(), createId(), createId()]; - const reusableOptionIds = [createId(), createId(), createId()]; - return { - name: t("templates.measure_task_accomplishment_name"), - role: "productManager", - industries: ["saas"], - channels: ["app", "website"], - description: t("templates.measure_task_accomplishment_description"), - preset: { - ...localSurvey, - name: t("templates.measure_task_accomplishment_name"), - questions: [ - { - id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - shuffleOption: "none", - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[1], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[3], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[0], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[1], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[2], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[4], - }, - ], - }, - ], - choices: [ - { - id: reusableOptionIds[0], - label: { default: t("templates.measure_task_accomplishment_question_1_option_1_label") }, - }, - { - id: reusableOptionIds[1], - label: { default: t("templates.measure_task_accomplishment_question_1_option_2_label") }, - }, - { - id: reusableOptionIds[2], - label: { default: t("templates.measure_task_accomplishment_question_1_option_3_label") }, - }, - ], - headline: { default: t("templates.measure_task_accomplishment_question_1_headline") }, - required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.Rating, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isGreaterThanOrEqual", - rightOperand: { - type: "static", - value: 4, - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[3], - }, - ], - }, - ], - range: 5, - scale: "number", - headline: { default: t("templates.measure_task_accomplishment_question_2_headline") }, - required: false, - lowerLabel: { default: t("templates.measure_task_accomplishment_question_2_lower_label") }, - upperLabel: { default: t("templates.measure_task_accomplishment_question_2_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "or", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[2], - type: "question", - }, - operator: "isSubmitted", - }, - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSkipped", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.measure_task_accomplishment_question_3_headline") }, - required: false, - placeholder: { default: t("templates.measure_task_accomplishment_question_3_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[3], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "or", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[3], - type: "question", - }, - operator: "isSubmitted", - }, - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSkipped", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.measure_task_accomplishment_question_4_headline") }, - required: false, - buttonLabel: { default: t("templates.measure_task_accomplishment_question_4_button_label") }, - inputType: "text", - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[4], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.measure_task_accomplishment_question_5_headline") }, - required: true, - buttonLabel: { default: t("templates.measure_task_accomplishment_question_5_button_label") }, - placeholder: { default: t("templates.measure_task_accomplishment_question_5_placeholder") }, - inputType: "text", - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const identifySignUpBarriers = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - const reusableQuestionIds = [ - createId(), - createId(), - createId(), - createId(), - createId(), - createId(), - createId(), - createId(), - createId(), - ]; - const reusableOptionIds = [createId(), createId(), createId(), createId(), createId()]; - - return { - name: t("templates.identify_sign_up_barriers_name"), - role: "marketing", - industries: ["saas", "eCommerce", "other"], - channels: ["website"], - description: t("templates.identify_sign_up_barriers_description"), - preset: { - ...localSurvey, - name: t("templates.identify_sign_up_barriers_with_project_name"), - questions: [ - { - id: reusableQuestionIds[0], - html: { - default: t("templates.identify_sign_up_barriers_question_1_html"), - }, - type: TSurveyQuestionTypeEnum.CTA, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "isSkipped", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - headline: { default: t("templates.identify_sign_up_barriers_question_1_headline") }, - required: false, - buttonLabel: { default: t("templates.identify_sign_up_barriers_question_1_button_label") }, - buttonExternal: false, - dismissButtonLabel: { - default: t("templates.identify_sign_up_barriers_question_1_dismiss_button_label"), - }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.Rating, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: 5, - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, - ], - range: 5, - scale: "number", - headline: { default: t("templates.identify_sign_up_barriers_question_2_headline") }, - required: true, - lowerLabel: { default: t("templates.identify_sign_up_barriers_question_2_lower_label") }, - upperLabel: { default: t("templates.identify_sign_up_barriers_question_2_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - shuffleOption: "none", - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[2], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[0], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[3], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[2], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[1], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[4], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[2], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[2], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[5], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[2], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[3], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[6], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[2], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[4], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[7], - }, - ], - }, - ], - choices: [ - { - id: reusableOptionIds[0], - label: { default: t("templates.identify_sign_up_barriers_question_3_choice_1_label") }, - }, - { - id: reusableOptionIds[1], - label: { default: t("templates.identify_sign_up_barriers_question_3_choice_2_label") }, - }, - { - id: reusableOptionIds[2], - label: { default: t("templates.identify_sign_up_barriers_question_3_choice_3_label") }, - }, - { - id: reusableOptionIds[3], - label: { default: t("templates.identify_sign_up_barriers_question_3_choice_4_label") }, - }, - { - id: reusableOptionIds[4], - label: { default: t("templates.identify_sign_up_barriers_question_3_choice_5_label") }, - }, - ], - headline: { default: t("templates.identify_sign_up_barriers_question_3_headline") }, - required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[3], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[3], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[8], - }, - ], - }, - ], - headline: { default: t("templates.identify_sign_up_barriers_question_4_headline") }, - required: true, - placeholder: { default: t("templates.identify_sign_up_barriers_question_4_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[4], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[4], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[8], - }, - ], - }, - ], - headline: { default: t("templates.identify_sign_up_barriers_question_5_headline") }, - required: true, - placeholder: { default: t("templates.identify_sign_up_barriers_question_5_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[5], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[5], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[8], - }, - ], - }, - ], - headline: { default: t("templates.identify_sign_up_barriers_question_6_headline") }, - required: true, - placeholder: { default: t("templates.identify_sign_up_barriers_question_6_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[6], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[6], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[8], - }, - ], - }, - ], - headline: { default: t("templates.identify_sign_up_barriers_question_7_headline") }, - required: true, - placeholder: { default: t("templates.identify_sign_up_barriers_question_7_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[7], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.identify_sign_up_barriers_question_8_headline") }, - required: true, - placeholder: { default: t("templates.identify_sign_up_barriers_question_8_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[8], - html: { - default: t("templates.identify_sign_up_barriers_question_9_html"), - }, - type: TSurveyQuestionTypeEnum.CTA, - headline: { default: t("templates.identify_sign_up_barriers_question_9_headline") }, - required: false, - buttonUrl: "https://app.formbricks.com/auth/signup", - buttonLabel: { default: t("templates.identify_sign_up_barriers_question_9_button_label") }, - buttonExternal: true, - dismissButtonLabel: { - default: t("templates.identify_sign_up_barriers_question_9_dismiss_button_label"), - }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const buildProductRoadmap = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.build_product_roadmap_name"), - role: "productManager", - industries: ["saas"], - channels: ["app", "link"], - description: t("templates.build_product_roadmap_description"), - preset: { - ...localSurvey, - name: t("templates.build_product_roadmap_name_with_project_name"), - questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - range: 5, - scale: "number", - headline: { - default: t("templates.build_product_roadmap_question_1_headline"), - }, - required: true, - lowerLabel: { default: t("templates.build_product_roadmap_question_1_lower_label") }, - upperLabel: { default: t("templates.build_product_roadmap_question_1_upper_label") }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { - default: t("templates.build_product_roadmap_question_2_headline"), - }, - required: true, - placeholder: { default: t("templates.build_product_roadmap_question_2_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, - ], - }, - }; -}; - -const understandPurchaseIntention = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - const reusableQuestionIds = [createId(), createId(), createId()]; - return { - name: t("templates.understand_purchase_intention_name"), - role: "sales", - industries: ["eCommerce"], - channels: ["website", "link", "app"], - description: t("templates.understand_purchase_intention_description"), - preset: { - ...localSurvey, - name: t("templates.understand_purchase_intention_name"), - questions: [ - { - id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.Rating, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "isLessThanOrEqual", - rightOperand: { - type: "static", - value: 2, - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[1], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: 3, - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[2], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: 4, - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[2], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", - rightOperand: { - type: "static", - value: 5, - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, + choices: [ + t("templates.product_market_fit_short_question_1_choice_1"), + t("templates.product_market_fit_short_question_1_choice_2"), + t("templates.product_market_fit_short_question_1_choice_3"), + ], + t, + }), + buildOpenTextQuestion({ + headline: t("templates.product_market_fit_short_question_2_headline"), + subheader: t("templates.product_market_fit_short_question_2_subheader"), + required: true, + inputType: "text", + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const marketAttribution = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.market_attribution_name"), + role: "marketing", + industries: ["saas", "eCommerce"], + channels: ["website", "app", "link"], + description: t("templates.market_attribution_description"), + questions: [ + buildMultipleChoiceQuestion({ + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.market_attribution_question_1_headline"), + subheader: t("templates.market_attribution_question_1_subheader"), + required: true, + shuffleOption: "none", + choices: [ + t("templates.market_attribution_question_1_choice_1"), + t("templates.market_attribution_question_1_choice_2"), + t("templates.market_attribution_question_1_choice_3"), + t("templates.market_attribution_question_1_choice_4"), + t("templates.market_attribution_question_1_choice_5"), + ], + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const changingSubscriptionExperience = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.changing_subscription_experience_name"), + role: "productManager", + industries: ["saas"], + channels: ["app"], + description: t("templates.changing_subscription_experience_description"), + questions: [ + buildMultipleChoiceQuestion({ + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.changing_subscription_experience_question_1_headline"), + required: true, + shuffleOption: "none", + choices: [ + t("templates.changing_subscription_experience_question_1_choice_1"), + t("templates.changing_subscription_experience_question_1_choice_2"), + t("templates.changing_subscription_experience_question_1_choice_3"), + t("templates.changing_subscription_experience_question_1_choice_4"), + t("templates.changing_subscription_experience_question_1_choice_5"), + ], + buttonLabel: t("templates.next"), + t, + }), + buildMultipleChoiceQuestion({ + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.changing_subscription_experience_question_2_headline"), + required: true, + shuffleOption: "none", + choices: [ + t("templates.changing_subscription_experience_question_2_choice_1"), + t("templates.changing_subscription_experience_question_2_choice_2"), + t("templates.changing_subscription_experience_question_2_choice_3"), ], + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const identifyCustomerGoals = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.identify_customer_goals_name"), + role: "productManager", + industries: ["saas", "other"], + channels: ["app", "website"], + description: t("templates.identify_customer_goals_description"), + questions: [ + buildMultipleChoiceQuestion({ + id: createId(), + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: "What's your primary goal for using $[projectName]?", + required: true, + shuffleOption: "none", + choices: [ + "Understand my user base deeply", + "Identify upselling opportunities", + "Build the best possible product", + "Rule the world to make everyone breakfast brussels sprouts.", + ], + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const featureChaser = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.feature_chaser_name"), + role: "productManager", + industries: ["saas"], + channels: ["app"], + description: t("templates.feature_chaser_description"), + questions: [ + buildRatingQuestion({ range: 5, scale: "number", - headline: { default: t("templates.understand_purchase_intention_question_1_headline") }, + headline: t("templates.feature_chaser_question_1_headline"), required: true, - lowerLabel: { default: t("templates.understand_purchase_intention_question_1_lower_label") }, - upperLabel: { default: t("templates.understand_purchase_intention_question_1_upper_label") }, + lowerLabel: t("templates.feature_chaser_question_1_lower_label"), + upperLabel: t("templates.feature_chaser_question_1_upper_label"), isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "or", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSubmitted", - }, - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSkipped", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, + t, + }), + buildMultipleChoiceQuestion({ + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + shuffleOption: "none", + choices: [ + t("templates.feature_chaser_question_2_choice_1"), + t("templates.feature_chaser_question_2_choice_2"), + t("templates.feature_chaser_question_2_choice_3"), + t("templates.feature_chaser_question_2_choice_4"), ], - headline: { default: t("templates.understand_purchase_intention_question_2_headline") }, + headline: t("templates.feature_chaser_question_2_headline"), + required: true, + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const fakeDoorFollowUp = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.fake_door_follow_up_name"), + role: "productManager", + industries: ["saas", "eCommerce"], + channels: ["app", "website"], + description: t("templates.fake_door_follow_up_description"), + questions: [ + buildRatingQuestion({ + headline: t("templates.fake_door_follow_up_question_1_headline"), + required: true, + lowerLabel: t("templates.fake_door_follow_up_question_1_lower_label"), + upperLabel: t("templates.fake_door_follow_up_question_1_upper_label"), + range: 5, + scale: "number", + isColorCodingEnabled: false, + buttonLabel: t("templates.next"), + t, + }), + buildMultipleChoiceQuestion({ + id: createId(), + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + headline: t("templates.fake_door_follow_up_question_2_headline"), required: false, - placeholder: { default: t("templates.understand_purchase_intention_question_2_placeholder") }, + shuffleOption: "none", + choices: [ + t("templates.fake_door_follow_up_question_2_choice_1"), + t("templates.fake_door_follow_up_question_2_choice_2"), + t("templates.fake_door_follow_up_question_2_choice_3"), + t("templates.fake_door_follow_up_question_2_choice_4"), + ], + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const feedbackBox = (t: TFnType): TTemplate => { + const reusableQuestionIds = [createId(), createId(), createId(), createId()]; + const reusableOptionIds = [createId(), createId()]; + const localSurvey = getDefaultSurveyPreset(t); + return buildSurvey( + { + name: t("templates.feedback_box_name"), + role: "productManager", + industries: ["saas"], + channels: ["app"], + description: t("templates.feedback_box_description"), + endings: localSurvey.endings, + questions: [ + buildMultipleChoiceQuestion({ + id: reusableQuestionIds[0], + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + shuffleOption: "none", + logic: [ + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[0], reusableQuestionIds[1]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[1], reusableQuestionIds[3]), + ], + choices: [ + t("templates.feedback_box_question_1_choice_1"), + t("templates.feedback_box_question_1_choice_2"), + ], + choiceIds: [reusableOptionIds[0], reusableOptionIds[1]], + headline: t("templates.feedback_box_question_1_headline"), + required: true, + subheader: t("templates.feedback_box_question_1_subheader"), + buttonLabel: t("templates.next"), + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[1], + logic: [createJumpLogic(reusableQuestionIds[1], reusableQuestionIds[2], "isSubmitted")], + headline: t("templates.feedback_box_question_2_headline"), + required: true, + subheader: t("templates.feedback_box_question_2_subheader"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + t, + }), + buildCTAQuestion({ id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.understand_purchase_intention_question_3_headline") }, + html: t("templates.feedback_box_question_3_html"), + logic: [ + createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isClicked"), + createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isSkipped"), + ], + headline: t("templates.feedback_box_question_3_headline"), + required: false, + buttonLabel: t("templates.feedback_box_question_3_button_label"), + buttonExternal: false, + dismissButtonLabel: t("templates.feedback_box_question_3_dismiss_button_label"), + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[3], + headline: t("templates.feedback_box_question_4_headline"), required: true, - placeholder: { default: t("templates.understand_purchase_intention_question_3_placeholder") }, + subheader: t("templates.feedback_box_question_4_subheader"), + buttonLabel: t("templates.feedback_box_question_4_button_label"), + placeholder: t("templates.feedback_box_question_4_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, + t, + }), ], }, - }; + t + ); }; -const improveNewsletterContent = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); +const integrationSetupSurvey = (t: TFnType): TTemplate => { const reusableQuestionIds = [createId(), createId(), createId()]; - return { - name: t("templates.improve_newsletter_content_name"), - role: "marketing", - industries: ["eCommerce", "saas", "other"], - channels: ["link"], - description: t("templates.improve_newsletter_content_description"), - preset: { - ...localSurvey, - name: t("templates.improve_newsletter_content_name"), + + return buildSurvey( + { + name: t("templates.integration_setup_survey_name"), + role: "productManager", + industries: ["saas"], + channels: ["app"], + description: t("templates.integration_setup_survey_description"), questions: [ - { + buildRatingQuestion({ id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.Rating, logic: [ { id: createId(), @@ -5481,10 +1397,10 @@ const improveNewsletterContent = (t: TFnType): TTemplate => { value: reusableQuestionIds[0], type: "question", }, - operator: "equals", + operator: "isGreaterThanOrEqual", rightOperand: { type: "static", - value: 5, + value: 4, }, }, ], @@ -5497,115 +1413,141 @@ const improveNewsletterContent = (t: TFnType): TTemplate => { }, ], }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "isLessThan", - rightOperand: { - type: "static", - value: 5, - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[1], - }, - ], - }, ], range: 5, - scale: "smiley", - headline: { default: t("templates.improve_newsletter_content_question_1_headline") }, + scale: "number", + headline: t("templates.integration_setup_survey_question_1_headline"), required: true, - lowerLabel: { default: t("templates.improve_newsletter_content_question_1_lower_label") }, - upperLabel: { default: t("templates.improve_newsletter_content_question_1_upper_label") }, + lowerLabel: t("templates.integration_setup_survey_question_1_lower_label"), + upperLabel: t("templates.integration_setup_survey_question_1_upper_label"), isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "or", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSubmitted", - }, - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[1], - type: "question", - }, - operator: "isSkipped", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: localSurvey.endings[0].id, - }, - ], - }, + headline: t("templates.integration_setup_survey_question_2_headline"), + required: false, + placeholder: t("templates.integration_setup_survey_question_2_placeholder"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[2], + headline: t("templates.integration_setup_survey_question_3_headline"), + required: false, + subheader: t("templates.integration_setup_survey_question_3_subheader"), + inputType: "text", + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const newIntegrationSurvey = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.new_integration_survey_name"), + role: "productManager", + industries: ["saas"], + channels: ["app"], + description: t("templates.new_integration_survey_description"), + questions: [ + buildMultipleChoiceQuestion({ + id: createId(), + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.new_integration_survey_question_1_headline"), + required: true, + shuffleOption: "none", + choices: [ + t("templates.new_integration_survey_question_1_choice_1"), + t("templates.new_integration_survey_question_1_choice_2"), + t("templates.new_integration_survey_question_1_choice_3"), + t("templates.new_integration_survey_question_1_choice_4"), + t("templates.new_integration_survey_question_1_choice_5"), + ], + buttonLabel: t("templates.finish"), + containsOther: true, + t, + }), + ], + }, + t + ); +}; + +const docsFeedback = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.docs_feedback_name"), + role: "productManager", + industries: ["saas"], + channels: ["app", "website", "link"], + description: t("templates.docs_feedback_description"), + questions: [ + buildMultipleChoiceQuestion({ + id: createId(), + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.docs_feedback_question_1_headline"), + required: true, + shuffleOption: "none", + choices: [ + t("templates.docs_feedback_question_1_choice_1"), + t("templates.docs_feedback_question_1_choice_2"), ], - headline: { default: t("templates.improve_newsletter_content_question_2_headline") }, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.docs_feedback_question_2_headline"), + required: false, + inputType: "text", + t, + }), + buildOpenTextQuestion({ + headline: t("templates.docs_feedback_question_3_headline"), + required: false, + inputType: "text", + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const nps = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.nps_name"), + role: "customerSuccess", + industries: ["saas", "eCommerce", "other"], + channels: ["app", "link", "website"], + description: t("templates.nps_description"), + questions: [ + buildNPSQuestion({ + headline: t("templates.nps_question_1_headline"), required: false, - placeholder: { default: t("templates.improve_newsletter_content_question_2_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[2], - html: { - default: t("templates.improve_newsletter_content_question_3_html"), - }, - type: TSurveyQuestionTypeEnum.CTA, - headline: { default: t("templates.improve_newsletter_content_question_3_headline") }, + lowerLabel: t("templates.nps_question_1_lower_label"), + upperLabel: t("templates.nps_question_1_upper_label"), + t, + }), + buildOpenTextQuestion({ + headline: t("templates.nps_question_2_headline"), required: false, - buttonUrl: "https://formbricks.com", - buttonLabel: { default: t("templates.improve_newsletter_content_question_3_button_label") }, - buttonExternal: true, - dismissButtonLabel: { - default: t("templates.improve_newsletter_content_question_3_dismiss_button_label"), - }, - backButtonLabel: { default: t("templates.back") }, - }, + inputType: "text", + buttonLabel: t("templates.finish"), + t, + }), ], }, - }; + t + ); }; -const evaluateAProductIdea = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); +const customerSatisfactionScore = (t: TFnType): TTemplate => { const reusableQuestionIds = [ createId(), createId(), @@ -5615,37 +1557,188 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => { createId(), createId(), createId(), + createId(), + createId(), ]; - return { - name: t("templates.evaluate_a_product_idea_name"), - role: "productManager", - industries: ["saas", "other"], - channels: ["link", "app"], - description: t("templates.evaluate_a_product_idea_description"), - preset: { - ...localSurvey, - name: t("templates.evaluate_a_product_idea_name"), + return buildSurvey( + { + name: t("templates.csat_name"), + role: "customerSuccess", + industries: ["saas", "eCommerce", "other"], + channels: ["app", "link", "website"], + description: t("templates.csat_description"), questions: [ - { + buildRatingQuestion({ id: reusableQuestionIds[0], - html: { - default: t("templates.evaluate_a_product_idea_question_1_html"), - }, - type: TSurveyQuestionTypeEnum.CTA, - headline: { - default: t("templates.evaluate_a_product_idea_question_1_headline"), - }, + range: 10, + scale: "number", + headline: t("templates.csat_question_1_headline"), required: true, - buttonLabel: { default: t("templates.evaluate_a_product_idea_question_1_button_label") }, - buttonExternal: false, - dismissButtonLabel: { - default: t("templates.evaluate_a_product_idea_question_1_dismiss_button_label"), - }, - backButtonLabel: { default: t("templates.back") }, - }, - { + lowerLabel: t("templates.csat_question_1_lower_label"), + upperLabel: t("templates.csat_question_1_upper_label"), + isColorCodingEnabled: false, + t, + }), + buildMultipleChoiceQuestion({ id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.Rating, + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.csat_question_2_headline"), + subheader: t("templates.csat_question_2_subheader"), + required: true, + choices: [ + t("templates.csat_question_2_choice_1"), + t("templates.csat_question_2_choice_2"), + t("templates.csat_question_2_choice_3"), + t("templates.csat_question_2_choice_4"), + t("templates.csat_question_2_choice_5"), + ], + t, + }), + buildMultipleChoiceQuestion({ + id: reusableQuestionIds[2], + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + headline: t("templates.csat_question_3_headline"), + subheader: t("templates.csat_question_3_subheader"), + required: true, + choices: [ + t("templates.csat_question_3_choice_1"), + t("templates.csat_question_3_choice_2"), + t("templates.csat_question_3_choice_3"), + t("templates.csat_question_3_choice_4"), + t("templates.csat_question_3_choice_5"), + t("templates.csat_question_3_choice_6"), + t("templates.csat_question_3_choice_7"), + t("templates.csat_question_3_choice_8"), + t("templates.csat_question_3_choice_9"), + t("templates.csat_question_3_choice_10"), + ], + t, + }), + buildMultipleChoiceQuestion({ + id: reusableQuestionIds[3], + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.csat_question_4_headline"), + subheader: t("templates.csat_question_4_subheader"), + required: true, + choices: [ + t("templates.csat_question_4_choice_1"), + t("templates.csat_question_4_choice_2"), + t("templates.csat_question_4_choice_3"), + t("templates.csat_question_4_choice_4"), + t("templates.csat_question_4_choice_5"), + ], + t, + }), + buildMultipleChoiceQuestion({ + id: reusableQuestionIds[4], + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.csat_question_5_headline"), + subheader: t("templates.csat_question_5_subheader"), + required: true, + choices: [ + t("templates.csat_question_5_choice_1"), + t("templates.csat_question_5_choice_2"), + t("templates.csat_question_5_choice_3"), + t("templates.csat_question_5_choice_4"), + t("templates.csat_question_5_choice_5"), + ], + t, + }), + buildMultipleChoiceQuestion({ + id: reusableQuestionIds[5], + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.csat_question_6_headline"), + subheader: t("templates.csat_question_6_subheader"), + required: true, + choices: [ + t("templates.csat_question_6_choice_1"), + t("templates.csat_question_6_choice_2"), + t("templates.csat_question_6_choice_3"), + t("templates.csat_question_6_choice_4"), + t("templates.csat_question_6_choice_5"), + ], + t, + }), + buildMultipleChoiceQuestion({ + id: reusableQuestionIds[6], + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.csat_question_7_headline"), + subheader: t("templates.csat_question_7_subheader"), + required: true, + choices: [ + t("templates.csat_question_7_choice_1"), + t("templates.csat_question_7_choice_2"), + t("templates.csat_question_7_choice_3"), + t("templates.csat_question_7_choice_4"), + t("templates.csat_question_7_choice_5"), + ], + t, + }), + buildMultipleChoiceQuestion({ + id: reusableQuestionIds[7], + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.csat_question_8_headline"), + subheader: t("templates.csat_question_8_subheader"), + required: true, + choices: [ + t("templates.csat_question_8_choice_1"), + t("templates.csat_question_8_choice_2"), + t("templates.csat_question_8_choice_3"), + t("templates.csat_question_8_choice_4"), + t("templates.csat_question_8_choice_5"), + ], + t, + }), + buildMultipleChoiceQuestion({ + id: reusableQuestionIds[8], + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.csat_question_9_headline"), + subheader: t("templates.csat_question_9_subheader"), + required: true, + choices: [ + t("templates.csat_question_9_choice_1"), + t("templates.csat_question_9_choice_2"), + t("templates.csat_question_9_choice_3"), + t("templates.csat_question_9_choice_4"), + t("templates.csat_question_9_choice_5"), + ], + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[9], + headline: t("templates.csat_question_10_headline"), + required: false, + placeholder: t("templates.csat_question_10_placeholder"), + inputType: "text", + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const collectFeedback = (t: TFnType): TTemplate => { + const reusableQuestionIds = [ + createId(), + createId(), + createId(), + createId(), + createId(), + createId(), + createId(), + ]; + return buildSurvey( + { + name: t("templates.collect_feedback_name"), + role: "productManager", + industries: ["other", "eCommerce"], + channels: ["website", "link"], + description: t("templates.collect_feedback_description"), + questions: [ + buildRatingQuestion({ + id: reusableQuestionIds[0], logic: [ { id: createId(), @@ -5656,7 +1749,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => { { id: createId(), leftOperand: { - value: reusableQuestionIds[1], + value: reusableQuestionIds[0], type: "question", }, operator: "isLessThanOrEqual", @@ -5675,6 +1768,20 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => { }, ], }, + ], + range: 5, + scale: "star", + headline: t("templates.collect_feedback_question_1_headline"), + subheader: t("templates.collect_feedback_question_1_subheader"), + required: true, + lowerLabel: t("templates.collect_feedback_question_1_lower_label"), + upperLabel: t("templates.collect_feedback_question_1_upper_label"), + isColorCodingEnabled: false, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[1], + logic: [ { id: createId(), conditions: { @@ -5687,11 +1794,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => { value: reusableQuestionIds[1], type: "question", }, - operator: "isGreaterThanOrEqual", - rightOperand: { - type: "static", - value: 4, - }, + operator: "isSubmitted", }, ], }, @@ -5704,48 +1807,452 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => { ], }, ], + headline: t("templates.collect_feedback_question_2_headline"), + required: true, + longAnswer: true, + placeholder: t("templates.collect_feedback_question_2_placeholder"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[2], + headline: t("templates.collect_feedback_question_3_headline"), + required: true, + longAnswer: true, + placeholder: t("templates.collect_feedback_question_3_placeholder"), + inputType: "text", + t, + }), + buildRatingQuestion({ + id: reusableQuestionIds[3], range: 5, - scale: "number", - headline: { default: t("templates.evaluate_a_product_idea_question_2_headline") }, + scale: "smiley", + headline: t("templates.collect_feedback_question_4_headline"), required: true, - lowerLabel: { default: t("templates.evaluate_a_product_idea_question_2_lower_label") }, - upperLabel: { default: t("templates.evaluate_a_product_idea_question_2_upper_label") }, + lowerLabel: t("templates.collect_feedback_question_4_lower_label"), + upperLabel: t("templates.collect_feedback_question_4_upper_label"), isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[4], + headline: t("templates.collect_feedback_question_5_headline"), + required: false, + longAnswer: true, + placeholder: t("templates.collect_feedback_question_5_placeholder"), + inputType: "text", + t, + }), + buildMultipleChoiceQuestion({ + id: reusableQuestionIds[5], + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + choices: [ + t("templates.collect_feedback_question_6_choice_1"), + t("templates.collect_feedback_question_6_choice_2"), + t("templates.collect_feedback_question_6_choice_3"), + t("templates.collect_feedback_question_6_choice_4"), + t("templates.collect_feedback_question_6_choice_5"), + ], + headline: t("templates.collect_feedback_question_6_headline"), + required: true, + shuffleOption: "none", + containsOther: true, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[6], + headline: t("templates.collect_feedback_question_7_headline"), + required: false, + inputType: "email", + longAnswer: false, + placeholder: t("templates.collect_feedback_question_7_placeholder"), + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; - { - id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.evaluate_a_product_idea_question_3_headline") }, +const identifyUpsellOpportunities = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.identify_upsell_opportunities_name"), + role: "sales", + industries: ["saas"], + channels: ["app", "link"], + description: t("templates.identify_upsell_opportunities_description"), + questions: [ + buildMultipleChoiceQuestion({ + id: createId(), + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.identify_upsell_opportunities_question_1_headline"), + required: true, + shuffleOption: "none", + choices: [ + t("templates.identify_upsell_opportunities_question_1_choice_1"), + t("templates.identify_upsell_opportunities_question_1_choice_2"), + t("templates.identify_upsell_opportunities_question_1_choice_3"), + t("templates.identify_upsell_opportunities_question_1_choice_4"), + ], + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const prioritizeFeatures = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.prioritize_features_name"), + role: "productManager", + industries: ["saas"], + channels: ["app"], + description: t("templates.prioritize_features_description"), + questions: [ + buildMultipleChoiceQuestion({ + id: createId(), + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + logic: [], + shuffleOption: "none", + choices: [ + t("templates.prioritize_features_question_1_choice_1"), + t("templates.prioritize_features_question_1_choice_2"), + t("templates.prioritize_features_question_1_choice_3"), + t("templates.prioritize_features_question_1_choice_4"), + ], + headline: t("templates.prioritize_features_question_1_headline"), + required: true, + t, + containsOther: true, + }), + buildMultipleChoiceQuestion({ + id: createId(), + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + logic: [], + shuffleOption: "none", + choices: [ + t("templates.prioritize_features_question_2_choice_1"), + t("templates.prioritize_features_question_2_choice_2"), + t("templates.prioritize_features_question_2_choice_3"), + ], + headline: t("templates.prioritize_features_question_2_headline"), + required: true, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.prioritize_features_question_3_headline"), + required: true, + placeholder: t("templates.prioritize_features_question_3_placeholder"), + inputType: "text", + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const gaugeFeatureSatisfaction = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.gauge_feature_satisfaction_name"), + role: "productManager", + industries: ["saas"], + channels: ["app"], + description: t("templates.gauge_feature_satisfaction_description"), + questions: [ + buildRatingQuestion({ + headline: t("templates.gauge_feature_satisfaction_question_1_headline"), + required: true, + lowerLabel: t("templates.gauge_feature_satisfaction_question_1_lower_label"), + upperLabel: t("templates.gauge_feature_satisfaction_question_1_upper_label"), + scale: "number", + range: 5, + isColorCodingEnabled: false, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.gauge_feature_satisfaction_question_2_headline"), + required: false, + inputType: "text", + buttonLabel: t("templates.finish"), + t, + }), + ], + endings: [getDefaultEndingCard([], t)], + hiddenFields: hiddenFieldsDefault, + }, + t + ); +}; + +const marketSiteClarity = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.market_site_clarity_name"), + role: "marketing", + industries: ["saas", "eCommerce", "other"], + channels: ["website"], + description: t("templates.market_site_clarity_description"), + questions: [ + buildMultipleChoiceQuestion({ + id: createId(), + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.market_site_clarity_question_1_headline"), + required: true, + shuffleOption: "none", + choices: [ + t("templates.market_site_clarity_question_1_choice_1"), + t("templates.market_site_clarity_question_1_choice_2"), + t("templates.market_site_clarity_question_1_choice_3"), + ], + t, + }), + buildOpenTextQuestion({ + headline: t("templates.market_site_clarity_question_2_headline"), + required: false, + inputType: "text", + t, + }), + buildCTAQuestion({ + headline: t("templates.market_site_clarity_question_3_headline"), + required: false, + buttonLabel: t("templates.market_site_clarity_question_3_button_label"), + buttonUrl: "https://app.formbricks.com/auth/signup", + buttonExternal: true, + t, + }), + ], + }, + t + ); +}; + +const customerEffortScore = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.customer_effort_score_name"), + role: "productManager", + industries: ["saas"], + channels: ["app"], + description: t("templates.customer_effort_score_description"), + questions: [ + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.customer_effort_score_question_1_headline"), + required: true, + lowerLabel: t("templates.customer_effort_score_question_1_lower_label"), + upperLabel: t("templates.customer_effort_score_question_1_upper_label"), + t, + }), + buildOpenTextQuestion({ + headline: t("templates.customer_effort_score_question_2_headline"), + required: true, + placeholder: t("templates.customer_effort_score_question_2_placeholder"), + inputType: "text", + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const careerDevelopmentSurvey = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.career_development_survey_name"), + role: "productManager", + industries: ["saas", "eCommerce", "other"], + channels: ["link"], + description: t("templates.career_development_survey_description"), + questions: [ + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.career_development_survey_question_1_headline"), + lowerLabel: t("templates.career_development_survey_question_1_lower_label"), + upperLabel: t("templates.career_development_survey_question_1_upper_label"), + required: true, + t, + }), + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.career_development_survey_question_2_headline"), + lowerLabel: t("templates.career_development_survey_question_2_lower_label"), + upperLabel: t("templates.career_development_survey_question_2_upper_label"), + required: true, + t, + }), + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.career_development_survey_question_3_headline"), + lowerLabel: t("templates.career_development_survey_question_3_lower_label"), + upperLabel: t("templates.career_development_survey_question_3_upper_label"), + required: true, + t, + }), + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.career_development_survey_question_4_headline"), + lowerLabel: t("templates.career_development_survey_question_4_lower_label"), + upperLabel: t("templates.career_development_survey_question_4_upper_label"), + required: true, + t, + }), + buildMultipleChoiceQuestion({ + id: createId(), + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.career_development_survey_question_5_headline"), + subheader: t("templates.career_development_survey_question_5_subheader"), + required: true, + shuffleOption: "none", + choices: [ + t("templates.career_development_survey_question_5_choice_1"), + t("templates.career_development_survey_question_5_choice_2"), + t("templates.career_development_survey_question_5_choice_3"), + t("templates.career_development_survey_question_5_choice_4"), + t("templates.career_development_survey_question_5_choice_5"), + t("templates.career_development_survey_question_5_choice_6"), + ], + containsOther: true, + t, + }), + buildMultipleChoiceQuestion({ + id: createId(), + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.career_development_survey_question_6_headline"), + subheader: t("templates.career_development_survey_question_6_subheader"), + required: true, + shuffleOption: "exceptLast", + choices: [ + t("templates.career_development_survey_question_6_choice_1"), + t("templates.career_development_survey_question_6_choice_2"), + t("templates.career_development_survey_question_6_choice_3"), + t("templates.career_development_survey_question_6_choice_4"), + t("templates.career_development_survey_question_6_choice_5"), + t("templates.career_development_survey_question_6_choice_6"), + ], + containsOther: true, + t, + }), + ], + }, + t + ); +}; + +const professionalDevelopmentSurvey = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.professional_development_survey_name"), + role: "productManager", + industries: ["saas", "eCommerce", "other"], + channels: ["link"], + description: t("templates.professional_development_survey_description"), + questions: [ + buildMultipleChoiceQuestion({ + id: createId(), + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.professional_development_survey_question_1_headline"), + required: true, + shuffleOption: "none", + choices: [ + t("templates.professional_development_survey_question_1_choice_1"), + t("templates.professional_development_survey_question_1_choice_2"), + ], + t, + }), + + buildMultipleChoiceQuestion({ + id: createId(), + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + headline: t("templates.professional_development_survey_question_2_headline"), + subheader: t("templates.professional_development_survey_question_2_subheader"), + required: true, + shuffleOption: "exceptLast", + choices: [ + t("templates.professional_development_survey_question_2_choice_1"), + t("templates.professional_development_survey_question_2_choice_2"), + t("templates.professional_development_survey_question_2_choice_3"), + t("templates.professional_development_survey_question_2_choice_4"), + t("templates.professional_development_survey_question_2_choice_5"), + t("templates.professional_development_survey_question_2_choice_6"), + ], + containsOther: true, + t, + }), + buildMultipleChoiceQuestion({ + id: createId(), + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: t("templates.professional_development_survey_question_3_headline"), + required: true, + shuffleOption: "none", + choices: [ + t("templates.professional_development_survey_question_3_choice_1"), + t("templates.professional_development_survey_question_3_choice_2"), + ], + t, + }), + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.professional_development_survey_question_4_headline"), + lowerLabel: t("templates.professional_development_survey_question_4_lower_label"), + upperLabel: t("templates.professional_development_survey_question_4_upper_label"), required: true, - placeholder: { default: t("templates.evaluate_a_product_idea_question_3_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[3], - html: { - default: t("templates.evaluate_a_product_idea_question_4_html"), - }, - type: TSurveyQuestionTypeEnum.CTA, - headline: { default: t("templates.evaluate_a_product_idea_question_4_headline") }, + isColorCodingEnabled: false, + t, + }), + buildMultipleChoiceQuestion({ + id: createId(), + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + headline: t("templates.professional_development_survey_question_5_headline"), required: true, - buttonLabel: { default: t("templates.evaluate_a_product_idea_question_4_button_label") }, - buttonExternal: false, - dismissButtonLabel: { - default: t("templates.evaluate_a_product_idea_question_4_dismiss_button_label"), - }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[4], - type: TSurveyQuestionTypeEnum.Rating, + shuffleOption: "exceptLast", + choices: [ + t("templates.professional_development_survey_question_5_choice_1"), + t("templates.professional_development_survey_question_5_choice_2"), + t("templates.professional_development_survey_question_5_choice_3"), + t("templates.professional_development_survey_question_5_choice_4"), + t("templates.professional_development_survey_question_5_choice_5"), + t("templates.professional_development_survey_question_5_choice_6"), + ], + buttonLabel: t("templates.finish"), + containsOther: true, + t, + }), + ], + }, + t + ); +}; + +const rateCheckoutExperience = (t: TFnType): TTemplate => { + const localSurvey = getDefaultSurveyPreset(t); + const reusableQuestionIds = [createId(), createId(), createId()]; + return buildSurvey( + { + name: t("templates.rate_checkout_experience_name"), + role: "productManager", + industries: ["eCommerce"], + channels: ["website", "app"], + description: t("templates.rate_checkout_experience_description"), + endings: localSurvey.endings, + questions: [ + buildRatingQuestion({ + id: reusableQuestionIds[0], logic: [ { id: createId(), @@ -5756,35 +2263,7 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => { { id: createId(), leftOperand: { - value: reusableQuestionIds[4], - type: "question", - }, - operator: "isLessThanOrEqual", - rightOperand: { - type: "static", - value: 3, - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[5], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[4], + value: reusableQuestionIds[0], type: "question", }, operator: "isGreaterThanOrEqual", @@ -5799,110 +2278,58 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => { { id: createId(), objective: "jumpToQuestion", - target: reusableQuestionIds[6], + target: reusableQuestionIds[2], }, ], }, ], range: 5, scale: "number", - headline: { default: t("templates.evaluate_a_product_idea_question_5_headline") }, + headline: t("templates.rate_checkout_experience_question_1_headline"), required: true, - lowerLabel: { default: t("templates.evaluate_a_product_idea_question_5_lower_label") }, - upperLabel: { default: t("templates.evaluate_a_product_idea_question_5_upper_label") }, + lowerLabel: t("templates.rate_checkout_experience_question_1_lower_label"), + upperLabel: t("templates.rate_checkout_experience_question_1_upper_label"), isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[5], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - logic: [ - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[5], - type: "question", - }, - operator: "isSubmitted", - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[7], - }, - ], - }, - ], - headline: { default: t("templates.evaluate_a_product_idea_question_6_headline") }, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[1], + logic: [createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isSubmitted")], + headline: t("templates.rate_checkout_experience_question_2_headline"), required: true, - placeholder: { default: t("templates.evaluate_a_product_idea_question_6_placeholder") }, + placeholder: t("templates.rate_checkout_experience_question_2_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[6], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.evaluate_a_product_idea_question_7_headline") }, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[2], + headline: t("templates.rate_checkout_experience_question_3_headline"), required: true, - placeholder: { default: t("templates.evaluate_a_product_idea_question_7_placeholder") }, - inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[7], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.evaluate_a_product_idea_question_8_headline") }, - required: false, - placeholder: { default: t("templates.evaluate_a_product_idea_question_8_placeholder") }, + placeholder: t("templates.rate_checkout_experience_question_3_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, + buttonLabel: t("templates.finish"), + t, + }), ], }, - }; + t + ); }; -const understandLowEngagement = (t: TFnType): TTemplate => { +const measureSearchExperience = (t: TFnType): TTemplate => { const localSurvey = getDefaultSurveyPreset(t); - const reusableQuestionIds = [createId(), createId(), createId(), createId(), createId(), createId()]; - - const reusableOptionIds = [createId(), createId(), createId(), createId()]; - return { - name: t("templates.understand_low_engagement_name"), - role: "productManager", - industries: ["saas"], - channels: ["link"], - description: t("templates.understand_low_engagement_description"), - preset: { - ...localSurvey, - name: t("templates.understand_low_engagement_name"), + const reusableQuestionIds = [createId(), createId(), createId()]; + return buildSurvey( + { + name: t("templates.measure_search_experience_name"), + role: "productManager", + industries: ["saas", "eCommerce"], + channels: ["app", "website"], + description: t("templates.measure_search_experience_description"), + endings: localSurvey.endings, questions: [ - { + buildRatingQuestion({ id: reusableQuestionIds[0], - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, - shuffleOption: "none", logic: [ { id: createId(), @@ -5916,38 +2343,10 @@ const understandLowEngagement = (t: TFnType): TTemplate => { value: reusableQuestionIds[0], type: "question", }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[0], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[1], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", + operator: "isGreaterThanOrEqual", rightOperand: { type: "static", - value: reusableOptionIds[1], + value: 4, }, }, ], @@ -5960,6 +2359,55 @@ const understandLowEngagement = (t: TFnType): TTemplate => { }, ], }, + ], + range: 5, + scale: "number", + headline: t("templates.measure_search_experience_question_1_headline"), + required: true, + lowerLabel: t("templates.measure_search_experience_question_1_lower_label"), + upperLabel: t("templates.measure_search_experience_question_1_upper_label"), + isColorCodingEnabled: false, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[1], + logic: [createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isSubmitted")], + headline: t("templates.measure_search_experience_question_2_headline"), + required: true, + placeholder: t("templates.measure_search_experience_question_2_placeholder"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[2], + headline: t("templates.measure_search_experience_question_3_headline"), + required: true, + placeholder: t("templates.measure_search_experience_question_3_placeholder"), + inputType: "text", + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const evaluateContentQuality = (t: TFnType): TTemplate => { + const localSurvey = getDefaultSurveyPreset(t); + const reusableQuestionIds = [createId(), createId(), createId()]; + return buildSurvey( + { + name: t("templates.evaluate_content_quality_name"), + role: "marketing", + industries: ["other"], + channels: ["website"], + description: t("templates.evaluate_content_quality_description"), + endings: localSurvey.endings, + questions: [ + buildRatingQuestion({ + id: reusableQuestionIds[0], + logic: [ { id: createId(), conditions: { @@ -5972,38 +2420,10 @@ const understandLowEngagement = (t: TFnType): TTemplate => { value: reusableQuestionIds[0], type: "question", }, - operator: "equals", - rightOperand: { - type: "static", - value: reusableOptionIds[2], - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[3], - }, - ], - }, - { - id: createId(), - conditions: { - id: createId(), - connector: "and", - conditions: [ - { - id: createId(), - leftOperand: { - value: reusableQuestionIds[0], - type: "question", - }, - operator: "equals", + operator: "isGreaterThanOrEqual", rightOperand: { type: "static", - value: reusableOptionIds[3], + value: 4, }, }, ], @@ -6012,10 +2432,79 @@ const understandLowEngagement = (t: TFnType): TTemplate => { { id: createId(), objective: "jumpToQuestion", - target: reusableQuestionIds[4], + target: reusableQuestionIds[2], }, ], }, + ], + range: 5, + scale: "number", + headline: t("templates.evaluate_content_quality_question_1_headline"), + required: true, + lowerLabel: t("templates.evaluate_content_quality_question_1_lower_label"), + upperLabel: t("templates.evaluate_content_quality_question_1_upper_label"), + isColorCodingEnabled: false, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[1], + logic: [createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isSubmitted")], + headline: t("templates.evaluate_content_quality_question_2_headline"), + required: true, + placeholder: t("templates.evaluate_content_quality_question_2_placeholder"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[2], + headline: t("templates.evaluate_content_quality_question_3_headline"), + required: true, + placeholder: t("templates.evaluate_content_quality_question_3_placeholder"), + inputType: "text", + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const measureTaskAccomplishment = (t: TFnType): TTemplate => { + const localSurvey = getDefaultSurveyPreset(t); + const reusableQuestionIds = [createId(), createId(), createId(), createId(), createId()]; + const reusableOptionIds = [createId(), createId(), createId()]; + return buildSurvey( + { + name: t("templates.measure_task_accomplishment_name"), + role: "productManager", + industries: ["saas"], + channels: ["app", "website"], + description: t("templates.measure_task_accomplishment_description"), + endings: localSurvey.endings, + questions: [ + buildMultipleChoiceQuestion({ + id: reusableQuestionIds[0], + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + shuffleOption: "none", + logic: [ + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[1], reusableQuestionIds[3]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[0], reusableQuestionIds[1]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[2], reusableQuestionIds[4]), + ], + choices: [ + t("templates.measure_task_accomplishment_question_1_option_1_label"), + t("templates.measure_task_accomplishment_question_1_option_2_label"), + t("templates.measure_task_accomplishment_question_1_option_3_label"), + ], + choiceIds: [reusableOptionIds[0], reusableOptionIds[1], reusableOptionIds[2]], + headline: t("templates.measure_task_accomplishment_question_1_headline"), + required: true, + t, + }), + buildRatingQuestion({ + id: reusableQuestionIds[1], + logic: [ { id: createId(), conditions: { @@ -6025,74 +2514,60 @@ const understandLowEngagement = (t: TFnType): TTemplate => { { id: createId(), leftOperand: { - value: reusableQuestionIds[0], + value: reusableQuestionIds[1], type: "question", }, - operator: "equals", + operator: "isGreaterThanOrEqual", rightOperand: { type: "static", - value: "other", - }, - }, - ], - }, - actions: [ - { - id: createId(), - objective: "jumpToQuestion", - target: reusableQuestionIds[5], - }, - ], - }, - ], - choices: [ - { - id: reusableOptionIds[0], - label: { default: t("templates.understand_low_engagement_question_1_choice_1") }, - }, - { - id: reusableOptionIds[1], - label: { default: t("templates.understand_low_engagement_question_1_choice_2") }, - }, - { - id: reusableOptionIds[2], - label: { default: t("templates.understand_low_engagement_question_1_choice_3") }, - }, - { - id: reusableOptionIds[3], - label: { default: t("templates.understand_low_engagement_question_1_choice_4") }, - }, - { - id: "other", - label: { default: t("templates.understand_low_engagement_question_1_choice_5") }, + value: 4, + }, + }, + ], + }, + actions: [ + { + id: createId(), + objective: "jumpToQuestion", + target: reusableQuestionIds[3], + }, + ], }, ], - headline: { default: t("templates.understand_low_engagement_question_1_headline") }, - required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[1], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, + range: 5, + scale: "number", + headline: t("templates.measure_task_accomplishment_question_2_headline"), + required: false, + lowerLabel: t("templates.measure_task_accomplishment_question_2_lower_label"), + upperLabel: t("templates.measure_task_accomplishment_question_2_upper_label"), + isColorCodingEnabled: false, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[2], logic: [ { id: createId(), conditions: { id: createId(), - connector: "and", + connector: "or", conditions: [ { id: createId(), leftOperand: { - value: reusableQuestionIds[1], + value: reusableQuestionIds[2], type: "question", }, operator: "isSubmitted", }, + { + id: createId(), + leftOperand: { + value: reusableQuestionIds[1], + type: "question", + }, + operator: "isSkipped", + }, ], }, actions: [ @@ -6104,34 +2579,37 @@ const understandLowEngagement = (t: TFnType): TTemplate => { ], }, ], - headline: { default: t("templates.understand_low_engagement_question_2_headline") }, - required: true, - placeholder: { default: t("templates.understand_low_engagement_question_2_placeholder") }, + headline: t("templates.measure_task_accomplishment_question_3_headline"), + required: false, + placeholder: t("templates.measure_task_accomplishment_question_3_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[2], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[3], logic: [ { id: createId(), conditions: { id: createId(), - connector: "and", + connector: "or", conditions: [ { id: createId(), leftOperand: { - value: reusableQuestionIds[2], + value: reusableQuestionIds[3], type: "question", }, operator: "isSubmitted", }, + { + id: createId(), + leftOperand: { + value: reusableQuestionIds[1], + type: "question", + }, + operator: "isSkipped", + }, ], }, actions: [ @@ -6143,19 +2621,64 @@ const understandLowEngagement = (t: TFnType): TTemplate => { ], }, ], - headline: { default: t("templates.understand_low_engagement_question_3_headline") }, + headline: t("templates.measure_task_accomplishment_question_4_headline"), + required: false, + buttonLabel: t("templates.measure_task_accomplishment_question_4_button_label"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[4], + headline: t("templates.measure_task_accomplishment_question_5_headline"), required: true, - placeholder: { default: t("templates.understand_low_engagement_question_3_placeholder") }, + buttonLabel: t("templates.measure_task_accomplishment_question_5_button_label"), + placeholder: t("templates.measure_task_accomplishment_question_5_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: reusableQuestionIds[3], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, + t, + }), + ], + }, + t + ); +}; + +const identifySignUpBarriers = (t: TFnType): TTemplate => { + const localSurvey = getDefaultSurveyPreset(t); + const reusableQuestionIds = [ + createId(), + createId(), + createId(), + createId(), + createId(), + createId(), + createId(), + createId(), + createId(), + ]; + const reusableOptionIds = [createId(), createId(), createId(), createId(), createId()]; + + return buildSurvey( + { + name: t("templates.identify_sign_up_barriers_name"), + role: "marketing", + industries: ["saas", "eCommerce", "other"], + channels: ["website"], + description: t("templates.identify_sign_up_barriers_description"), + endings: localSurvey.endings, + questions: [ + buildCTAQuestion({ + id: reusableQuestionIds[0], + html: t("templates.identify_sign_up_barriers_question_1_html"), + logic: [createJumpLogic(reusableQuestionIds[0], localSurvey.endings[0].id, "isSkipped")], + headline: t("templates.identify_sign_up_barriers_question_1_headline"), + required: false, + buttonLabel: t("templates.identify_sign_up_barriers_question_1_button_label"), + buttonExternal: false, + dismissButtonLabel: t("templates.identify_sign_up_barriers_question_1_dismiss_button_label"), + t, + }), + buildRatingQuestion({ + id: reusableQuestionIds[1], logic: [ { id: createId(), @@ -6166,10 +2689,14 @@ const understandLowEngagement = (t: TFnType): TTemplate => { { id: createId(), leftOperand: { - value: reusableQuestionIds[3], + value: reusableQuestionIds[1], type: "question", }, - operator: "isSubmitted", + operator: "equals", + rightOperand: { + type: "static", + value: 5, + }, }, ], }, @@ -6182,20 +2709,210 @@ const understandLowEngagement = (t: TFnType): TTemplate => { ], }, ], - headline: { default: t("templates.understand_low_engagement_question_4_headline") }, + range: 5, + scale: "number", + headline: t("templates.identify_sign_up_barriers_question_2_headline"), + required: true, + lowerLabel: t("templates.identify_sign_up_barriers_question_2_lower_label"), + upperLabel: t("templates.identify_sign_up_barriers_question_2_upper_label"), + isColorCodingEnabled: false, + t, + }), + buildMultipleChoiceQuestion({ + id: reusableQuestionIds[2], + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + shuffleOption: "none", + logic: [ + createChoiceJumpLogic(reusableQuestionIds[2], reusableOptionIds[0], reusableQuestionIds[3]), + createChoiceJumpLogic(reusableQuestionIds[2], reusableOptionIds[1], reusableQuestionIds[4]), + createChoiceJumpLogic(reusableQuestionIds[2], reusableOptionIds[2], reusableQuestionIds[5]), + createChoiceJumpLogic(reusableQuestionIds[2], reusableOptionIds[3], reusableQuestionIds[6]), + createChoiceJumpLogic(reusableQuestionIds[2], reusableOptionIds[4], reusableQuestionIds[7]), + ], + choices: [ + t("templates.identify_sign_up_barriers_question_3_choice_1_label"), + t("templates.identify_sign_up_barriers_question_3_choice_2_label"), + t("templates.identify_sign_up_barriers_question_3_choice_3_label"), + t("templates.identify_sign_up_barriers_question_3_choice_4_label"), + t("templates.identify_sign_up_barriers_question_3_choice_5_label"), + ], + choiceIds: [ + reusableOptionIds[0], + reusableOptionIds[1], + reusableOptionIds[2], + reusableOptionIds[3], + reusableOptionIds[4], + ], + headline: t("templates.identify_sign_up_barriers_question_3_headline"), + required: true, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[3], + logic: [createJumpLogic(reusableQuestionIds[3], reusableQuestionIds[8], "isSubmitted")], + headline: t("templates.identify_sign_up_barriers_question_4_headline"), required: true, - placeholder: { default: t("templates.understand_low_engagement_question_4_placeholder") }, + placeholder: t("templates.identify_sign_up_barriers_question_4_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[4], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, + logic: [createJumpLogic(reusableQuestionIds[4], reusableQuestionIds[8], "isSubmitted")], + headline: t("templates.identify_sign_up_barriers_question_5_headline"), + required: true, + placeholder: t("templates.identify_sign_up_barriers_question_5_placeholder"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[5], + logic: [createJumpLogic(reusableQuestionIds[5], reusableQuestionIds[8], "isSubmitted")], + headline: t("templates.identify_sign_up_barriers_question_6_headline"), + required: true, + placeholder: t("templates.identify_sign_up_barriers_question_6_placeholder"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[6], + logic: [createJumpLogic(reusableQuestionIds[6], reusableQuestionIds[8], "isSubmitted")], + headline: t("templates.identify_sign_up_barriers_question_7_headline"), + required: true, + placeholder: t("templates.identify_sign_up_barriers_question_7_placeholder"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[7], + headline: t("templates.identify_sign_up_barriers_question_8_headline"), + required: true, + placeholder: t("templates.identify_sign_up_barriers_question_8_placeholder"), + inputType: "text", + t, + }), + buildCTAQuestion({ + id: reusableQuestionIds[8], + html: t("templates.identify_sign_up_barriers_question_9_html"), + headline: t("templates.identify_sign_up_barriers_question_9_headline"), + required: false, + buttonUrl: "https://app.formbricks.com/auth/signup", + buttonLabel: t("templates.identify_sign_up_barriers_question_9_button_label"), + buttonExternal: true, + dismissButtonLabel: t("templates.identify_sign_up_barriers_question_9_dismiss_button_label"), + t, + }), + ], + }, + t + ); +}; + +const buildProductRoadmap = (t: TFnType): TTemplate => { + return buildSurvey( + { + name: t("templates.build_product_roadmap_name"), + role: "productManager", + industries: ["saas"], + channels: ["app", "link"], + description: t("templates.build_product_roadmap_description"), + questions: [ + buildRatingQuestion({ + range: 5, + scale: "number", + headline: t("templates.build_product_roadmap_question_1_headline"), + required: true, + lowerLabel: t("templates.build_product_roadmap_question_1_lower_label"), + upperLabel: t("templates.build_product_roadmap_question_1_upper_label"), + isColorCodingEnabled: false, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.build_product_roadmap_question_2_headline"), + required: true, + placeholder: t("templates.build_product_roadmap_question_2_placeholder"), + inputType: "text", + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const understandPurchaseIntention = (t: TFnType): TTemplate => { + const localSurvey = getDefaultSurveyPreset(t); + const reusableQuestionIds = [createId(), createId(), createId()]; + return buildSurvey( + { + name: t("templates.understand_purchase_intention_name"), + role: "sales", + industries: ["eCommerce"], + channels: ["website", "link", "app"], + description: t("templates.understand_purchase_intention_description"), + endings: localSurvey.endings, + questions: [ + buildRatingQuestion({ + id: reusableQuestionIds[0], + logic: [ + createChoiceJumpLogic(reusableQuestionIds[0], 2, reusableQuestionIds[1]), + createChoiceJumpLogic(reusableQuestionIds[0], 3, reusableQuestionIds[2]), + createChoiceJumpLogic(reusableQuestionIds[0], 4, reusableQuestionIds[2]), + createChoiceJumpLogic(reusableQuestionIds[0], 5, localSurvey.endings[0].id), + ], + range: 5, + scale: "number", + headline: t("templates.understand_purchase_intention_question_1_headline"), + required: true, + lowerLabel: t("templates.understand_purchase_intention_question_1_lower_label"), + upperLabel: t("templates.understand_purchase_intention_question_1_upper_label"), + isColorCodingEnabled: false, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[1], + logic: [ + createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isSubmitted"), + createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isSkipped"), + ], + headline: t("templates.understand_purchase_intention_question_2_headline"), + required: false, + placeholder: t("templates.understand_purchase_intention_question_2_placeholder"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[2], + headline: t("templates.understand_purchase_intention_question_3_headline"), + required: true, + placeholder: t("templates.understand_purchase_intention_question_3_placeholder"), + inputType: "text", + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const improveNewsletterContent = (t: TFnType): TTemplate => { + const localSurvey = getDefaultSurveyPreset(t); + const reusableQuestionIds = [createId(), createId(), createId()]; + return buildSurvey( + { + name: t("templates.improve_newsletter_content_name"), + role: "marketing", + industries: ["eCommerce", "saas", "other"], + channels: ["link"], + description: t("templates.improve_newsletter_content_description"), + endings: localSurvey.endings, + questions: [ + buildRatingQuestion({ + id: reusableQuestionIds[0], logic: [ + createChoiceJumpLogic(reusableQuestionIds[0], 5, reusableQuestionIds[2]), { id: createId(), conditions: { @@ -6205,10 +2922,14 @@ const understandLowEngagement = (t: TFnType): TTemplate => { { id: createId(), leftOperand: { - value: reusableQuestionIds[4], + value: reusableQuestionIds[0], type: "question", }, - operator: "isSubmitted", + operator: "isLessThan", + rightOperand: { + type: "static", + value: 5, + }, }, ], }, @@ -6216,667 +2937,603 @@ const understandLowEngagement = (t: TFnType): TTemplate => { { id: createId(), objective: "jumpToQuestion", - target: localSurvey.endings[0].id, + target: reusableQuestionIds[1], }, ], }, ], - headline: { default: t("templates.understand_low_engagement_question_5_headline") }, + range: 5, + scale: "smiley", + headline: t("templates.improve_newsletter_content_question_1_headline"), + required: true, + lowerLabel: t("templates.improve_newsletter_content_question_1_lower_label"), + upperLabel: t("templates.improve_newsletter_content_question_1_upper_label"), + isColorCodingEnabled: false, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[1], + logic: [ + createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isSubmitted"), + createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isSkipped"), + ], + headline: t("templates.improve_newsletter_content_question_2_headline"), + required: false, + placeholder: t("templates.improve_newsletter_content_question_2_placeholder"), + inputType: "text", + t, + }), + buildCTAQuestion({ + id: reusableQuestionIds[2], + html: t("templates.improve_newsletter_content_question_3_html"), + headline: t("templates.improve_newsletter_content_question_3_headline"), + required: false, + buttonUrl: "https://formbricks.com", + buttonLabel: t("templates.improve_newsletter_content_question_3_button_label"), + buttonExternal: true, + dismissButtonLabel: t("templates.improve_newsletter_content_question_3_dismiss_button_label"), + t, + }), + ], + }, + t + ); +}; + +const evaluateAProductIdea = (t: TFnType): TTemplate => { + const reusableQuestionIds = [ + createId(), + createId(), + createId(), + createId(), + createId(), + createId(), + createId(), + createId(), + ]; + return buildSurvey( + { + name: t("templates.evaluate_a_product_idea_name"), + role: "productManager", + industries: ["saas", "other"], + channels: ["link", "app"], + description: t("templates.evaluate_a_product_idea_description"), + questions: [ + buildCTAQuestion({ + id: reusableQuestionIds[0], + html: t("templates.evaluate_a_product_idea_question_1_html"), + headline: t("templates.evaluate_a_product_idea_question_1_headline"), + required: true, + buttonLabel: t("templates.evaluate_a_product_idea_question_1_button_label"), + buttonExternal: false, + dismissButtonLabel: t("templates.evaluate_a_product_idea_question_1_dismiss_button_label"), + t, + }), + buildRatingQuestion({ + id: reusableQuestionIds[1], + logic: [ + createChoiceJumpLogic(reusableQuestionIds[1], 3, reusableQuestionIds[2]), + createChoiceJumpLogic(reusableQuestionIds[1], 4, reusableQuestionIds[3]), + ], + range: 5, + scale: "number", + headline: t("templates.evaluate_a_product_idea_question_2_headline"), + required: true, + lowerLabel: t("templates.evaluate_a_product_idea_question_2_lower_label"), + upperLabel: t("templates.evaluate_a_product_idea_question_2_upper_label"), + isColorCodingEnabled: false, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[2], + headline: t("templates.evaluate_a_product_idea_question_3_headline"), + required: true, + placeholder: t("templates.evaluate_a_product_idea_question_3_placeholder"), + inputType: "text", + t, + }), + buildCTAQuestion({ + id: reusableQuestionIds[3], + html: t("templates.evaluate_a_product_idea_question_4_html"), + headline: t("templates.evaluate_a_product_idea_question_4_headline"), + required: true, + buttonLabel: t("templates.evaluate_a_product_idea_question_4_button_label"), + buttonExternal: false, + dismissButtonLabel: t("templates.evaluate_a_product_idea_question_4_dismiss_button_label"), + t, + }), + buildRatingQuestion({ + id: reusableQuestionIds[4], + logic: [ + createChoiceJumpLogic(reusableQuestionIds[4], 3, reusableQuestionIds[5]), + createChoiceJumpLogic(reusableQuestionIds[4], 4, reusableQuestionIds[6]), + ], + range: 5, + scale: "number", + headline: t("templates.evaluate_a_product_idea_question_5_headline"), + required: true, + lowerLabel: t("templates.evaluate_a_product_idea_question_5_lower_label"), + upperLabel: t("templates.evaluate_a_product_idea_question_5_upper_label"), + isColorCodingEnabled: false, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[5], + logic: [createJumpLogic(reusableQuestionIds[5], reusableQuestionIds[7], "isSubmitted")], + headline: t("templates.evaluate_a_product_idea_question_6_headline"), + required: true, + placeholder: t("templates.evaluate_a_product_idea_question_6_placeholder"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[6], + headline: t("templates.evaluate_a_product_idea_question_7_headline"), + required: true, + placeholder: t("templates.evaluate_a_product_idea_question_7_placeholder"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[7], + headline: t("templates.evaluate_a_product_idea_question_8_headline"), + required: false, + placeholder: t("templates.evaluate_a_product_idea_question_8_placeholder"), + inputType: "text", + buttonLabel: t("templates.finish"), + t, + }), + ], + }, + t + ); +}; + +const understandLowEngagement = (t: TFnType): TTemplate => { + const localSurvey = getDefaultSurveyPreset(t); + const reusableQuestionIds = [createId(), createId(), createId(), createId(), createId(), createId()]; + + const reusableOptionIds = [createId(), createId(), createId(), createId()]; + return buildSurvey( + { + name: t("templates.understand_low_engagement_name"), + role: "productManager", + industries: ["saas"], + channels: ["link"], + description: t("templates.understand_low_engagement_description"), + endings: localSurvey.endings, + questions: [ + buildMultipleChoiceQuestion({ + id: reusableQuestionIds[0], + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + shuffleOption: "none", + logic: [ + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[0], reusableQuestionIds[1]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[1], reusableQuestionIds[2]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[2], reusableQuestionIds[3]), + createChoiceJumpLogic(reusableQuestionIds[0], reusableOptionIds[3], reusableQuestionIds[4]), + createChoiceJumpLogic(reusableQuestionIds[0], "other", reusableQuestionIds[5]), + ], + choices: [ + t("templates.understand_low_engagement_question_1_choice_1"), + t("templates.understand_low_engagement_question_1_choice_2"), + t("templates.understand_low_engagement_question_1_choice_3"), + t("templates.understand_low_engagement_question_1_choice_4"), + t("templates.understand_low_engagement_question_1_choice_5"), + ], + choiceIds: [reusableOptionIds[0], reusableOptionIds[1], reusableOptionIds[2], reusableOptionIds[3]], + headline: t("templates.understand_low_engagement_question_1_headline"), + required: true, + containsOther: true, + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[1], + logic: [createJumpLogic(reusableQuestionIds[1], localSurvey.endings[0].id, "isSubmitted")], + headline: t("templates.understand_low_engagement_question_2_headline"), required: true, - placeholder: { default: t("templates.understand_low_engagement_question_5_placeholder") }, + placeholder: t("templates.understand_low_engagement_question_2_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[2], + logic: [createJumpLogic(reusableQuestionIds[2], localSurvey.endings[0].id, "isSubmitted")], + headline: t("templates.understand_low_engagement_question_3_headline"), + required: true, + placeholder: t("templates.understand_low_engagement_question_3_placeholder"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[3], + logic: [createJumpLogic(reusableQuestionIds[3], localSurvey.endings[0].id, "isSubmitted")], + headline: t("templates.understand_low_engagement_question_4_headline"), + required: true, + placeholder: t("templates.understand_low_engagement_question_4_placeholder"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ + id: reusableQuestionIds[4], + logic: [createJumpLogic(reusableQuestionIds[4], localSurvey.endings[0].id, "isSubmitted")], + headline: t("templates.understand_low_engagement_question_5_headline"), + required: true, + placeholder: t("templates.understand_low_engagement_question_5_placeholder"), + inputType: "text", + t, + }), + buildOpenTextQuestion({ id: reusableQuestionIds[5], - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, logic: [], - headline: { default: t("templates.understand_low_engagement_question_6_headline") }, + headline: t("templates.understand_low_engagement_question_6_headline"), required: false, - placeholder: { default: t("templates.understand_low_engagement_question_6_placeholder") }, + placeholder: t("templates.understand_low_engagement_question_6_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, + buttonLabel: t("templates.finish"), + t, + }), ], }, - }; + t + ); }; const employeeWellBeing = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.employee_well_being_name"), - role: "peopleManager", - industries: ["saas", "eCommerce", "other"], - channels: ["link"], - description: t("templates.employee_well_being_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.employee_well_being_name"), + role: "peopleManager", + industries: ["saas", "eCommerce", "other"], + channels: ["link"], + description: t("templates.employee_well_being_description"), questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { default: t("templates.employee_well_being_question_1_headline") }, + buildRatingQuestion({ + headline: t("templates.employee_well_being_question_1_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.employee_well_being_question_1_lower_label"), - }, - upperLabel: { - default: t("templates.employee_well_being_question_1_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { - default: t("templates.employee_well_being_question_2_headline"), - }, + lowerLabel: t("templates.employee_well_being_question_1_lower_label"), + upperLabel: t("templates.employee_well_being_question_1_upper_label"), + t, + }), + buildRatingQuestion({ + headline: t("templates.employee_well_being_question_2_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.employee_well_being_question_2_lower_label"), - }, - upperLabel: { - default: t("templates.employee_well_being_question_2_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { default: t("templates.employee_well_being_question_3_headline") }, + lowerLabel: t("templates.employee_well_being_question_2_lower_label"), + upperLabel: t("templates.employee_well_being_question_2_upper_label"), + t, + }), + buildRatingQuestion({ + headline: t("templates.employee_well_being_question_3_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.employee_well_being_question_3_lower_label"), - }, - upperLabel: { - default: t("templates.employee_well_being_question_3_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.employee_well_being_question_4_headline") }, + lowerLabel: t("templates.employee_well_being_question_3_lower_label"), + upperLabel: t("templates.employee_well_being_question_3_upper_label"), + t, + }), + buildOpenTextQuestion({ + headline: t("templates.employee_well_being_question_4_headline"), required: false, - placeholder: { default: t("templates.employee_well_being_question_4_placeholder") }, + placeholder: t("templates.employee_well_being_question_4_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, + buttonLabel: t("templates.finish"), + t, + }), ], }, - }; + t + ); }; const longTermRetentionCheckIn = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.long_term_retention_check_in_name"), - role: "peopleManager", - industries: ["saas", "other"], - channels: ["app", "link"], - description: t("templates.long_term_retention_check_in_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.long_term_retention_check_in_name"), + role: "peopleManager", + industries: ["saas", "other"], + channels: ["app", "link"], + description: t("templates.long_term_retention_check_in_description"), questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, + buildRatingQuestion({ range: 5, scale: "star", - headline: { default: t("templates.long_term_retention_check_in_question_1_headline") }, + headline: t("templates.long_term_retention_check_in_question_1_headline"), required: true, - lowerLabel: { default: t("templates.long_term_retention_check_in_question_1_lower_label") }, - upperLabel: { default: t("templates.long_term_retention_check_in_question_1_upper_label") }, + lowerLabel: t("templates.long_term_retention_check_in_question_1_lower_label"), + upperLabel: t("templates.long_term_retention_check_in_question_1_upper_label"), isColorCodingEnabled: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.long_term_retention_check_in_question_2_headline") }, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.long_term_retention_check_in_question_2_headline"), required: false, - placeholder: { default: t("templates.long_term_retention_check_in_question_2_placeholder") }, + placeholder: t("templates.long_term_retention_check_in_question_2_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), + t, + }), + buildMultipleChoiceQuestion({ type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, shuffleOption: "none", choices: [ - { - id: createId(), - label: { default: t("templates.long_term_retention_check_in_question_3_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.long_term_retention_check_in_question_3_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.long_term_retention_check_in_question_3_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.long_term_retention_check_in_question_3_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.long_term_retention_check_in_question_3_choice_5") }, - }, + t("templates.long_term_retention_check_in_question_3_choice_1"), + t("templates.long_term_retention_check_in_question_3_choice_2"), + t("templates.long_term_retention_check_in_question_3_choice_3"), + t("templates.long_term_retention_check_in_question_3_choice_4"), + t("templates.long_term_retention_check_in_question_3_choice_5"), ], - headline: { - default: t("templates.long_term_retention_check_in_question_3_headline"), - }, + headline: t("templates.long_term_retention_check_in_question_3_headline"), required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, + t, + }), + buildRatingQuestion({ range: 5, scale: "number", - headline: { default: t("templates.long_term_retention_check_in_question_4_headline") }, + headline: t("templates.long_term_retention_check_in_question_4_headline"), required: true, - lowerLabel: { default: t("templates.long_term_retention_check_in_question_4_lower_label") }, - upperLabel: { default: t("templates.long_term_retention_check_in_question_4_upper_label") }, + lowerLabel: t("templates.long_term_retention_check_in_question_4_lower_label"), + upperLabel: t("templates.long_term_retention_check_in_question_4_upper_label"), isColorCodingEnabled: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { - default: t("templates.long_term_retention_check_in_question_5_headline"), - }, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.long_term_retention_check_in_question_5_headline"), required: false, - placeholder: { default: t("templates.long_term_retention_check_in_question_5_placeholder") }, + placeholder: t("templates.long_term_retention_check_in_question_5_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.NPS, - headline: { default: t("templates.long_term_retention_check_in_question_6_headline") }, + t, + }), + buildNPSQuestion({ + headline: t("templates.long_term_retention_check_in_question_6_headline"), required: false, - lowerLabel: { default: t("templates.long_term_retention_check_in_question_6_lower_label") }, - upperLabel: { default: t("templates.long_term_retention_check_in_question_6_upper_label") }, + lowerLabel: t("templates.long_term_retention_check_in_question_6_lower_label"), + upperLabel: t("templates.long_term_retention_check_in_question_6_upper_label"), isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), + t, + }), + buildMultipleChoiceQuestion({ type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, shuffleOption: "none", choices: [ - { - id: createId(), - label: { default: t("templates.long_term_retention_check_in_question_7_choice_1") }, - }, - { - id: createId(), - label: { default: t("templates.long_term_retention_check_in_question_7_choice_2") }, - }, - { - id: createId(), - label: { default: t("templates.long_term_retention_check_in_question_7_choice_3") }, - }, - { - id: createId(), - label: { default: t("templates.long_term_retention_check_in_question_7_choice_4") }, - }, - { - id: createId(), - label: { default: t("templates.long_term_retention_check_in_question_7_choice_5") }, - }, - ], - headline: { default: t("templates.long_term_retention_check_in_question_7_headline") }, - required: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.long_term_retention_check_in_question_8_headline") }, + t("templates.long_term_retention_check_in_question_7_choice_1"), + t("templates.long_term_retention_check_in_question_7_choice_2"), + t("templates.long_term_retention_check_in_question_7_choice_3"), + t("templates.long_term_retention_check_in_question_7_choice_4"), + t("templates.long_term_retention_check_in_question_7_choice_5"), + ], + headline: t("templates.long_term_retention_check_in_question_7_headline"), + required: true, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.long_term_retention_check_in_question_8_headline"), required: false, - placeholder: { default: t("templates.long_term_retention_check_in_question_8_placeholder") }, + placeholder: t("templates.long_term_retention_check_in_question_8_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, + t, + }), + buildRatingQuestion({ range: 5, scale: "smiley", - headline: { default: t("templates.long_term_retention_check_in_question_9_headline") }, + headline: t("templates.long_term_retention_check_in_question_9_headline"), required: true, - lowerLabel: { default: t("templates.long_term_retention_check_in_question_9_lower_label") }, - upperLabel: { default: t("templates.long_term_retention_check_in_question_9_upper_label") }, + lowerLabel: t("templates.long_term_retention_check_in_question_9_lower_label"), + upperLabel: t("templates.long_term_retention_check_in_question_9_upper_label"), isColorCodingEnabled: true, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { default: t("templates.long_term_retention_check_in_question_10_headline") }, + t, + }), + buildOpenTextQuestion({ + headline: t("templates.long_term_retention_check_in_question_10_headline"), required: false, - placeholder: { default: t("templates.long_term_retention_check_in_question_10_placeholder") }, + placeholder: t("templates.long_term_retention_check_in_question_10_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, + t, + }), ], }, - }; + t + ); }; const professionalDevelopmentGrowth = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.professional_development_growth_survey_name"), - role: "peopleManager", - industries: ["saas", "eCommerce", "other"], - channels: ["link"], - description: t("templates.professional_development_growth_survey_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.professional_development_growth_survey_name"), + role: "peopleManager", + industries: ["saas", "eCommerce", "other"], + channels: ["link"], + description: t("templates.professional_development_growth_survey_description"), questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { - default: t("templates.professional_development_growth_survey_question_1_headline"), - }, + buildRatingQuestion({ + headline: t("templates.professional_development_growth_survey_question_1_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.professional_development_growth_survey_question_1_lower_label"), - }, - upperLabel: { - default: t("templates.professional_development_growth_survey_question_1_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { - default: t("templates.professional_development_growth_survey_question_2_headline"), - }, + lowerLabel: t("templates.professional_development_growth_survey_question_1_lower_label"), + upperLabel: t("templates.professional_development_growth_survey_question_1_upper_label"), + t, + }), + buildRatingQuestion({ + headline: t("templates.professional_development_growth_survey_question_2_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.professional_development_growth_survey_question_2_lower_label"), - }, - upperLabel: { - default: t("templates.professional_development_growth_survey_question_2_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { - default: t("templates.professional_development_growth_survey_question_3_headline"), - }, + lowerLabel: t("templates.professional_development_growth_survey_question_2_lower_label"), + upperLabel: t("templates.professional_development_growth_survey_question_2_upper_label"), + t, + }), + buildRatingQuestion({ + headline: t("templates.professional_development_growth_survey_question_3_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.professional_development_growth_survey_question_3_lower_label"), - }, - upperLabel: { - default: t("templates.professional_development_growth_survey_question_3_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { - default: t("templates.professional_development_growth_survey_question_4_headline"), - }, + lowerLabel: t("templates.professional_development_growth_survey_question_3_lower_label"), + upperLabel: t("templates.professional_development_growth_survey_question_3_upper_label"), + t, + }), + buildOpenTextQuestion({ + headline: t("templates.professional_development_growth_survey_question_4_headline"), required: false, - placeholder: { - default: t("templates.professional_development_growth_survey_question_4_placeholder"), - }, + placeholder: t("templates.professional_development_growth_survey_question_4_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, + buttonLabel: t("templates.finish"), + t, + }), ], }, - }; + t + ); }; const recognitionAndReward = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.recognition_and_reward_survey_name"), - role: "peopleManager", - industries: ["saas", "eCommerce", "other"], - channels: ["link"], - description: t("templates.recognition_and_reward_survey_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.recognition_and_reward_survey_name"), + role: "peopleManager", + industries: ["saas", "eCommerce", "other"], + channels: ["link"], + description: t("templates.recognition_and_reward_survey_description"), questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { - default: t("templates.recognition_and_reward_survey_question_1_headline"), - }, + buildRatingQuestion({ + headline: t("templates.recognition_and_reward_survey_question_1_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.recognition_and_reward_survey_question_1_lower_label"), - }, - upperLabel: { - default: t("templates.recognition_and_reward_survey_question_1_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { - default: t("templates.recognition_and_reward_survey_question_2_headline"), - }, + lowerLabel: t("templates.recognition_and_reward_survey_question_1_lower_label"), + upperLabel: t("templates.recognition_and_reward_survey_question_1_upper_label"), + t, + }), + buildRatingQuestion({ + headline: t("templates.recognition_and_reward_survey_question_2_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.recognition_and_reward_survey_question_2_lower_label"), - }, - upperLabel: { - default: t("templates.recognition_and_reward_survey_question_2_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { - default: t("templates.recognition_and_reward_survey_question_3_headline"), - }, + lowerLabel: t("templates.recognition_and_reward_survey_question_2_lower_label"), + upperLabel: t("templates.recognition_and_reward_survey_question_2_upper_label"), + t, + }), + buildRatingQuestion({ + headline: t("templates.recognition_and_reward_survey_question_3_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.recognition_and_reward_survey_question_3_lower_label"), - }, - upperLabel: { - default: t("templates.recognition_and_reward_survey_question_3_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { - default: t("templates.recognition_and_reward_survey_question_4_headline"), - }, + lowerLabel: t("templates.recognition_and_reward_survey_question_3_lower_label"), + upperLabel: t("templates.recognition_and_reward_survey_question_3_upper_label"), + t, + }), + buildOpenTextQuestion({ + headline: t("templates.recognition_and_reward_survey_question_4_headline"), required: false, - placeholder: { - default: t("templates.recognition_and_reward_survey_question_4_placeholder"), - }, + placeholder: t("templates.recognition_and_reward_survey_question_4_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, + t, + }), ], }, - }; + t + ); }; const alignmentAndEngagement = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.alignment_and_engagement_survey_name"), - role: "peopleManager", - industries: ["saas", "eCommerce", "other"], - channels: ["link"], - description: t("templates.alignment_and_engagement_survey_description"), - preset: { - ...localSurvey, - name: "Alignment and Engagement with Company Vision", + return buildSurvey( + { + name: t("templates.alignment_and_engagement_survey_name"), + role: "peopleManager", + industries: ["saas", "eCommerce", "other"], + channels: ["link"], + description: t("templates.alignment_and_engagement_survey_description"), questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { - default: t("templates.alignment_and_engagement_survey_question_1_headline"), - }, + buildRatingQuestion({ + headline: t("templates.alignment_and_engagement_survey_question_1_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.alignment_and_engagement_survey_question_1_lower_label"), - }, - upperLabel: { - default: t("templates.alignment_and_engagement_survey_question_1_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { - default: t("templates.alignment_and_engagement_survey_question_2_headline"), - }, + lowerLabel: t("templates.alignment_and_engagement_survey_question_1_lower_label"), + upperLabel: t("templates.alignment_and_engagement_survey_question_1_upper_label"), + t, + }), + buildRatingQuestion({ + headline: t("templates.alignment_and_engagement_survey_question_2_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.alignment_and_engagement_survey_question_2_lower_label"), - }, - upperLabel: { - default: t("templates.alignment_and_engagement_survey_question_2_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { - default: t("templates.alignment_and_engagement_survey_question_3_headline"), - }, + lowerLabel: t("templates.alignment_and_engagement_survey_question_2_lower_label"), + t, + }), + buildRatingQuestion({ + headline: t("templates.alignment_and_engagement_survey_question_3_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.alignment_and_engagement_survey_question_3_lower_label"), - }, - upperLabel: { - default: t("templates.alignment_and_engagement_survey_question_3_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { - default: t("templates.alignment_and_engagement_survey_question_4_headline"), - }, + lowerLabel: t("templates.alignment_and_engagement_survey_question_3_lower_label"), + upperLabel: t("templates.alignment_and_engagement_survey_question_3_upper_label"), + t, + }), + buildOpenTextQuestion({ + headline: t("templates.alignment_and_engagement_survey_question_4_headline"), required: false, - placeholder: { - default: t("templates.alignment_and_engagement_survey_question_4_placeholder"), - }, + placeholder: t("templates.alignment_and_engagement_survey_question_4_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, + buttonLabel: t("templates.finish"), + t, + }), ], }, - }; + t + ); }; const supportiveWorkCulture = (t: TFnType): TTemplate => { - const localSurvey = getDefaultSurveyPreset(t); - return { - name: t("templates.supportive_work_culture_survey_name"), - role: "peopleManager", - industries: ["saas", "eCommerce", "other"], - channels: ["link"], - description: t("templates.supportive_work_culture_survey_description"), - preset: { - ...localSurvey, + return buildSurvey( + { name: t("templates.supportive_work_culture_survey_name"), + role: "peopleManager", + industries: ["saas", "eCommerce", "other"], + channels: ["link"], + description: t("templates.supportive_work_culture_survey_description"), questions: [ - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { - default: t("templates.supportive_work_culture_survey_question_1_headline"), - }, + buildRatingQuestion({ + headline: t("templates.supportive_work_culture_survey_question_1_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.supportive_work_culture_survey_question_1_lower_label"), - }, - upperLabel: { - default: t("templates.supportive_work_culture_survey_question_1_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { - default: t("templates.supportive_work_culture_survey_question_2_headline"), - }, + lowerLabel: t("templates.supportive_work_culture_survey_question_1_lower_label"), + upperLabel: t("templates.supportive_work_culture_survey_question_1_upper_label"), + t, + }), + buildRatingQuestion({ + headline: t("templates.supportive_work_culture_survey_question_2_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.supportive_work_culture_survey_question_2_lower_label"), - }, - upperLabel: { - default: t("templates.supportive_work_culture_survey_question_2_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.Rating, - headline: { - default: t("templates.supportive_work_culture_survey_question_3_headline"), - }, + lowerLabel: t("templates.supportive_work_culture_survey_question_2_lower_label"), + upperLabel: t("templates.supportive_work_culture_survey_question_2_upper_label"), + t, + }), + buildRatingQuestion({ + headline: t("templates.supportive_work_culture_survey_question_3_headline"), required: true, scale: "number", range: 10, - lowerLabel: { - default: t("templates.supportive_work_culture_survey_question_3_lower_label"), - }, - upperLabel: { - default: t("templates.supportive_work_culture_survey_question_3_upper_label"), - }, - isColorCodingEnabled: false, - buttonLabel: { default: t("templates.next") }, - backButtonLabel: { default: t("templates.back") }, - }, - { - id: createId(), - type: TSurveyQuestionTypeEnum.OpenText, - charLimit: { - enabled: false, - }, - headline: { - default: t("templates.supportive_work_culture_survey_question_4_headline"), - }, + lowerLabel: t("templates.supportive_work_culture_survey_question_3_lower_label"), + upperLabel: t("templates.supportive_work_culture_survey_question_3_upper_label"), + t, + }), + buildOpenTextQuestion({ + headline: t("templates.supportive_work_culture_survey_question_4_headline"), required: false, - placeholder: { - default: t("templates.supportive_work_culture_survey_question_4_placeholder"), - }, + placeholder: t("templates.supportive_work_culture_survey_question_4_placeholder"), inputType: "text", - buttonLabel: { default: t("templates.finish") }, - backButtonLabel: { default: t("templates.back") }, - }, + buttonLabel: t("templates.finish"), + t, + }), ], }, - }; + t + ); }; export const templates = (t: TFnType): TTemplate[] => [ @@ -6886,6 +3543,7 @@ export const templates = (t: TFnType): TTemplate[] => [ onboardingSegmentation(t), churnSurvey(t), earnedAdvocacyScore(t), + usabilityScoreRatingSurvey(t), improveTrialConversion(t), reviewPrompt(t), interviewPrompt(t), @@ -6941,9 +3599,9 @@ export const customSurveyTemplate = (t: TFnType): TTemplate => { { id: createId(), type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: t("templates.custom_survey_question_1_headline") }, - placeholder: { default: t("templates.custom_survey_question_1_placeholder") }, - buttonLabel: { default: t("templates.next") }, + headline: createI18nString(t("templates.custom_survey_question_1_headline"), []), + placeholder: createI18nString(t("templates.custom_survey_question_1_placeholder"), []), + buttonLabel: createI18nString(t("templates.next"), []), required: true, inputType: "text", charLimit: { @@ -6966,13 +3624,9 @@ export const previewSurvey = (projectName: string, t: TFnType) => { createdBy: "cltwumfbz0000echxysz6ptvq", status: "inProgress", welcomeCard: { - html: { - default: t("templates.preview_survey_welcome_card_html"), - }, + html: createI18nString(t("templates.preview_survey_welcome_card_html"), []), enabled: false, - headline: { - default: t("templates.preview_survey_welcome_card_headline"), - }, + headline: createI18nString(t("templates.preview_survey_welcome_card_headline"), []), timeToFinish: false, showResponseCount: false, }, @@ -6980,59 +3634,43 @@ export const previewSurvey = (projectName: string, t: TFnType) => { segment: null, questions: [ { - id: "lbdxozwikh838yc6a8vbwuju", - type: "rating", - range: 5, - scale: "star", + ...buildMultipleChoiceQuestion({ + id: "rjpu42ps6dzirsn9ds6eydgt", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + choiceIds: ["x6wty2s72v7vd538aadpurqx", "fbcj4530t2n357ymjp2h28d6"], + choices: [ + t("templates.preview_survey_question_2_choice_1_label"), + t("templates.preview_survey_question_2_choice_2_label"), + ], + headline: t("templates.preview_survey_question_2_headline"), + backButtonLabel: t("templates.preview_survey_question_2_back_button_label"), + required: true, + shuffleOption: "none", + t, + }), isDraft: true, - headline: { - default: t("templates.preview_survey_question_1_headline", { projectName }), - }, - required: true, - subheader: { - default: t("templates.preview_survey_question_1_subheader"), - }, - lowerLabel: { - default: t("templates.preview_survey_question_1_lower_label"), - }, - upperLabel: { - default: t("templates.preview_survey_question_1_upper_label"), - }, }, { - id: "rjpu42ps6dzirsn9ds6eydgt", - type: "multipleChoiceSingle", - choices: [ - { - id: "x6wty2s72v7vd538aadpurqx", - label: { - default: t("templates.preview_survey_question_2_choice_1_label"), - }, - }, - { - id: "fbcj4530t2n357ymjp2h28d6", - label: { - default: t("templates.preview_survey_question_2_choice_2_label"), - }, - }, - ], + ...buildRatingQuestion({ + id: "lbdxozwikh838yc6a8vbwuju", + range: 5, + scale: "star", + headline: t("templates.preview_survey_question_1_headline", { projectName }), + required: true, + subheader: t("templates.preview_survey_question_1_subheader"), + lowerLabel: t("templates.preview_survey_question_1_lower_label"), + upperLabel: t("templates.preview_survey_question_1_upper_label"), + t, + }), isDraft: true, - headline: { - default: t("templates.preview_survey_question_2_headline"), - }, - backButtonLabel: { - default: t("templates.preview_survey_question_2_back_button_label"), - }, - required: true, - shuffleOption: "none", }, ], endings: [ { id: "cltyqp5ng000108l9dmxw6nde", type: "endScreen", - headline: { default: t("templates.preview_survey_ending_card_headline") }, - subheader: { default: t("templates.preview_survey_ending_card_description") }, + headline: createI18nString(t("templates.preview_survey_ending_card_headline"), []), + subheader: createI18nString(t("templates.preview_survey_ending_card_description"), []), }, ], hiddenFields: { @@ -7045,6 +3683,7 @@ export const previewSurvey = (projectName: string, t: TFnType) => { displayLimit: null, autoClose: null, runOnDate: null, + recaptcha: null, closeOnDate: null, delay: 0, displayPercentage: null, @@ -7059,7 +3698,6 @@ export const previewSurvey = (projectName: string, t: TFnType) => { isEncrypted: true, }, pin: null, - resultShareKey: null, languages: [], triggers: [], showLanguageSwitch: false, diff --git a/apps/web/app/middleware/bucket.ts b/apps/web/app/middleware/bucket.ts deleted file mode 100644 index 3b11f583d598..000000000000 --- a/apps/web/app/middleware/bucket.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { rateLimit } from "@/app/middleware/rate-limit"; -import { - CLIENT_SIDE_API_RATE_LIMIT, - FORGET_PASSWORD_RATE_LIMIT, - LOGIN_RATE_LIMIT, - SHARE_RATE_LIMIT, - SIGNUP_RATE_LIMIT, - SYNC_USER_IDENTIFICATION_RATE_LIMIT, - VERIFY_EMAIL_RATE_LIMIT, -} from "@formbricks/lib/constants"; - -export const loginLimiter = rateLimit({ - interval: LOGIN_RATE_LIMIT.interval, - allowedPerInterval: LOGIN_RATE_LIMIT.allowedPerInterval, -}); -export const signupLimiter = rateLimit({ - interval: SIGNUP_RATE_LIMIT.interval, - allowedPerInterval: SIGNUP_RATE_LIMIT.allowedPerInterval, -}); -export const verifyEmailLimiter = rateLimit({ - interval: VERIFY_EMAIL_RATE_LIMIT.interval, - allowedPerInterval: VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval, -}); -export const forgotPasswordLimiter = rateLimit({ - interval: FORGET_PASSWORD_RATE_LIMIT.interval, - allowedPerInterval: FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval, -}); -export const clientSideApiEndpointsLimiter = rateLimit({ - interval: CLIENT_SIDE_API_RATE_LIMIT.interval, - allowedPerInterval: CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval, -}); - -export const shareUrlLimiter = rateLimit({ - interval: SHARE_RATE_LIMIT.interval, - allowedPerInterval: SHARE_RATE_LIMIT.allowedPerInterval, -}); - -export const syncUserIdentificationLimiter = rateLimit({ - interval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval, - allowedPerInterval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval, -}); diff --git a/apps/web/app/middleware/domain-utils.test.ts b/apps/web/app/middleware/domain-utils.test.ts new file mode 100644 index 000000000000..cfcce8c00df6 --- /dev/null +++ b/apps/web/app/middleware/domain-utils.test.ts @@ -0,0 +1,85 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getPublicDomainHost, isPublicDomainConfigured, isRequestFromPublicDomain } from "./domain-utils"; + +// Mock the env module +vi.mock("@/lib/env", () => ({ + env: { + get PUBLIC_URL() { + return process.env.PUBLIC_URL || ""; + }, + }, +})); + +describe("Domain Utils", () => { + beforeEach(() => { + process.env.PUBLIC_URL = ""; + }); + + describe("getPublicDomain", () => { + test("should return null when PUBLIC_URL is empty", () => { + expect(getPublicDomainHost()).toBeNull(); + }); + + test("should return the host from a valid PUBLIC_URL", () => { + process.env.PUBLIC_URL = "https://example.com"; + expect(getPublicDomainHost()).toBe("example.com"); + }); + + test("should handle URLs with paths", () => { + process.env.PUBLIC_URL = "https://example.com/path"; + expect(getPublicDomainHost()).toBe("example.com"); + }); + + test("should handle URLs with ports", () => { + process.env.PUBLIC_URL = "https://example.com:3000"; + expect(getPublicDomainHost()).toBe("example.com:3000"); + }); + }); + + describe("isPublicDomainConfigured", () => { + test("should return false when PUBLIC_URL is empty", () => { + process.env.PUBLIC_URL = ""; + expect(isPublicDomainConfigured()).toBe(false); + }); + + test("should return true when PUBLIC_URL is valid", () => { + process.env.PUBLIC_URL = "https://example.com"; + expect(isPublicDomainConfigured()).toBe(true); + }); + }); + + describe("isRequestFromPublicDomain", () => { + test("should return false when public domain is not configured", () => { + process.env.PUBLIC_URL = ""; + const request = new NextRequest("https://example.com"); + expect(isRequestFromPublicDomain(request)).toBe(false); + }); + + test("should return false when host doesn't match public domain", () => { + process.env.PUBLIC_URL = "https://example.com"; + const request = new NextRequest("https://different-domain.com"); + expect(isRequestFromPublicDomain(request)).toBe(false); + }); + + test("should return true when host matches public domain", () => { + process.env.PUBLIC_URL = "https://example.com"; + const request = new NextRequest("https://example.com", { + headers: { + host: "example.com", + }, + }); + expect(isRequestFromPublicDomain(request)).toBe(true); + }); + + test("should handle domains with ports", () => { + process.env.PUBLIC_URL = "https://example.com:3000"; + const request = new NextRequest("https://example.com:3000", { + headers: { + host: "example.com:3000", + }, + }); + expect(isRequestFromPublicDomain(request)).toBe(true); + }); + }); +}); diff --git a/apps/web/app/middleware/domain-utils.ts b/apps/web/app/middleware/domain-utils.ts new file mode 100644 index 000000000000..dd5c284966be --- /dev/null +++ b/apps/web/app/middleware/domain-utils.ts @@ -0,0 +1,31 @@ +import { env } from "@/lib/env"; +import { NextRequest } from "next/server"; + +/** + * Get the public domain from PUBLIC_URL environment variable + */ +export const getPublicDomainHost = (): string | null => { + const PUBLIC_URL = env.PUBLIC_URL; + if (!PUBLIC_URL) return null; + + return new URL(PUBLIC_URL).host; +}; + +/** + * Check if PUBLIC_URL is configured (has a valid public domain) + */ +export const isPublicDomainConfigured = (): boolean => { + return getPublicDomainHost() !== null; +}; + +/** + * Check if the current request is coming from the public domain + */ +export const isRequestFromPublicDomain = (request: NextRequest): boolean => { + const host = request.headers.get("host"); + const publicDomainHost = getPublicDomainHost(); + + if (!publicDomainHost) return false; + + return host === publicDomainHost; +}; diff --git a/apps/web/app/middleware/endpoint-validator.test.ts b/apps/web/app/middleware/endpoint-validator.test.ts new file mode 100644 index 000000000000..916447e51f32 --- /dev/null +++ b/apps/web/app/middleware/endpoint-validator.test.ts @@ -0,0 +1,678 @@ +import { describe, expect, test } from "vitest"; +import { + AuthenticationMethod, + isAdminDomainRoute, + isAuthProtectedRoute, + isClientSideApiRoute, + isIntegrationRoute, + isManagementApiRoute, + isPublicDomainRoute, + isRouteAllowedForDomain, + isSyncWithUserIdentificationEndpoint, +} from "./endpoint-validator"; + +describe("endpoint-validator", () => { + describe("AuthenticationMethod enum", () => { + test("should have correct values", () => { + expect(AuthenticationMethod.ApiKey).toBe("apiKey"); + expect(AuthenticationMethod.Session).toBe("session"); + expect(AuthenticationMethod.Both).toBe("both"); + expect(AuthenticationMethod.None).toBe("none"); + }); + }); + describe("isClientSideApiRoute", () => { + test("should return correct object for client-side API routes with rate limiting", () => { + expect(isClientSideApiRoute("/api/v1/client/storage")).toEqual({ + isClientSideApi: true, + isRateLimited: true, + }); + expect(isClientSideApiRoute("/api/v1/client/other")).toEqual({ + isClientSideApi: true, + isRateLimited: true, + }); + expect(isClientSideApiRoute("/api/v2/client/other")).toEqual({ + isClientSideApi: true, + isRateLimited: true, + }); + expect(isClientSideApiRoute("/api/v3/client/test")).toEqual({ + isClientSideApi: true, + isRateLimited: true, + }); + }); + + test("should return correct object for OG route (client-side but not rate limited)", () => { + expect(isClientSideApiRoute("/api/v1/client/og")).toEqual({ + isClientSideApi: true, + isRateLimited: false, + }); + expect(isClientSideApiRoute("/api/v1/client/og/image")).toEqual({ + isClientSideApi: true, + isRateLimited: false, + }); + }); + + test("should return false for non-client-side API routes", () => { + expect(isClientSideApiRoute("/api/v1/management/something")).toEqual({ + isClientSideApi: false, + isRateLimited: true, + }); + expect(isClientSideApiRoute("/api/v1/js/actions")).toEqual({ + isClientSideApi: false, + isRateLimited: true, + }); + expect(isClientSideApiRoute("/api/something")).toEqual({ + isClientSideApi: false, + isRateLimited: true, + }); + expect(isClientSideApiRoute("/auth/login")).toEqual({ + isClientSideApi: false, + isRateLimited: true, + }); + expect(isClientSideApiRoute("/api/v1/integrations/webhook")).toEqual({ + isClientSideApi: false, + isRateLimited: true, + }); + }); + + test("should handle edge cases", () => { + expect(isClientSideApiRoute("/api/v1/client")).toEqual({ + isClientSideApi: false, + isRateLimited: true, + }); + expect(isClientSideApiRoute("/api/v1/client/")).toEqual({ + isClientSideApi: true, + isRateLimited: true, + }); + expect(isClientSideApiRoute("/api/client/test")).toEqual({ + isClientSideApi: false, + isRateLimited: true, + }); + }); + }); + + describe("isManagementApiRoute", () => { + test("should return correct object for management API routes with API key authentication", () => { + expect(isManagementApiRoute("/api/v1/management/something")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/v2/management/other")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/v1/management/surveys")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/v3/management/users")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + }); + + test("should return correct object for storage management routes with both authentication methods", () => { + expect(isManagementApiRoute("/api/v1/management/storage")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.Both, + }); + expect(isManagementApiRoute("/api/v1/management/storage/files")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.Both, + }); + expect(isManagementApiRoute("/api/v1/management/storage/upload")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.Both, + }); + }); + + test("should return correct object for webhooks routes with API key authentication", () => { + expect(isManagementApiRoute("/api/v1/webhooks")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/v1/webhooks/123")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/v1/webhooks/webhook-id/config")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + }); + + test("should return correct object for non-v1 storage management routes (only v1 supports both auth methods)", () => { + expect(isManagementApiRoute("/api/v2/management/storage")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/v3/management/storage/upload")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + }); + + test("should return correct object for non-v1 webhooks routes (falls back to management regex)", () => { + expect(isManagementApiRoute("/api/v2/webhooks")).toEqual({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/v3/webhooks/123")).toEqual({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/v2/management/webhooks")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + }); + + test("should return correct object for non-management API routes", () => { + expect(isManagementApiRoute("/api/v1/client/something")).toEqual({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/something")).toEqual({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/auth/login")).toEqual({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/v1/integrations/webhook")).toEqual({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + }); + + test("should handle edge cases", () => { + expect(isManagementApiRoute("/api/v1/management")).toEqual({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/v1/management/")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/management/test")).toEqual({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + }); + + test("should handle webhooks edge cases", () => { + expect(isManagementApiRoute("/api/v1/webhook")).toEqual({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/webhooks")).toEqual({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/webhooks/api/v1")).toEqual({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + }); + }); + + describe("isIntegrationRoute", () => { + test("should return true for integration API routes", () => { + expect(isIntegrationRoute("/api/v1/integrations/webhook")).toBe(true); + expect(isIntegrationRoute("/api/v2/integrations/slack")).toBe(true); + expect(isIntegrationRoute("/api/v1/integrations/zapier")).toBe(true); + expect(isIntegrationRoute("/api/v3/integrations/other")).toBe(true); + }); + + test("should return false for non-integration API routes", () => { + expect(isIntegrationRoute("/api/v1/client/something")).toBe(false); + expect(isIntegrationRoute("/api/v1/management/something")).toBe(false); + expect(isIntegrationRoute("/api/something")).toBe(false); + expect(isIntegrationRoute("/auth/login")).toBe(false); + expect(isIntegrationRoute("/integrations/webhook")).toBe(false); + }); + + test("should handle edge cases", () => { + expect(isIntegrationRoute("/api/v1/integrations")).toBe(false); + expect(isIntegrationRoute("/api/v1/integrations/")).toBe(true); + expect(isIntegrationRoute("/api/integrations/test")).toBe(false); + }); + }); + + describe("isAuthProtectedRoute", () => { + test("should return true for protected routes", () => { + expect(isAuthProtectedRoute("/environments")).toBe(true); + expect(isAuthProtectedRoute("/environments/something")).toBe(true); + expect(isAuthProtectedRoute("/environments/123/surveys")).toBe(true); + expect(isAuthProtectedRoute("/setup/organization")).toBe(true); + expect(isAuthProtectedRoute("/setup/organization/create")).toBe(true); + expect(isAuthProtectedRoute("/organizations")).toBe(true); + expect(isAuthProtectedRoute("/organizations/something")).toBe(true); + expect(isAuthProtectedRoute("/organizations/123/settings")).toBe(true); + }); + + test("should return false for non-protected routes", () => { + expect(isAuthProtectedRoute("/auth/login")).toBe(false); + expect(isAuthProtectedRoute("/auth/signup")).toBe(false); + expect(isAuthProtectedRoute("/api/something")).toBe(false); + expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false); + expect(isAuthProtectedRoute("/")).toBe(false); + expect(isAuthProtectedRoute("/s/survey123")).toBe(false); + expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false); + expect(isAuthProtectedRoute("/health")).toBe(false); + }); + + test("should handle edge cases", () => { + expect(isAuthProtectedRoute("/environment")).toBe(false); // partial match should not work + expect(isAuthProtectedRoute("/organization")).toBe(false); // partial match should not work + expect(isAuthProtectedRoute("/setup/team")).toBe(false); // not in protected routes + expect(isAuthProtectedRoute("/setup")).toBe(false); // partial match should not work + }); + }); + + describe("isSyncWithUserIdentificationEndpoint", () => { + test("should return environmentId and userId for valid sync URLs", () => { + const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456"); + expect(result1).toEqual({ + environmentId: "env123", + userId: "user456", + }); + + const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/abc-123/app/sync/xyz-789"); + expect(result2).toEqual({ + environmentId: "abc-123", + userId: "xyz-789", + }); + + const result3 = isSyncWithUserIdentificationEndpoint( + "/api/v1/client/env_123_test/app/sync/user_456_test" + ); + expect(result3).toEqual({ + environmentId: "env_123_test", + userId: "user_456_test", + }); + }); + + test("should handle optional trailing slash", () => { + // Test both with and without trailing slash + const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456"); + expect(result1).toEqual({ + environmentId: "env123", + userId: "user456", + }); + + const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456/"); + expect(result2).toEqual({ + environmentId: "env123", + userId: "user456", + }); + }); + + test("should return false for invalid sync URLs", () => { + expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync")).toBe(false); + expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/something")).toBe(false); + expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false); + expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/other/user456")).toBe(false); + expect(isSyncWithUserIdentificationEndpoint("/api/v2/client/env123/app/sync/user456")).toBe(false); // only v1 supported + }); + + test("should handle empty or malformed IDs", () => { + expect(isSyncWithUserIdentificationEndpoint("/api/v1/client//app/sync/user456")).toBe(false); + expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/")).toBe(false); + }); + }); + + describe("isPublicDomainRoute", () => { + test("should return true for health endpoint", () => { + expect(isPublicDomainRoute("/health")).toBe(true); + }); + + test("should return true for public storage routes", () => { + expect(isPublicDomainRoute("/storage/env123/public/file.jpg")).toBe(true); + expect(isPublicDomainRoute("/storage/abc-456/public/document.pdf")).toBe(true); + expect(isPublicDomainRoute("/storage/env123/public/folder/image.png")).toBe(true); + }); + + test("should return false for private storage routes", () => { + expect(isPublicDomainRoute("/storage/env123/private/file.jpg")).toBe(false); + expect(isPublicDomainRoute("/storage/env123")).toBe(false); + expect(isPublicDomainRoute("/storage")).toBe(false); + }); + + // Static assets are not handled by domain routing - middleware doesn't run on them + + test("should return true for survey routes", () => { + expect(isPublicDomainRoute("/s/survey123")).toBe(true); + expect(isPublicDomainRoute("/s/survey-id-with-dashes")).toBe(true); + expect(isPublicDomainRoute("/s/survey_id_with_underscores")).toBe(true); + expect(isPublicDomainRoute("/s/abc123def456")).toBe(true); + }); + + test("should return false for malformed survey routes", () => { + expect(isPublicDomainRoute("/s/")).toBe(false); + expect(isPublicDomainRoute("/s")).toBe(false); + expect(isPublicDomainRoute("/survey/123")).toBe(false); + }); + + test("should return true for contact survey routes", () => { + expect(isPublicDomainRoute("/c/jwt-token")).toBe(true); + expect(isPublicDomainRoute("/c/very-long-jwt-token-123")).toBe(true); + expect(isPublicDomainRoute("/c/token.with.dots")).toBe(true); + }); + + test("should return false for malformed contact survey routes", () => { + expect(isPublicDomainRoute("/c/")).toBe(false); + expect(isPublicDomainRoute("/c")).toBe(false); + expect(isPublicDomainRoute("/contact/token")).toBe(false); + }); + + test("should return true for client API routes", () => { + expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true); + expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true); + expect(isPublicDomainRoute("/api/v1/client/env/actions")).toBe(true); + expect(isPublicDomainRoute("/api/v2/client/env/responses")).toBe(true); + }); + + test("should return false for non-client API routes", () => { + expect(isPublicDomainRoute("/api/v3/client/something")).toBe(false); // only v1 and v2 supported + expect(isPublicDomainRoute("/api/client/something")).toBe(false); + expect(isPublicDomainRoute("/api/v1/management/users")).toBe(false); + expect(isPublicDomainRoute("/api/v1/integrations/webhook")).toBe(false); + }); + + test("should return false for admin-only routes", () => { + expect(isPublicDomainRoute("/")).toBe(false); + expect(isPublicDomainRoute("/environments/123")).toBe(false); + expect(isPublicDomainRoute("/auth/login")).toBe(false); + expect(isPublicDomainRoute("/setup/organization")).toBe(false); + expect(isPublicDomainRoute("/organizations/123")).toBe(false); + expect(isPublicDomainRoute("/product/settings")).toBe(false); + expect(isPublicDomainRoute("/api/v1/management/users")).toBe(false); + expect(isPublicDomainRoute("/api/v2/management/surveys")).toBe(false); + }); + }); + + describe("isAdminDomainRoute", () => { + test("should return true for health endpoint (backward compatibility)", () => { + expect(isAdminDomainRoute("/health")).toBe(true); + }); + + test("should return true for public storage routes (backward compatibility)", () => { + expect(isAdminDomainRoute("/storage/env123/public/file.jpg")).toBe(true); + expect(isAdminDomainRoute("/storage/abc-456/public/document.pdf")).toBe(true); + }); + + // Static assets are not handled by domain routing - middleware doesn't run on them + + test("should return true for admin routes", () => { + expect(isAdminDomainRoute("/")).toBe(true); + expect(isAdminDomainRoute("/environments/123")).toBe(true); + expect(isAdminDomainRoute("/environments/123/surveys")).toBe(true); + expect(isAdminDomainRoute("/auth/login")).toBe(true); + expect(isAdminDomainRoute("/auth/signup")).toBe(true); + expect(isAdminDomainRoute("/setup/organization")).toBe(true); + expect(isAdminDomainRoute("/setup/team")).toBe(true); + expect(isAdminDomainRoute("/organizations/123")).toBe(true); + expect(isAdminDomainRoute("/organizations/123/settings")).toBe(true); + expect(isAdminDomainRoute("/product/settings")).toBe(true); + expect(isAdminDomainRoute("/product/features")).toBe(true); + expect(isAdminDomainRoute("/api/v1/management/users")).toBe(true); + expect(isAdminDomainRoute("/api/v2/management/surveys")).toBe(true); + expect(isAdminDomainRoute("/api/v1/integrations/webhook")).toBe(true); + expect(isAdminDomainRoute("/pipeline/jobs")).toBe(true); + expect(isAdminDomainRoute("/cron/tasks")).toBe(true); + expect(isAdminDomainRoute("/random/route")).toBe(true); + }); + + test("should return false for public-only routes", () => { + expect(isAdminDomainRoute("/s/survey123")).toBe(false); + expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false); + expect(isAdminDomainRoute("/c/jwt-token")).toBe(false); + expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false); + expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false); + expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false); + }); + + test("should handle edge cases", () => { + expect(isAdminDomainRoute("")).toBe(true); + expect(isAdminDomainRoute("/unknown/path")).toBe(true); // unknown routes default to admin + }); + }); + + describe("isRouteAllowedForDomain", () => { + describe("public domain routing", () => { + test("should allow public routes on public domain", () => { + expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true); + expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true); + expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true); + expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true); + expect(isRouteAllowedForDomain("/health", true)).toBe(true); + expect(isRouteAllowedForDomain("/storage/env123/public/file.jpg", true)).toBe(true); + // Static assets not tested - middleware doesn't run on them + }); + + test("should block admin routes on public domain", () => { + expect(isRouteAllowedForDomain("/", true)).toBe(false); + expect(isRouteAllowedForDomain("/environments/123", true)).toBe(false); + expect(isRouteAllowedForDomain("/auth/login", true)).toBe(false); + expect(isRouteAllowedForDomain("/api/v1/management/users", true)).toBe(false); + expect(isRouteAllowedForDomain("/api/v1/integrations/webhook", true)).toBe(false); + expect(isRouteAllowedForDomain("/organizations/123", true)).toBe(false); + expect(isRouteAllowedForDomain("/setup/organization", true)).toBe(false); + }); + }); + + describe("admin domain routing", () => { + test("should allow admin routes on admin domain", () => { + expect(isRouteAllowedForDomain("/", false)).toBe(true); + expect(isRouteAllowedForDomain("/environments/123", false)).toBe(true); + expect(isRouteAllowedForDomain("/auth/login", false)).toBe(true); + expect(isRouteAllowedForDomain("/api/v1/management/users", false)).toBe(true); + expect(isRouteAllowedForDomain("/api/v1/integrations/webhook", false)).toBe(true); + expect(isRouteAllowedForDomain("/health", false)).toBe(true); + expect(isRouteAllowedForDomain("/storage/env123/public/file.jpg", false)).toBe(true); + expect(isRouteAllowedForDomain("/pipeline/jobs", false)).toBe(true); + expect(isRouteAllowedForDomain("/cron/tasks", false)).toBe(true); + expect(isRouteAllowedForDomain("/unknown/route", false)).toBe(true); + }); + + test("should block public-only routes on admin domain when PUBLIC_URL is configured", () => { + expect(isRouteAllowedForDomain("/s/survey123", false)).toBe(false); + expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false); + expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false); + expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false); + expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false); + expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false); + }); + }); + + describe("edge cases", () => { + test("should handle empty paths", () => { + expect(isRouteAllowedForDomain("", true)).toBe(false); + expect(isRouteAllowedForDomain("", false)).toBe(true); + }); + + test("should handle paths with query parameters and fragments", () => { + expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true); + expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true); + expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false); + expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true); + }); + }); + }); + + describe("comprehensive integration tests", () => { + describe("URL parsing edge cases", () => { + test("should handle paths with query parameters", () => { + expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true); + expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true); + expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false); + expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true); + }); + + test("should handle paths with fragments", () => { + expect(isPublicDomainRoute("/s/survey123#section")).toBe(true); + expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true); + expect(isPublicDomainRoute("/environments/123#overview")).toBe(false); + expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true); + }); + + test("should handle trailing slashes", () => { + expect(isPublicDomainRoute("/s/survey123/")).toBe(true); + expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true); + expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isIntegrationRoute("/api/v1/integrations/webhook/")).toBe(true); + }); + }); + + describe("nested route handling", () => { + test("should handle nested survey routes", () => { + expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true); + expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true); + expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true); + }); + + test("should handle nested client API routes", () => { + expect(isPublicDomainRoute("/api/v1/client/env123/actions")).toBe(true); + expect(isPublicDomainRoute("/api/v2/client/env456/responses")).toBe(true); + expect(isPublicDomainRoute("/api/v1/client/env789/surveys/123")).toBe(true); + expect(isClientSideApiRoute("/api/v1/client/env123/actions")).toEqual({ + isClientSideApi: true, + isRateLimited: true, + }); + }); + + test("should handle deeply nested admin routes", () => { + expect(isAuthProtectedRoute("/environments/123/surveys/456/settings")).toBe(true); + expect(isAuthProtectedRoute("/organizations/789/members/123/roles")).toBe(true); + expect(isAuthProtectedRoute("/setup/organization/team/invites")).toBe(true); + }); + }); + + describe("version handling", () => { + test("should handle different API versions correctly", () => { + // Client API - only v1 and v2 supported in public routes + expect(isPublicDomainRoute("/api/v1/client/test")).toBe(true); + expect(isPublicDomainRoute("/api/v2/client/test")).toBe(true); + expect(isPublicDomainRoute("/api/v3/client/test")).toBe(false); + + // Management API - all versions supported + expect(isManagementApiRoute("/api/v1/management/test")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/v2/management/test")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isManagementApiRoute("/api/v3/management/test")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + + // Integration API - all versions supported + expect(isIntegrationRoute("/api/v1/integrations/test")).toBe(true); + expect(isIntegrationRoute("/api/v2/integrations/test")).toBe(true); + expect(isIntegrationRoute("/api/v3/integrations/test")).toBe(true); + }); + }); + + describe("special characters in routes", () => { + test("should handle special characters in survey IDs", () => { + expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true); + expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true); + expect( + isSyncWithUserIdentificationEndpoint("/api/v1/client/env-123_test/app/sync/user-456_test") + ).toEqual({ + environmentId: "env-123_test", + userId: "user-456_test", + }); + }); + }); + + describe("security considerations", () => { + test("should properly validate malicious or injection-like URLs", () => { + // SQL injection-like attempts + expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format + expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + + // Path traversal attempts + expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern + expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true); + + // XSS-like attempts + expect(isPublicDomainRoute("/s/")).toBe(true); + expect(isClientSideApiRoute("/api/v1/client/")).toEqual({ + isClientSideApi: true, + isRateLimited: true, + }); + }); + + test("should handle URL encoding", () => { + expect(isPublicDomainRoute("/s/survey%20123")).toBe(true); + expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true); + expect(isAuthProtectedRoute("/environments%2F123")).toBe(true); + expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({ + isManagementApi: true, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + }); + }); + + describe("performance considerations", () => { + test("should handle very long URLs efficiently", () => { + const longSurveyId = "a".repeat(1000); + const longPath = `s/${longSurveyId}`; + expect(isPublicDomainRoute(`/${longPath}`)).toBe(true); + + const longEnvironmentId = "env" + "a".repeat(1000); + const longUserId = "user" + "b".repeat(1000); + expect( + isSyncWithUserIdentificationEndpoint(`/api/v1/client/${longEnvironmentId}/app/sync/${longUserId}`) + ).toEqual({ + environmentId: longEnvironmentId, + userId: longUserId, + }); + }); + + test("should handle empty and minimal inputs", () => { + expect(isPublicDomainRoute("")).toBe(false); + expect(isClientSideApiRoute("")).toEqual({ + isClientSideApi: false, + isRateLimited: true, + }); + expect(isManagementApiRoute("")).toEqual({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isIntegrationRoute("")).toBe(false); + expect(isAuthProtectedRoute("")).toBe(false); + expect(isSyncWithUserIdentificationEndpoint("")).toBe(false); + }); + }); + + describe("case sensitivity", () => { + test("should be case sensitive for route patterns", () => { + // These should not match due to case sensitivity + expect(isPublicDomainRoute("/S/survey123")).toBe(false); + expect(isPublicDomainRoute("/C/jwt-token")).toBe(false); + expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({ + isClientSideApi: false, + isRateLimited: true, + }); + expect(isManagementApiRoute("/API/V1/MANAGEMENT/test")).toEqual({ + isManagementApi: false, + authenticationMethod: AuthenticationMethod.ApiKey, + }); + expect(isIntegrationRoute("/API/V1/INTEGRATIONS/test")).toBe(false); + expect(isAuthProtectedRoute("/ENVIRONMENTS/123")).toBe(false); + }); + }); + }); +}); diff --git a/apps/web/app/middleware/endpoint-validator.ts b/apps/web/app/middleware/endpoint-validator.ts index ef079a6ba7fb..0bb16988f9d3 100644 --- a/apps/web/app/middleware/endpoint-validator.ts +++ b/apps/web/app/middleware/endpoint-validator.ts @@ -1,26 +1,38 @@ -export const isLoginRoute = (url: string) => url === "/api/auth/callback/credentials"; +import { + getAllPubliclyAccessibleRoutePatterns, + getPublicDomainRoutePatterns, + matchesAnyPattern, +} from "./route-config"; -export const isSignupRoute = (url: string) => url === "/auth/signup"; +export enum AuthenticationMethod { + ApiKey = "apiKey", + Session = "session", + Both = "both", + None = "none", +} -export const isVerifyEmailRoute = (url: string) => url === "/auth/verify-email"; +export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; isRateLimited: boolean } => { + // Open Graph image generation route is a client side API route but it should not be rate limited + if (url.includes("/api/v1/client/og")) return { isClientSideApi: true, isRateLimited: false }; -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\//; - return regex.test(url); + return { isClientSideApi: regex.test(url), isRateLimited: true }; }; -export const isManagementApiRoute = (url: string): boolean => { +export const isManagementApiRoute = ( + url: string +): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => { + if (url.includes("/api/v1/management/storage")) + return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both }; + if (url.includes("/api/v1/webhooks")) + return { isManagementApi: true, authenticationMethod: AuthenticationMethod.ApiKey }; + const regex = /^\/api\/v\d+\/management\//; - return regex.test(url); + return { isManagementApi: regex.test(url), authenticationMethod: AuthenticationMethod.ApiKey }; }; -export const isShareUrlRoute = (url: string): boolean => { - const regex = /\/share\/[A-Za-z0-9]+\/(?:summary|responses)/; +export const isIntegrationRoute = (url: string): boolean => { + const regex = /^\/api\/v\d+\/integrations\//; return regex.test(url); }; @@ -38,3 +50,39 @@ export const isSyncWithUserIdentificationEndpoint = ( const match = url.match(regex); return match ? { environmentId: match.groups!.environmentId, userId: match.groups!.userId } : false; }; + +/** + * Check if the route should be accessible on the public domain (PUBLIC_URL) + * Uses whitelist approach - only explicitly allowed routes are accessible + */ +export const isPublicDomainRoute = (url: string): boolean => { + const publicRoutePatterns = getAllPubliclyAccessibleRoutePatterns(); + return matchesAnyPattern(url, publicRoutePatterns); +}; + +/** + * Check if the route should be accessible on the admin domain (WEBAPP_URL) + * When PUBLIC_URL is configured, admin domain should only allow admin-specific routes + health + */ +export const isAdminDomainRoute = (url: string): boolean => { + const publicOnlyRoutePatterns = getPublicDomainRoutePatterns(); + const isPublicRoute = matchesAnyPattern(url, publicOnlyRoutePatterns); + + if (isPublicRoute) { + return false; + } + + // For non-public routes, allow them (includes known admin routes and unknown routes like pipeline, cron) + return true; +}; + +/** + * Determine if a request should be allowed based on domain and route + */ +export const isRouteAllowedForDomain = (url: string, isPublicDomain: boolean): boolean => { + if (isPublicDomain) { + return isPublicDomainRoute(url); + } + + return isAdminDomainRoute(url); +}; diff --git a/apps/web/app/middleware/rate-limit.ts b/apps/web/app/middleware/rate-limit.ts deleted file mode 100644 index 4c9dc467a7cc..000000000000 --- a/apps/web/app/middleware/rate-limit.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { LRUCache } from "lru-cache"; -import { ENTERPRISE_LICENSE_KEY, REDIS_HTTP_URL } from "@formbricks/lib/constants"; -import { logger } from "@formbricks/logger"; - -interface Options { - interval: number; - allowedPerInterval: number; -} - -const inMemoryRateLimiter = (options: Options) => { - const tokenCache = new LRUCache({ - max: 1000, - ttl: options.interval * 1000, // converts to expected input of milliseconds - }); - - return (token: string) => { - const currentUsage = tokenCache.get(token) ?? 0; - if (currentUsage >= options.allowedPerInterval) { - throw new Error("Rate limit exceeded"); - } - tokenCache.set(token, currentUsage + 1); - }; -}; - -const redisRateLimiter = (options: Options) => async (token: string) => { - try { - if (!REDIS_HTTP_URL) { - throw new Error("Redis HTTP URL is not set"); - } - const tokenCountResponse = await fetch(`${REDIS_HTTP_URL}/INCR/${token}`); - if (!tokenCountResponse.ok) { - logger.error({ tokenCountResponse }, "Failed to increment token count in Redis"); - return; - } - - const { INCR } = await tokenCountResponse.json(); - if (INCR === 1) { - await fetch(`${REDIS_HTTP_URL}/EXPIRE/${token}/${options.interval.toString()}`); - } else if (INCR > options.allowedPerInterval) { - throw new Error(); - } - } catch (e) { - throw new Error("Rate limit exceeded for IP: " + token); - } -}; - -export const rateLimit = (options: Options) => { - if (REDIS_HTTP_URL && ENTERPRISE_LICENSE_KEY) { - return redisRateLimiter(options); - } else { - return inMemoryRateLimiter(options); - } -}; diff --git a/apps/web/app/middleware/route-config.ts b/apps/web/app/middleware/route-config.ts new file mode 100644 index 000000000000..2891e0c3c214 --- /dev/null +++ b/apps/web/app/middleware/route-config.ts @@ -0,0 +1,49 @@ +/** + * Routes that should be accessible on the public domain (PUBLIC_URL) + * Uses whitelist approach - only these routes are allowed on public domain + */ +const PUBLIC_ROUTES = { + // Survey routes + SURVEY_ROUTES: [ + /^\/s\/[^/]+/, // /s/[surveyId] - survey pages + /^\/c\/[^/]+/, // /c/[jwt] - contact survey pages + ], + + // API routes accessible from public domain + API_ROUTES: [ + /^\/api\/v[12]\/client\//, // /api/v1/client/** and /api/v2/client/** + ], +} as const; + +const COMMON_ROUTES = { + HEALTH_ROUTES: [/^\/health$/], // /health endpoint + PUBLIC_STORAGE_ROUTES: [ + /^\/storage\/[^/]+\/public\//, // /storage/[environmentId]/public/** - public storage + ], +} as const; + +/** + * Get public only route patterns as a flat array + */ +export const getPublicDomainRoutePatterns = (): RegExp[] => { + return Object.values(PUBLIC_ROUTES).flat(); +}; + +/** + * Get all public route patterns as a flat array + */ +export const getAllPubliclyAccessibleRoutePatterns = (): RegExp[] => { + const routes = { + ...PUBLIC_ROUTES, + ...COMMON_ROUTES, + }; + + return Object.values(routes).flat(); +}; + +/** + * Check if a URL matches any of the given route patterns + */ +export const matchesAnyPattern = (url: string, patterns: RegExp[]): boolean => { + return patterns.some((pattern) => pattern.test(url)); +}; diff --git a/apps/web/app/not-found.test.tsx b/apps/web/app/not-found.test.tsx new file mode 100644 index 000000000000..ece5afabef11 --- /dev/null +++ b/apps/web/app/not-found.test.tsx @@ -0,0 +1,37 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/preact"; +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import NotFound from "./not-found"; + +describe("NotFound", () => { + afterEach(() => { + cleanup(); + }); + + test("renders 404 page with correct content", () => { + render(); + + // Check for the 404 text + const errorCode = screen.getByTestId("error-code"); + expect(errorCode).toBeInTheDocument(); + expect(errorCode).toHaveClass("text-sm", "font-semibold"); + expect(errorCode).toHaveTextContent("404"); + + // Check for the heading + const heading = screen.getByRole("heading", { name: "Page not found" }); + expect(heading).toBeInTheDocument(); + expect(heading).toHaveClass("mt-2", "text-2xl", "font-bold"); + + // Check for the error message + const errorMessage = screen.getByTestId("error-message"); + expect(errorMessage).toBeInTheDocument(); + expect(errorMessage).toHaveClass("mt-2", "text-base"); + expect(errorMessage).toHaveTextContent("Sorry, we couldn't find the page you're looking for."); + + // Check for the button + const button = screen.getByRole("button", { name: "Back to home" }); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass("mt-8"); + }); +}); diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index c3e38a0ef74b..a48fa6a5d06b 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -3,18 +3,18 @@ import Link from "next/link"; const NotFound = () => { return ( - <> -
-

404

-

Page not found

-

- Sorry, we couldn’t find the page you’re looking for. -

- - - -
- +
+

+ 404 +

+

Page not found

+

+ Sorry, we couldn't find the page you're looking for. +

+ + + +
); }; diff --git a/apps/web/app/page.test.tsx b/apps/web/app/page.test.tsx new file mode 100644 index 000000000000..796bb2f137fc --- /dev/null +++ b/apps/web/app/page.test.tsx @@ -0,0 +1,446 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { TMembership } from "@formbricks/types/memberships"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TProject } from "@formbricks/types/project"; +import { TUser } from "@formbricks/types/user"; +import Page from "./page"; + +vi.mock("@/lib/project/service", () => ({ + getUserProjectEnvironmentsByOrganizationIds: vi.fn(), +})); + +vi.mock("@/lib/instance/service", () => ({ + getIsFreshInstance: vi.fn(), +})); + +vi.mock("@/lib/membership/service", () => ({ + getMembershipByUserIdOrganizationId: vi.fn(), +})); + +vi.mock("@/lib/membership/utils", () => ({ + getAccessFlags: vi.fn(), +})); + +vi.mock("@/lib/organization/service", () => ({ + getOrganizationsByUserId: vi.fn(), +})); + +vi.mock("@/lib/user/service", () => ({ + getUser: vi.fn(), +})); + +vi.mock("@/modules/auth/lib/authOptions", () => ({ + authOptions: {}, +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("@/modules/ui/components/client-logout", () => ({ + ClientLogout: () =>
Client Logout
, +})); + +vi.mock("@/app/ClientEnvironmentRedirect", () => ({ + default: ({ environmentId, userEnvironments }: { environmentId: string; userEnvironments?: string[] }) => ( +
+ Environment ID: {environmentId} + {userEnvironments && ` | User Environments: ${userEnvironments.join(", ")}`} +
+ ), +})); + +describe("Page", () => { + beforeEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("redirects to setup/intro when no session and fresh instance", async () => { + const { getServerSession } = await import("next-auth"); + const { getIsFreshInstance } = await import("@/lib/instance/service"); + const { redirect } = await import("next/navigation"); + + vi.mocked(getServerSession).mockResolvedValue(null); + vi.mocked(getIsFreshInstance).mockResolvedValue(true); + + await Page(); + + expect(redirect).toHaveBeenCalledWith("/setup/intro"); + }); + + test("redirects to auth/login when no session and not fresh instance", async () => { + const { getServerSession } = await import("next-auth"); + const { getIsFreshInstance } = await import("@/lib/instance/service"); + const { redirect } = await import("next/navigation"); + + vi.mocked(getServerSession).mockResolvedValue(null); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + + await Page(); + + expect(redirect).toHaveBeenCalledWith("/auth/login"); + }); + + test("shows client logout when user is not found", async () => { + const { getServerSession } = await import("next-auth"); + const { getIsFreshInstance } = await import("@/lib/instance/service"); + const { getUser } = await import("@/lib/user/service"); + const { render } = await import("@testing-library/react"); + + vi.mocked(getServerSession).mockResolvedValue({ + user: { id: "test-user-id" }, + } as any); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getUser).mockResolvedValue(null); + + const result = await Page(); + const { container } = render(result); + + expect(container.querySelector('[data-testid="client-logout"]')).toBeInTheDocument(); + }); + + test("redirects to organization creation when user has no organizations", async () => { + const { getServerSession } = await import("next-auth"); + const { getIsFreshInstance } = await import("@/lib/instance/service"); + const { getUser } = await import("@/lib/user/service"); + const { getOrganizationsByUserId } = await import("@/lib/organization/service"); + const { redirect } = await import("next/navigation"); + + const mockUser: TUser = { + id: "test-user-id", + name: "Test User", + email: "test@example.com", + emailVerified: null, + imageUrl: null, + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + role: null, + objective: null, + notificationSettings: { + alert: {}, + unsubscribedOrganizationIds: [], + }, + locale: "en-US", + lastLoginAt: null, + isActive: true, + }; + + vi.mocked(getServerSession).mockResolvedValue({ + user: { id: "test-user-id" }, + } as any); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getOrganizationsByUserId).mockResolvedValue([]); + + await Page(); + + expect(redirect).toHaveBeenCalledWith("/setup/organization/create"); + }); + + test("redirects to project creation when user has organizations but no environment", async () => { + const { getServerSession } = await import("next-auth"); + const { getIsFreshInstance } = await import("@/lib/instance/service"); + const { getUser } = await import("@/lib/user/service"); + const { getOrganizationsByUserId } = await import("@/lib/organization/service"); + const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); + const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); + const { getAccessFlags } = await import("@/lib/membership/utils"); + const { redirect } = await import("next/navigation"); + + const mockUser: TUser = { + id: "test-user-id", + name: "Test User", + email: "test@example.com", + emailVerified: null, + imageUrl: null, + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + role: null, + objective: null, + notificationSettings: { + alert: {}, + unsubscribedOrganizationIds: [], + }, + locale: "en-US", + lastLoginAt: null, + isActive: true, + }; + + const mockOrganization: TOrganization = { + id: "test-org-id", + name: "Test Organization", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: null, + plan: "free", + period: "monthly", + limits: { + projects: 3, + monthly: { + responses: 1500, + miu: 2000, + }, + }, + periodStart: new Date(), + }, + isAIEnabled: false, + }; + + const mockMembership: TMembership = { + organizationId: "test-org-id", + userId: "test-user-id", + accepted: true, + role: "owner", + }; + + const mockUserProjects = [ + { + id: "test-project-id", + name: "Test Project", + environments: [], + }, + ]; + + vi.mocked(getServerSession).mockResolvedValue({ + user: { id: "test-user-id" }, + } as any); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue( + mockUserProjects as unknown as TProject[] + ); + vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getAccessFlags).mockReturnValue({ + isManager: false, + isOwner: true, + isBilling: false, + isMember: true, + }); + + await Page(); + + expect(redirect).toHaveBeenCalledWith(`/organizations/${mockOrganization.id}/projects/new/mode`); + }); + + test("redirects to landing when user has organizations but no environment and is not owner/manager", async () => { + const { getServerSession } = await import("next-auth"); + const { getIsFreshInstance } = await import("@/lib/instance/service"); + const { getUser } = await import("@/lib/user/service"); + const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); + const { getOrganizationsByUserId } = await import("@/lib/organization/service"); + const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); + const { getAccessFlags } = await import("@/lib/membership/utils"); + const { redirect } = await import("next/navigation"); + + const mockUser: TUser = { + id: "test-user-id", + name: "Test User", + email: "test@example.com", + emailVerified: null, + imageUrl: null, + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + role: null, + objective: null, + notificationSettings: { + alert: {}, + unsubscribedOrganizationIds: [], + }, + locale: "en-US", + lastLoginAt: null, + isActive: true, + }; + + const mockOrganization: TOrganization = { + id: "test-org-id", + name: "Test Organization", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: null, + plan: "free", + period: "monthly", + limits: { + projects: 3, + monthly: { + responses: 1500, + miu: 2000, + }, + }, + periodStart: new Date(), + }, + isAIEnabled: false, + }; + + const mockMembership: TMembership = { + organizationId: "test-org-id", + userId: "test-user-id", + accepted: true, + role: "member", + }; + + const mockUserProjects = [ + { + id: "test-project-id", + name: "Test Project", + environments: [], + }, + ]; + + vi.mocked(getServerSession).mockResolvedValue({ + user: { id: "test-user-id" }, + } as any); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue( + mockUserProjects as unknown as TProject[] + ); + vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getAccessFlags).mockReturnValue({ + isManager: false, + isOwner: false, + isBilling: false, + isMember: true, + }); + + await Page(); + + expect(redirect).toHaveBeenCalledWith(`/organizations/${mockOrganization.id}/landing`); + }); + + test("renders ClientEnvironmentRedirect when user has environment", async () => { + const { getServerSession } = await import("next-auth"); + const { getIsFreshInstance } = await import("@/lib/instance/service"); + const { getUser } = await import("@/lib/user/service"); + const { getOrganizationsByUserId } = await import("@/lib/organization/service"); + const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); + const { getAccessFlags } = await import("@/lib/membership/utils"); + const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service"); + const { render } = await import("@testing-library/react"); + + const mockUser: TUser = { + id: "test-user-id", + name: "Test User", + email: "test@example.com", + emailVerified: null, + imageUrl: null, + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + role: null, + objective: null, + notificationSettings: { + alert: {}, + unsubscribedOrganizationIds: [], + }, + locale: "en-US", + lastLoginAt: null, + isActive: true, + }; + + const mockOrganization: TOrganization = { + id: "test-org-id", + name: "Test Organization", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: null, + plan: "free", + period: "monthly", + limits: { + projects: 3, + monthly: { + responses: 1500, + miu: 2000, + }, + }, + periodStart: new Date(), + }, + isAIEnabled: false, + }; + + const mockMembership: TMembership = { + organizationId: "test-org-id", + userId: "test-user-id", + accepted: true, + role: "member", + }; + + const mockUserProjects = [ + { + id: "project-1", + name: "Test Project", + createdAt: new Date(), + updatedAt: new Date(), + organizationId: "test-org-id", + styling: { allowStyleOverwrite: true }, + recontactDays: 0, + inAppSurveyBranding: false, + linkSurveyBranding: false, + config: { channel: "link" as const, industry: "saas" as const }, + placement: "bottomRight" as const, + clickOutsideClose: false, + darkOverlay: false, + languages: [], + logo: null, + environments: [ + { + id: "test-env-id", + type: "production" as const, + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + appSetupCompleted: true, + }, + { + id: "test-env-dev", + type: "development" as const, + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + appSetupCompleted: true, + }, + ], + }, + ] as any; + + vi.mocked(getServerSession).mockResolvedValue({ + user: { id: "test-user-id" }, + } as any); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects); + vi.mocked(getAccessFlags).mockReturnValue({ + isManager: false, + isOwner: false, + isBilling: false, + isMember: true, + }); + + const result = await Page(); + const { container } = render(result); + + expect(container.querySelector('[data-testid="client-environment-redirect"]')).toHaveTextContent( + `User Environments: test-env-id, test-env-dev` + ); + }); +}); diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 4d094ba18f11..39950f14b7ae 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,15 +1,15 @@ import ClientEnvironmentRedirect from "@/app/ClientEnvironmentRedirect"; +import { getIsFreshInstance } from "@/lib/instance/service"; +import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { getAccessFlags } from "@/lib/membership/utils"; +import { getOrganizationsByUserId } from "@/lib/organization/service"; +import { getUserProjectEnvironmentsByOrganizationIds } from "@/lib/project/service"; +import { getUser } from "@/lib/user/service"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { ClientLogout } from "@/modules/ui/components/client-logout"; import type { Session } from "next-auth"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; -import { getFirstEnvironmentIdByUserId } from "@formbricks/lib/environment/service"; -import { getIsFreshInstance } from "@formbricks/lib/instance/service"; -import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; -import { getOrganizationsByUserId } from "@formbricks/lib/organization/service"; -import { getUser } from "@formbricks/lib/user/service"; const Page = async () => { const session: Session | null = await getServerSession(authOptions); @@ -17,9 +17,9 @@ const Page = async () => { if (!session) { if (isFreshInstance) { - redirect("/setup/intro"); + return redirect("/setup/intro"); } else { - redirect("/auth/login"); + return redirect("/auth/login"); } } @@ -34,16 +34,37 @@ const Page = async () => { return redirect("/setup/organization/create"); } - let environmentId: string | null = null; - environmentId = await getFirstEnvironmentIdByUserId(session.user.id); + const projectsByOrg = await getUserProjectEnvironmentsByOrganizationIds( + userOrganizations.map((org) => org.id), + user.id + ); + + // Flatten all environments from all projects across all organizations + const allEnvironments = projectsByOrg.flatMap((project) => project.environments); + + // Find first production environment and collect all other environment IDs in one pass + const { firstProductionEnvironmentId, otherEnvironmentIds } = allEnvironments.reduce( + (acc, env) => { + if (env.type === "production" && !acc.firstProductionEnvironmentId) { + acc.firstProductionEnvironmentId = env.id; + } else { + acc.otherEnvironmentIds.add(env.id); + } + return acc; + }, + { firstProductionEnvironmentId: null as string | null, otherEnvironmentIds: new Set() } + ); + + const userEnvironments = [...otherEnvironmentIds]; const currentUserMembership = await getMembershipByUserIdOrganizationId( session.user.id, userOrganizations[0].id ); + const { isManager, isOwner } = getAccessFlags(currentUserMembership?.role); - if (!environmentId) { + if (!firstProductionEnvironmentId) { if (isOwner || isManager) { return redirect(`/organizations/${userOrganizations[0].id}/projects/new/mode`); } else { @@ -51,7 +72,10 @@ const Page = async () => { } } - return ; + // Put the first production environment at the front of the array + const sortedUserEnvironments = [firstProductionEnvironmentId, ...userEnvironments]; + + return ; }; export default Page; diff --git a/apps/web/app/s/[surveyId]/not-found.tsx b/apps/web/app/s/[surveyId]/not-found.tsx index 845c9b416b6c..5590c8ec822c 100644 --- a/apps/web/app/s/[surveyId]/not-found.tsx +++ b/apps/web/app/s/[surveyId]/not-found.tsx @@ -1,3 +1,5 @@ import { LinkSurveyNotFound } from "@/modules/survey/link/not-found"; -export default LinkSurveyNotFound; +export default function NotFound() { + return ; +} diff --git a/apps/web/app/sentry/SentryProvider.test.tsx b/apps/web/app/sentry/SentryProvider.test.tsx index 89a44fa39621..6be78aeab020 100644 --- a/apps/web/app/sentry/SentryProvider.test.tsx +++ b/apps/web/app/sentry/SentryProvider.test.tsx @@ -1,6 +1,6 @@ import * as Sentry from "@sentry/nextjs"; import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { SentryProvider } from "./SentryProvider"; vi.mock("@sentry/nextjs", async () => { @@ -17,17 +17,18 @@ vi.mock("@sentry/nextjs", async () => { }; }); +const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0"; + describe("SentryProvider", () => { afterEach(() => { cleanup(); }); - it("calls Sentry.init when sentryDsn is provided", () => { - const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0"; + test("calls Sentry.init when sentryDsn is provided", () => { const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); render( - +
Test Content
); @@ -37,7 +38,7 @@ describe("SentryProvider", () => { expect(initSpy).toHaveBeenCalledWith( expect.objectContaining({ dsn: sentryDsn, - tracesSampleRate: 1, + tracesSampleRate: 0, debug: false, replaysOnErrorSampleRate: 1.0, replaysSessionSampleRate: 0.1, @@ -47,7 +48,25 @@ describe("SentryProvider", () => { ); }); - it("does not call Sentry.init when sentryDsn is not provided", () => { + test("calls Sentry.init with sentryRelease when provided", () => { + const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); + const testRelease = "v1.2.3"; + + render( + +
Test Content
+
+ ); + + expect(initSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: sentryDsn, + release: testRelease, + }) + ); + }); + + test("does not call Sentry.init when sentryDsn is not provided", () => { const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); render( @@ -59,22 +78,52 @@ describe("SentryProvider", () => { expect(initSpy).not.toHaveBeenCalled(); }); - it("renders children", () => { - const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0"; + test("does not call Sentry.init when isEnabled is not provided", () => { + const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); + render(
Test Content
); + + expect(initSpy).not.toHaveBeenCalled(); + }); + + test("renders children", () => { + render( + +
Test Content
+
+ ); expect(screen.getByTestId("child")).toHaveTextContent("Test Content"); }); - it("processes beforeSend correctly", () => { - const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0"; + test("does not reinitialize Sentry when props change after initial render", () => { + const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); + + const { rerender } = render( + +
Test Content
+
+ ); + + expect(initSpy).toHaveBeenCalledTimes(1); + + rerender( + +
Test Content
+
+ ); + + expect(initSpy).toHaveBeenCalledTimes(1); + }); + + test("processes beforeSend correctly", () => { const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); render( - +
Test Content
); @@ -98,4 +147,36 @@ describe("SentryProvider", () => { const hintWithoutError = { originalException: undefined }; expect(beforeSend(dummyEvent, hintWithoutError)).toEqual(dummyEvent); }); + + test("processes beforeSend correctly when hint.originalException is not an Error object", () => { + const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); + + render( + +
Test Content
+
+ ); + + const config = initSpy.mock.calls[0][0]; + expect(config).toHaveProperty("beforeSend"); + const beforeSend = config.beforeSend; + + if (!beforeSend) { + throw new Error("beforeSend is not defined"); + } + + const dummyEvent = { some: "event" } as unknown as Sentry.ErrorEvent; + + const hintWithString = { originalException: "string exception" }; + expect(() => beforeSend(dummyEvent, hintWithString)).not.toThrow(); + expect(beforeSend(dummyEvent, hintWithString)).toEqual(dummyEvent); + + const hintWithNumber = { originalException: 123 }; + expect(() => beforeSend(dummyEvent, hintWithNumber)).not.toThrow(); + expect(beforeSend(dummyEvent, hintWithNumber)).toEqual(dummyEvent); + + const hintWithNull = { originalException: null }; + expect(() => beforeSend(dummyEvent, hintWithNull)).not.toThrow(); + expect(beforeSend(dummyEvent, hintWithNull)).toEqual(dummyEvent); + }); }); diff --git a/apps/web/app/sentry/SentryProvider.tsx b/apps/web/app/sentry/SentryProvider.tsx index cdaf6bc1ac75..da65b2cbf002 100644 --- a/apps/web/app/sentry/SentryProvider.tsx +++ b/apps/web/app/sentry/SentryProvider.tsx @@ -6,16 +6,27 @@ import { useEffect } from "react"; interface SentryProviderProps { children: React.ReactNode; sentryDsn?: string; + sentryRelease?: string; + sentryEnvironment?: string; + isEnabled?: boolean; } -export const SentryProvider = ({ children, sentryDsn }: SentryProviderProps) => { +export const SentryProvider = ({ + children, + sentryDsn, + sentryRelease, + sentryEnvironment, + isEnabled, +}: SentryProviderProps) => { useEffect(() => { - if (sentryDsn) { + if (sentryDsn && isEnabled) { Sentry.init({ dsn: sentryDsn, + release: sentryRelease, + environment: sentryEnvironment, - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1, + // No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737 + tracesSampleRate: 0, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, diff --git a/apps/web/app/setup/organization/create/actions.ts b/apps/web/app/setup/organization/create/actions.ts index 11261b081a17..fde25c06c9cd 100644 --- a/apps/web/app/setup/organization/create/actions.ts +++ b/apps/web/app/setup/organization/create/actions.ts @@ -1,35 +1,44 @@ "use server"; +import { gethasNoOrganizations } from "@/lib/instance/service"; +import { createMembership } from "@/lib/membership/service"; +import { createOrganization } from "@/lib/organization/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { z } from "zod"; -import { gethasNoOrganizations } from "@formbricks/lib/instance/service"; -import { createMembership } from "@formbricks/lib/membership/service"; -import { createOrganization } from "@formbricks/lib/organization/service"; import { OperationNotAllowedError } from "@formbricks/types/errors"; const ZCreateOrganizationAction = z.object({ organizationName: z.string(), }); -export const createOrganizationAction = authenticatedActionClient - .schema(ZCreateOrganizationAction) - .action(async ({ ctx, parsedInput }) => { - const hasNoOrganizations = await gethasNoOrganizations(); - const isMultiOrgEnabled = await getIsMultiOrgEnabled(); +export const createOrganizationAction = authenticatedActionClient.schema(ZCreateOrganizationAction).action( + withAuditLogging( + "created", + "organization", + async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => { + const hasNoOrganizations = await gethasNoOrganizations(); + const isMultiOrgEnabled = await getIsMultiOrgEnabled(); - if (!hasNoOrganizations && !isMultiOrgEnabled) { - throw new OperationNotAllowedError("This action can only be performed on a fresh instance."); - } + if (!hasNoOrganizations && !isMultiOrgEnabled) { + throw new OperationNotAllowedError("This action can only be performed on a fresh instance."); + } + + const newOrganization = await createOrganization({ + name: parsedInput.organizationName, + }); - const newOrganization = await createOrganization({ - name: parsedInput.organizationName, - }); + await createMembership(newOrganization.id, ctx.user.id, { + role: "owner", + accepted: true, + }); - await createMembership(newOrganization.id, ctx.user.id, { - role: "owner", - accepted: true, - }); + ctx.auditLoggingCtx.organizationId = newOrganization.id; + ctx.auditLoggingCtx.newObject = newOrganization; - return newOrganization; - }); + return newOrganization; + } + ) +); diff --git a/apps/web/app/share/[sharingKey]/(analysis)/responses/page.tsx b/apps/web/app/share/[sharingKey]/(analysis)/responses/page.tsx deleted file mode 100644 index 0e95005ffda0..000000000000 --- a/apps/web/app/share/[sharingKey]/(analysis)/responses/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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 { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; -import { PageHeader } from "@/modules/ui/components/page-header"; -import { getTranslate } from "@/tolgee/server"; -import { notFound } from "next/navigation"; -import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@formbricks/lib/constants"; -import { getEnvironment } from "@formbricks/lib/environment/service"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; -import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; -import { getSurvey, getSurveyIdByResultShareKey } from "@formbricks/lib/survey/service"; -import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; -import { findMatchingLocale } from "@formbricks/lib/utils/locale"; - -type Params = Promise<{ - sharingKey: string; -}>; - -interface ResponsesPageProps { - params: Params; -} - -const Page = async (props: ResponsesPageProps) => { - const t = await getTranslate(); - const params = await props.params; - const surveyId = await getSurveyIdByResultShareKey(params.sharingKey); - - if (!surveyId) { - return notFound(); - } - const survey = await getSurvey(surveyId); - if (!survey) { - throw new Error(t("common.survey_not_found")); - } - const environmentId = survey.environmentId; - const [environment, project, tags] = await Promise.all([ - getEnvironment(environmentId), - getProjectByEnvironmentId(environmentId), - getTagsByEnvironmentId(environmentId), - ]); - - if (!environment) { - throw new Error(t("common.environment_not_found")); - } - if (!project) { - throw new Error(t("common.project_not_found")); - } - - const totalResponseCount = await getResponseCountBySurveyId(surveyId); - const locale = await findMatchingLocale(); - - return ( -
- - - - - - -
- ); -}; - -export default Page; diff --git a/apps/web/app/share/[sharingKey]/(analysis)/summary/page.tsx b/apps/web/app/share/[sharingKey]/(analysis)/summary/page.tsx deleted file mode 100644 index fbd78487d4f0..000000000000 --- a/apps/web/app/share/[sharingKey]/(analysis)/summary/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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 { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; -import { PageHeader } from "@/modules/ui/components/page-header"; -import { getTranslate } from "@/tolgee/server"; -import { notFound } from "next/navigation"; -import { DEFAULT_LOCALE, WEBAPP_URL } from "@formbricks/lib/constants"; -import { getEnvironment } from "@formbricks/lib/environment/service"; -import { getProjectByEnvironmentId } from "@formbricks/lib/project/service"; -import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; -import { getSurvey, getSurveyIdByResultShareKey } from "@formbricks/lib/survey/service"; - -type Params = Promise<{ - sharingKey: string; -}>; - -interface SummaryPageProps { - params: Params; -} - -const Page = async (props: SummaryPageProps) => { - const t = await getTranslate(); - const params = await props.params; - const surveyId = await getSurveyIdByResultShareKey(params.sharingKey); - - if (!surveyId) { - return notFound(); - } - - const survey = await getSurvey(surveyId); - if (!survey) { - throw new Error(t("common.survey_not_found")); - } - - const environmentId = survey.environmentId; - - const [environment, project] = await Promise.all([ - getEnvironment(environmentId), - getProjectByEnvironmentId(environmentId), - ]); - - if (!environment) { - throw new Error(t("common.environment_not_found")); - } - - if (!project) { - throw new Error(t("common.project_not_found")); - } - - const totalResponseCount = await getResponseCountBySurveyId(surveyId); - - return ( -
- - - - - - -
- ); -}; - -export default Page; diff --git a/apps/web/app/share/[sharingKey]/actions.ts b/apps/web/app/share/[sharingKey]/actions.ts deleted file mode 100644 index d1fc75ed5b0a..000000000000 --- a/apps/web/app/share/[sharingKey]/actions.ts +++ /dev/null @@ -1,84 +0,0 @@ -"use server"; - -import { actionClient } from "@/lib/utils/action-client"; -import { z } from "zod"; -import { - getResponseCountBySurveyId, - getResponseFilteringValues, - getResponses, -} from "@formbricks/lib/response/service"; -import { getSurveyIdByResultShareKey } from "@formbricks/lib/survey/service"; -import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service"; -import { ZId } from "@formbricks/types/common"; -import { AuthorizationError } from "@formbricks/types/errors"; -import { ZResponseFilterCriteria } from "@formbricks/types/responses"; -import { getSurveySummary } from "../../(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary"; - -const ZGetResponsesBySurveySharingKeyAction = z.object({ - sharingKey: z.string(), - limit: z.number().optional(), - offset: z.number().optional(), - filterCriteria: ZResponseFilterCriteria.optional(), -}); - -export const getResponsesBySurveySharingKeyAction = actionClient - .schema(ZGetResponsesBySurveySharingKeyAction) - .action(async ({ parsedInput }) => { - const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey); - if (!surveyId) throw new AuthorizationError("Not authorized"); - - const responses = await getResponses( - surveyId, - parsedInput.limit, - parsedInput.offset, - parsedInput.filterCriteria - ); - return responses; - }); - -const ZGetSummaryBySurveySharingKeyAction = z.object({ - sharingKey: z.string(), - filterCriteria: ZResponseFilterCriteria.optional(), -}); - -export const getSummaryBySurveySharingKeyAction = actionClient - .schema(ZGetSummaryBySurveySharingKeyAction) - .action(async ({ parsedInput }) => { - const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey); - if (!surveyId) throw new AuthorizationError("Not authorized"); - - return await getSurveySummary(surveyId, parsedInput.filterCriteria); - }); - -const ZGetResponseCountBySurveySharingKeyAction = z.object({ - sharingKey: z.string(), - filterCriteria: ZResponseFilterCriteria.optional(), -}); - -export const getResponseCountBySurveySharingKeyAction = actionClient - .schema(ZGetResponseCountBySurveySharingKeyAction) - .action(async ({ parsedInput }) => { - const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey); - if (!surveyId) throw new AuthorizationError("Not authorized"); - - return await getResponseCountBySurveyId(surveyId, parsedInput.filterCriteria); - }); - -const ZGetSurveyFilterDataBySurveySharingKeyAction = z.object({ - sharingKey: z.string(), - environmentId: ZId, -}); - -export const getSurveyFilterDataBySurveySharingKeyAction = actionClient - .schema(ZGetSurveyFilterDataBySurveySharingKeyAction) - .action(async ({ parsedInput }) => { - const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey); - if (!surveyId) throw new AuthorizationError("Not authorized"); - - const [tags, { contactAttributes: attributes, meta, hiddenFields }] = await Promise.all([ - getTagsByEnvironmentId(parsedInput.environmentId), - getResponseFilteringValues(surveyId), - ]); - - return { environmentTags: tags, attributes, meta, hiddenFields }; - }); diff --git a/apps/web/app/share/[sharingKey]/layout.tsx b/apps/web/app/share/[sharingKey]/layout.tsx deleted file mode 100644 index 28a2ff7cbf1a..000000000000 --- a/apps/web/app/share/[sharingKey]/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; -import { Metadata } from "next"; - -export const metadata: Metadata = { - robots: { index: false, follow: false }, -}; - -const EnvironmentLayout = ({ children }) => { - return ( -
- {children} -
- ); -}; - -export default EnvironmentLayout; diff --git a/apps/web/app/share/[sharingKey]/not-found.tsx b/apps/web/app/share/[sharingKey]/not-found.tsx deleted file mode 100644 index 5e9e674b387a..000000000000 --- a/apps/web/app/share/[sharingKey]/not-found.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Button } from "@/modules/ui/components/button"; -import { getTranslate } from "@/tolgee/server"; -import Link from "next/link"; - -const NotFound = async () => { - const t = await getTranslate(); - return ( - <> -
-

404

-

{t("share.page_not_found")}

-

- {t("share.page_not_found_description")} -

- - - -
- - ); -}; - -export default NotFound; diff --git a/apps/web/app/share/[sharingKey]/page.tsx b/apps/web/app/share/[sharingKey]/page.tsx deleted file mode 100644 index f784a6f7d254..000000000000 --- a/apps/web/app/share/[sharingKey]/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { redirect } from "next/navigation"; - -type Params = Promise<{ - sharingKey: string; -}>; - -const Page = async (props: { params: Params }) => { - const params = await props.params; - return redirect(`/share/${params.sharingKey}/summary`); -}; - -export default Page; diff --git a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/delete-file.ts b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/delete-file.ts index 2e837d923341..f293d2702743 100644 --- a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/delete-file.ts +++ b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/delete-file.ts @@ -1,6 +1,5 @@ import { responses } from "@/app/lib/api/response"; -import { storageCache } from "@formbricks/lib/storage/cache"; -import { deleteFile } from "@formbricks/lib/storage/service"; +import { deleteFile } from "@/lib/storage/service"; import { type TAccessType } from "@formbricks/types/storage"; export const handleDeleteFile = async (environmentId: string, accessType: TAccessType, fileName: string) => { @@ -8,8 +7,6 @@ export const handleDeleteFile = async (environmentId: string, accessType: TAcces const { message, success, code } = await deleteFile(environmentId, accessType, fileName); if (success) { - // revalidate cache - storageCache.revalidate({ fileKey: `${environmentId}/${accessType}/${fileName}` }); return responses.successResponse(message); } diff --git a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/get-file.ts b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/get-file.ts index cfdebe5bbbff..524cca58105d 100644 --- a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/get-file.ts +++ b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/lib/get-file.ts @@ -1,8 +1,8 @@ import { responses } from "@/app/lib/api/response"; +import { UPLOADS_DIR, isS3Configured } from "@/lib/constants"; +import { getLocalFile, getS3File } from "@/lib/storage/service"; import { notFound } from "next/navigation"; import path from "node:path"; -import { UPLOADS_DIR, isS3Configured } from "@formbricks/lib/constants"; -import { getLocalFile, getS3File } from "@formbricks/lib/storage/service"; export const getFile = async ( environmentId: string, diff --git a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts index 5a3f70ef781a..2ef1974252ce 100644 --- a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts +++ b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts @@ -2,10 +2,14 @@ import { authenticateRequest } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { handleDeleteFile } from "@/app/storage/[environmentId]/[accessType]/[fileName]/lib/delete-file"; +import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; +import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper"; import { authOptions } from "@/modules/auth/lib/authOptions"; +import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler"; +import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { getServerSession } from "next-auth"; import { type NextRequest } from "next/server"; -import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; +import { logger } from "@formbricks/logger"; import { ZStorageRetrievalParams } from "@formbricks/types/storage"; import { getFile } from "./lib/get-file"; @@ -57,46 +61,161 @@ export const GET = async ( }; export const DELETE = async ( - _: NextRequest, - props: { params: Promise<{ fileName: string }> } + request: NextRequest, + props: { params: Promise<{ environmentId: string; accessType: string; fileName: string }> } ): Promise => { const params = await props.params; + + const getOrgId = async (environmentId: string): Promise => { + try { + return await getOrganizationIdFromEnvironmentId(environmentId); + } catch (error) { + logger.error("Failed to get organization ID for environment", { error }); + return UNKNOWN_DATA; + } + }; + + const logFileDeletion = async ({ + accessType, + userId, + status = "failure", + failureReason, + oldObject, + }: { + accessType?: string; + userId?: string; + status?: TAuditStatus; + failureReason?: string; + oldObject?: Record; + }) => { + try { + const organizationId = await getOrgId(environmentId); + + await queueAuditEvent({ + action: "deleted", + targetType: "file", + userId: userId || UNKNOWN_DATA, // NOSONAR // We want to check for empty user IDs too + userType: "user", + targetId: `${environmentId}:${accessType}`, // Generic target identifier + organizationId, + status, + newObject: { + environmentId, + accessType, + ...(failureReason && { failureReason }), + }, + oldObject, + apiUrl: request.url, + }); + } catch (auditError) { + logger.error("Failed to log file deletion audit event:", auditError); + } + }; + + // Validation if (!params.fileName) { + await logFileDeletion({ + failureReason: "fileName parameter missing", + }); return responses.badRequestResponse("Fields are missing or incorrectly formatted", { fileName: "fileName is required", }); } - const [environmentId, accessType, file] = params.fileName.split("/"); + const { environmentId, accessType, fileName } = params; + + // Security check: If fileName contains the same properties from the route, ensure they match + // This is to prevent a user from deleting a file from a different environment + const [fileEnvironmentId, fileAccessType, file] = fileName.split("/"); + if (fileEnvironmentId !== environmentId) { + await logFileDeletion({ + failureReason: "Environment ID mismatch between route and fileName", + accessType, + }); + return responses.badRequestResponse("Environment ID mismatch", { + message: "The environment ID in the fileName does not match the route environment ID", + }); + } + + if (fileAccessType !== accessType) { + await logFileDeletion({ + failureReason: "Access type mismatch between route and fileName", + accessType, + }); + return responses.badRequestResponse("Access type mismatch", { + message: "The access type in the fileName does not match the route access type", + }); + } const paramValidation = ZStorageRetrievalParams.safeParse({ fileName: file, environmentId, accessType }); if (!paramValidation.success) { + await logFileDeletion({ + failureReason: "Parameter validation failed", + accessType, + }); return responses.badRequestResponse( "Fields are missing or incorrectly formatted", transformErrorToDetails(paramValidation.error), true ); } - // check if user is authenticated - const session = await getServerSession(authOptions); + const { + environmentId: validEnvId, + accessType: validAccessType, + fileName: validFileName, + } = paramValidation.data; + // Authentication + const session = await getServerSession(authOptions); if (!session?.user) { + await logFileDeletion({ + failureReason: "User not authenticated", + accessType: validAccessType, + }); return responses.notAuthenticatedResponse(); } - // check if the user has access to the environment - - const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId); - + // Authorization + const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, validEnvId); if (!isUserAuthorized) { + await logFileDeletion({ + failureReason: "User not authorized to access environment", + accessType: validAccessType, + userId: session.user.id, + }); return responses.unauthorizedResponse(); } - return await handleDeleteFile( - paramValidation.data.environmentId, - paramValidation.data.accessType, - paramValidation.data.fileName - ); + try { + const deleteResult = await handleDeleteFile(validEnvId, validAccessType, validFileName); + const isSuccess = deleteResult.status === 200; + let failureReason = "File deletion failed"; + + if (!isSuccess) { + try { + const responseBody = await deleteResult.json(); + failureReason = responseBody.message || failureReason; // NOSONAR // We want to check for empty messages too + } catch (error) { + logger.error("Failed to parse file delete error response body", { error }); + } + } + + await logFileDeletion({ + status: isSuccess ? "success" : "failure", + failureReason: isSuccess ? undefined : failureReason, + accessType: validAccessType, + userId: session.user.id, + }); + + return deleteResult; + } catch (error) { + await logFileDeletion({ + failureReason: error instanceof Error ? error.message : "Unexpected error during file deletion", + accessType: validAccessType, + userId: session.user.id, + }); + throw error; + } }; diff --git a/apps/web/cache-handler.js b/apps/web/cache-handler.js new file mode 100644 index 000000000000..c8e6a327f29f --- /dev/null +++ b/apps/web/cache-handler.js @@ -0,0 +1,99 @@ +// This cache handler follows the @fortedigital/nextjs-cache-handler example +// Read more at: https://github.com/fortedigital/nextjs-cache-handler + +// @neshca/cache-handler dependencies +const { CacheHandler } = require("@neshca/cache-handler"); +const createLruHandler = require("@neshca/cache-handler/local-lru").default; + +// Next/Redis dependencies +const { createClient } = require("redis"); +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 +CacheHandler.onCreation(() => { + // Important - It's recommended to use global scope to ensure only one Redis connection is made + // This ensures only one instance get created + if (global.cacheHandlerConfig) { + return global.cacheHandlerConfig; + } + + // Important - It's recommended to use global scope to ensure only one Redis connection is made + // This ensures new instances are not created in a race condition + if (global.cacheHandlerConfigPromise) { + return global.cacheHandlerConfigPromise; + } + + // If REDIS_URL is not set, we will use LRU cache only + if (!process.env.REDIS_URL) { + const lruCache = createLruHandler(); + return { handlers: [lruCache] }; + } + + // Main promise initializing the handler + global.cacheHandlerConfigPromise = (async () => { + /** @type {import("redis").RedisClientType | null} */ + let redisClient = null; + // eslint-disable-next-line turbo/no-undeclared-env-vars -- Next.js will inject this variable + if (PHASE_PRODUCTION_BUILD !== process.env.NEXT_PHASE) { + const settings = { + url: process.env.REDIS_URL, // Make sure you configure this variable + pingInterval: 10000, + }; + + try { + redisClient = createClient(settings); + redisClient.on("error", (e) => { + console.error("Redis error", e); + global.cacheHandlerConfig = null; + global.cacheHandlerConfigPromise = null; + }); + } catch (error) { + console.error("Failed to create Redis client:", error); + } + } + + if (redisClient) { + try { + console.info("Connecting Redis client..."); + await redisClient.connect(); + console.info("Redis client connected."); + } catch (error) { + console.error("Failed to connect Redis client:", error); + await redisClient + .disconnect() + .catch(() => console.error("Failed to quit the Redis client after failing to connect.")); + } + } + const lruCache = createLruHandler(); + + if (!redisClient?.isReady) { + console.error("Failed to initialize caching layer."); + global.cacheHandlerConfigPromise = null; + global.cacheHandlerConfig = { handlers: [lruCache] }; + return global.cacheHandlerConfig; + } + + const redisCacheHandler = createRedisHandler({ + client: redisClient, + keyPrefix: "nextjs:", + }); + + global.cacheHandlerConfigPromise = null; + + global.cacheHandlerConfig = { + handlers: [createBufferStringHandler(redisCacheHandler)], + }; + + return global.cacheHandlerConfig; + })(); + + return global.cacheHandlerConfigPromise; +}); + +module.exports = new Next15CacheHandler(); diff --git a/apps/web/cache-handler.mjs b/apps/web/cache-handler.mjs deleted file mode 100644 index 1065fa3b832b..000000000000 --- a/apps/web/cache-handler.mjs +++ /dev/null @@ -1,79 +0,0 @@ -import { CacheHandler } from "@neshca/cache-handler"; -import createLruHandler from "@neshca/cache-handler/local-lru"; -import createRedisHandler from "@neshca/cache-handler/redis-strings"; -import { createClient } from "redis"; - -// Function to create a timeout promise -const createTimeoutPromise = (ms, rejectReason) => { - return new Promise((_, reject) => setTimeout(() => reject(new Error(rejectReason)), ms)); -}; - -CacheHandler.onCreation(async () => { - let client; - - if (process.env.REDIS_URL) { - try { - // Create a Redis client. - client = createClient({ - url: process.env.REDIS_URL, - }); - - // Redis won't work without error handling. - client.on("error", () => {}); - } catch (error) { - console.warn("Failed to create Redis client:", error); - } - - if (client) { - try { - // Wait for the client to connect with a timeout of 5000ms. - const connectPromise = client.connect(); - const timeoutPromise = createTimeoutPromise(5000, "Redis connection timed out"); // 5000ms timeout - await Promise.race([connectPromise, timeoutPromise]); - } catch (error) { - console.warn("Failed to connect Redis client:", error); - - console.warn("Disconnecting the Redis client..."); - // Try to disconnect the client to stop it from reconnecting. - client - .disconnect() - .then(() => { - console.info("Redis client disconnected."); - }) - .catch(() => { - console.warn("Failed to quit the Redis client after failing to connect."); - }); - } - } - } - - /** @type {import("@neshca/cache-handler").Handler | null} */ - let handler; - - if (client?.isReady) { - const redisHandlerOptions = { - client, - keyPrefix: "fb:", - timeoutMs: 1000, - }; - - // Create the `redis-stack` Handler if the client is available and connected. - handler = await createRedisHandler(redisHandlerOptions); - } else { - // Fallback to LRU handler if Redis client is not available. - // The application will still work, but the cache will be in memory only and not shared. - handler = createLruHandler(); - console.log("Using LRU handler for caching."); - } - - return { - handlers: [handler], - ttl: { - // We set the stale and the expire age to the same value, because the stale age is determined by the unstable_cache revalidation. - defaultStaleAge: (process.env.REDIS_URL && Number(process.env.REDIS_DEFAULT_TTL)) || 86400, - estimateExpireAge: (staleAge) => staleAge, - }, - }; -}); - -export default CacheHandler; diff --git a/apps/web/instrumentation-node.ts b/apps/web/instrumentation-node.ts index 55eeac233fb1..3e43e9f5c151 100644 --- a/apps/web/instrumentation-node.ts +++ b/apps/web/instrumentation-node.ts @@ -1,18 +1,18 @@ // instrumentation-node.ts +import { env } from "@/lib/env"; import { PrometheusExporter } from "@opentelemetry/exporter-prometheus"; import { HostMetrics } from "@opentelemetry/host-metrics"; import { registerInstrumentations } from "@opentelemetry/instrumentation"; import { HttpInstrumentation } from "@opentelemetry/instrumentation-http"; import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node"; import { - Resource, - detectResourcesSync, + detectResources, envDetector, hostDetector, processDetector, + resourceFromAttributes, } from "@opentelemetry/resources"; import { MeterProvider } from "@opentelemetry/sdk-metrics"; -import { env } from "@formbricks/lib/env"; import { logger } from "@formbricks/logger"; const exporter = new PrometheusExporter({ @@ -21,11 +21,11 @@ const exporter = new PrometheusExporter({ host: "0.0.0.0", // Listen on all network interfaces }); -const detectedResources = detectResourcesSync({ +const detectedResources = detectResources({ detectors: [envDetector, processDetector, hostDetector], }); -const customResources = new Resource({}); +const customResources = resourceFromAttributes({}); const resources = detectedResources.merge(customResources); diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts index e86284efd380..c470953ee3ad 100644 --- a/apps/web/instrumentation.ts +++ b/apps/web/instrumentation.ts @@ -1,14 +1,17 @@ -import { PROMETHEUS_ENABLED, SENTRY_DSN } from "@formbricks/lib/constants"; +import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants"; +import * as Sentry from "@sentry/nextjs"; + +export const onRequestError = Sentry.captureRequestError; // instrumentation.ts export const register = async () => { if (process.env.NEXT_RUNTIME === "nodejs" && PROMETHEUS_ENABLED) { await import("./instrumentation-node"); } - if (process.env.NEXT_RUNTIME === "nodejs" && SENTRY_DSN) { + if (process.env.NEXT_RUNTIME === "nodejs" && IS_PRODUCTION && SENTRY_DSN) { await import("./sentry.server.config"); } - if (process.env.NEXT_RUNTIME === "edge" && SENTRY_DSN) { + if (process.env.NEXT_RUNTIME === "edge" && IS_PRODUCTION && SENTRY_DSN) { await import("./sentry.edge.config"); } }; diff --git a/packages/lib/__mocks__/database.ts b/apps/web/lib/__mocks__/database.ts similarity index 100% rename from packages/lib/__mocks__/database.ts rename to apps/web/lib/__mocks__/database.ts diff --git a/packages/lib/account/service.ts b/apps/web/lib/account/service.ts similarity index 100% rename from packages/lib/account/service.ts rename to apps/web/lib/account/service.ts diff --git a/packages/lib/account/utils.ts b/apps/web/lib/account/utils.ts similarity index 100% rename from packages/lib/account/utils.ts rename to apps/web/lib/account/utils.ts diff --git a/apps/web/lib/actionClass/service.test.ts b/apps/web/lib/actionClass/service.test.ts new file mode 100644 index 000000000000..4df83848c64d --- /dev/null +++ b/apps/web/lib/actionClass/service.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { TActionClass } from "@formbricks/types/action-classes"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { + deleteActionClass, + getActionClass, + getActionClassByEnvironmentIdAndName, + getActionClasses, +} from "./service"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + actionClass: { + findMany: vi.fn(), + findFirst: vi.fn(), + findUnique: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +vi.mock("../utils/validate", () => ({ + validateInputs: vi.fn(), +})); + +describe("ActionClass Service", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getActionClasses", () => { + test("should return action classes for environment", async () => { + const mockActionClasses = [ + { + id: "id1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Action 1", + description: "desc", + type: "code", + key: "key1", + noCodeConfig: {}, + environmentId: "env1", + }, + ]; + vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses); + + const result = await getActionClasses("env1"); + expect(result).toEqual(mockActionClasses); + expect(prisma.actionClass.findMany).toHaveBeenCalledWith({ + where: { environmentId: "env1" }, + select: expect.any(Object), + take: undefined, + skip: undefined, + orderBy: { createdAt: "asc" }, + }); + }); + + test("should throw DatabaseError when prisma throws", async () => { + vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("fail")); + await expect(getActionClasses("env1")).rejects.toThrow(DatabaseError); + }); + }); + + describe("getActionClassByEnvironmentIdAndName", () => { + test("should return action class when found", async () => { + const mockActionClass = { + id: "id2", + createdAt: new Date(), + updatedAt: new Date(), + name: "Action 2", + description: "desc2", + type: "noCode", + key: null, + noCodeConfig: {}, + environmentId: "env2", + }; + if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn(); + vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(mockActionClass); + + const result = await getActionClassByEnvironmentIdAndName("env2", "Action 2"); + expect(result).toEqual(mockActionClass); + expect(prisma.actionClass.findFirst).toHaveBeenCalledWith({ + where: { name: "Action 2", environmentId: "env2" }, + select: expect.any(Object), + }); + }); + + test("should return null when not found", async () => { + if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn(); + vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(null); + const result = await getActionClassByEnvironmentIdAndName("env2", "Action 2"); + expect(result).toBeNull(); + }); + + test("should throw DatabaseError when prisma throws", async () => { + if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn(); + vi.mocked(prisma.actionClass.findFirst).mockRejectedValue(new Error("fail")); + await expect(getActionClassByEnvironmentIdAndName("env2", "Action 2")).rejects.toThrow(DatabaseError); + }); + }); + + describe("getActionClass", () => { + test("should return action class when found", async () => { + const mockActionClass = { + id: "id3", + createdAt: new Date(), + updatedAt: new Date(), + name: "Action 3", + description: "desc3", + type: "code", + key: "key3", + noCodeConfig: {}, + environmentId: "env3", + }; + if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn(); + vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(mockActionClass); + const result = await getActionClass("id3"); + expect(result).toEqual(mockActionClass); + expect(prisma.actionClass.findUnique).toHaveBeenCalledWith({ + where: { id: "id3" }, + select: expect.any(Object), + }); + }); + + test("should return null when not found", async () => { + if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn(); + vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(null); + const result = await getActionClass("id3"); + expect(result).toBeNull(); + }); + + test("should throw DatabaseError when prisma throws", async () => { + if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn(); + vi.mocked(prisma.actionClass.findUnique).mockRejectedValue(new Error("fail")); + await expect(getActionClass("id3")).rejects.toThrow(DatabaseError); + }); + }); + + describe("deleteActionClass", () => { + test("should delete and return action class", async () => { + const mockActionClass: TActionClass = { + id: "id4", + createdAt: new Date(), + updatedAt: new Date(), + name: "Action 4", + description: null, + type: "code", + key: "key4", + noCodeConfig: null, + environmentId: "env4", + }; + if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn(); + vi.mocked(prisma.actionClass.delete).mockResolvedValue(mockActionClass); + const result = await deleteActionClass("id4"); + expect(result).toEqual(mockActionClass); + expect(prisma.actionClass.delete).toHaveBeenCalledWith({ + where: { id: "id4" }, + select: expect.any(Object), + }); + }); + + test("should throw ResourceNotFoundError if action class is null", async () => { + if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn(); + vi.mocked(prisma.actionClass.delete).mockResolvedValue(null as unknown as TActionClass); + await expect(deleteActionClass("id4")).rejects.toThrow(ResourceNotFoundError); + }); + + test("should rethrow unknown errors", async () => { + if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn(); + const error = new Error("unknown"); + vi.mocked(prisma.actionClass.delete).mockRejectedValue(error); + await expect(deleteActionClass("id4")).rejects.toThrow("unknown"); + }); + }); +}); diff --git a/apps/web/lib/actionClass/service.ts b/apps/web/lib/actionClass/service.ts new file mode 100644 index 000000000000..19e619694e3c --- /dev/null +++ b/apps/web/lib/actionClass/service.ts @@ -0,0 +1,196 @@ +"use server"; + +import "server-only"; +import { ActionClass, Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes"; +import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { ITEMS_PER_PAGE } from "../constants"; +import { validateInputs } from "../utils/validate"; + +const selectActionClass = { + id: true, + createdAt: true, + updatedAt: true, + name: true, + description: true, + type: true, + key: true, + noCodeConfig: true, + environmentId: true, +} satisfies Prisma.ActionClassSelect; + +export const getActionClasses = reactCache( + async (environmentId: string, page?: number): Promise => { + validateInputs([environmentId, ZId], [page, ZOptionalNumber]); + + try { + return await prisma.actionClass.findMany({ + where: { + environmentId: environmentId, + }, + select: selectActionClass, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + orderBy: { + createdAt: "asc", + }, + }); + } catch (error) { + throw new DatabaseError(`Database error when fetching actions for environment ${environmentId}`); + } + } +); + +// This function is used to get an action by its name and environmentId(it can return private actions as well) +export const getActionClassByEnvironmentIdAndName = reactCache( + async (environmentId: string, name: string): Promise => { + validateInputs([environmentId, ZId], [name, ZString]); + + try { + const actionClass = await prisma.actionClass.findFirst({ + where: { + name, + environmentId, + }, + select: selectActionClass, + }); + + return actionClass; + } catch (error) { + throw new DatabaseError(`Database error when fetching action`); + } + } +); + +export const getActionClass = reactCache(async (actionClassId: string): Promise => { + validateInputs([actionClassId, ZId]); + + try { + const actionClass = await prisma.actionClass.findUnique({ + where: { + id: actionClassId, + }, + select: selectActionClass, + }); + + return actionClass; + } catch (error) { + throw new DatabaseError(`Database error when fetching action`); + } +}); + +export const deleteActionClass = async (actionClassId: string): Promise => { + validateInputs([actionClassId, ZId]); + + try { + const actionClass = await prisma.actionClass.delete({ + where: { + id: actionClassId, + }, + select: selectActionClass, + }); + if (actionClass === null) throw new ResourceNotFoundError("Action", actionClassId); + + return actionClass; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const createActionClass = async ( + environmentId: string, + actionClass: TActionClassInput +): Promise => { + validateInputs([environmentId, ZId], [actionClass, ZActionClassInput]); + + const { environmentId: _, ...actionClassInput } = actionClass; + + try { + const actionClassPrisma = await prisma.actionClass.create({ + data: { + ...actionClassInput, + environment: { connect: { id: environmentId } }, + key: actionClassInput.type === "code" ? actionClassInput.key : undefined, + noCodeConfig: + actionClassInput.type === "noCode" + ? actionClassInput.noCodeConfig === null + ? undefined + : actionClassInput.noCodeConfig + : undefined, + }, + select: selectActionClass, + }); + + return actionClassPrisma; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === PrismaErrorType.UniqueConstraintViolation + ) { + throw new DatabaseError( + `Action with ${error.meta?.target?.[0]} ${actionClass[error.meta?.target?.[0]]} already exists` + ); + } + + throw new DatabaseError(`Database error when creating an action for environment ${environmentId}`); + } +}; + +export const updateActionClass = async ( + environmentId: string, + actionClassId: string, + inputActionClass: Partial +): Promise => { + validateInputs([environmentId, ZId], [actionClassId, ZId], [inputActionClass, ZActionClassInput]); + + const { environmentId: _, ...actionClassInput } = inputActionClass; + try { + const result = await prisma.actionClass.update({ + where: { + id: actionClassId, + }, + data: { + ...actionClassInput, + environment: { connect: { id: environmentId } }, + key: actionClassInput.type === "code" ? actionClassInput.key : undefined, + noCodeConfig: + actionClassInput.type === "noCode" + ? actionClassInput.noCodeConfig === null + ? undefined + : actionClassInput.noCodeConfig + : undefined, + }, + select: { + ...selectActionClass, + surveyTriggers: { + select: { + surveyId: true, + }, + }, + }, + }); + + return result; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === PrismaErrorType.UniqueConstraintViolation + ) { + throw new DatabaseError( + `Action with ${error.meta?.target?.[0]} ${inputActionClass[error.meta?.target?.[0]]} already exists` + ); + } + + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}; diff --git a/apps/web/lib/airtable/service.ts b/apps/web/lib/airtable/service.ts new file mode 100644 index 000000000000..bce27a9b4bc3 --- /dev/null +++ b/apps/web/lib/airtable/service.ts @@ -0,0 +1,286 @@ +import { Prisma } from "@prisma/client"; +import { logger } from "@formbricks/logger"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TIntegrationItem } from "@formbricks/types/integration"; +import { + TIntegrationAirtable, + TIntegrationAirtableConfigData, + TIntegrationAirtableCredential, + ZIntegrationAirtableBases, + ZIntegrationAirtableCredential, + ZIntegrationAirtableTables, + ZIntegrationAirtableTablesWithFields, + ZIntegrationAirtableTokenSchema, +} from "@formbricks/types/integration/airtable"; +import { AIRTABLE_CLIENT_ID, AIRTABLE_MESSAGE_LIMIT } from "../constants"; +import { createOrUpdateIntegration, getIntegrationByType } from "../integration/service"; +import { delay } from "../utils/promises"; +import { truncateText } from "../utils/strings"; + +export const getBases = async (key: string) => { + const req = await fetch("https://api.airtable.com/v0/meta/bases", { + headers: { + Authorization: `Bearer ${key}`, + }, + }); + + const res = await req.json(); + return ZIntegrationAirtableBases.parse(res); +}; + +const tableFetcher = async (key: TIntegrationAirtableCredential, baseId: string) => { + const req = await fetch(`https://api.airtable.com/v0/meta/bases/${baseId}/tables`, { + headers: { + Authorization: `Bearer ${key.access_token}`, + }, + }); + + const res = await req.json(); + + return res; +}; + +export const getTables = async (key: TIntegrationAirtableCredential, baseId: string) => { + const res = await tableFetcher(key, baseId); + return ZIntegrationAirtableTables.parse(res); +}; + +export const fetchAirtableAuthToken = async (formData: Record) => { + const formBody = Object.keys(formData) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(formData[key])}`) + .join("&"); + + const tokenReq = await fetch("https://airtable.com/oauth2/v1/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: formBody, + method: "POST", + }); + + const tokenRes: unknown = await tokenReq.json(); + const parsedToken = ZIntegrationAirtableTokenSchema.safeParse(tokenRes); + + if (!parsedToken.success) { + logger.error(parsedToken.error, "Error parsing airtable token"); + throw new Error(parsedToken.error.message); + } + const { access_token, refresh_token, expires_in } = parsedToken.data; + const expiry_date = new Date(); + expiry_date.setSeconds(expiry_date.getSeconds() + expires_in); + + return { + access_token, + expiry_date: expiry_date.toISOString(), + refresh_token, + }; +}; + +export const getAirtableToken = async (environmentId: string) => { + try { + const airtableIntegration = (await getIntegrationByType( + environmentId, + "airtable" + )) as TIntegrationAirtable; + + const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse( + airtableIntegration?.config.key + ); + + const expiryDate = new Date(expiry_date); + const currentDate = new Date(); + + if (currentDate >= expiryDate) { + const client_id = AIRTABLE_CLIENT_ID; + + const newToken = await fetchAirtableAuthToken({ + grant_type: "refresh_token", + refresh_token, + client_id, + }); + + if (!newToken) { + logger.error("Failed to fetch new Airtable token", { + environmentId, + airtableIntegration, + }); + throw new Error("Failed to fetch new Airtable token"); + } + + await createOrUpdateIntegration(environmentId, { + type: "airtable", + config: { + data: airtableIntegration?.config?.data ?? [], + email: airtableIntegration?.config?.email ?? "", + key: newToken, + }, + }); + + return newToken.access_token; + } + + return access_token; + } catch (error) { + logger.error("Failed to get Airtable token", { + environmentId, + error, + }); + throw new Error("Failed to get Airtable token"); + } +}; + +export const getAirtableTables = async (environmentId: string) => { + let tables: TIntegrationItem[] = []; + try { + const token = await getAirtableToken(environmentId); + + tables = (await getBases(token)).bases; + + return tables; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + throw error; + } +}; + +const addRecords = async ( + key: TIntegrationAirtableCredential, + baseId: string, + tableId: string, + data: Record +) => { + const req = await fetch(`https://api.airtable.com/v0/${baseId}/${tableId}`, { + method: "POST", + headers: { + Authorization: `Bearer ${key.access_token}`, + "Content-type": "application/json", + }, + body: JSON.stringify({ + fields: data, + typecast: true, + }), + }); + const res = await req.json(); + + return res; +}; + +const addField = async ( + key: TIntegrationAirtableCredential, + baseId: string, + tableId: string, + data: Record +) => { + const req = await fetch(`https://api.airtable.com/v0/meta/bases/${baseId}/tables/${tableId}/fields`, { + method: "POST", + headers: { + Authorization: `Bearer ${key.access_token}`, + "Content-type": "application/json", + }, + body: JSON.stringify(data), + }); + + return await req.json(); +}; + +const getExistingFields = async (key: TIntegrationAirtableCredential, baseId: string, tableId: string) => { + const req = await tableFetcher(key, baseId); + const tables = ZIntegrationAirtableTablesWithFields.parse(req).tables; + const currentTable = tables.find((t) => t.id === tableId); + + if (!currentTable) { + throw new Error(`Table with ID ${tableId} not found`); + } + + return new Set(currentTable.fields.map((f) => f.name)); +}; + +export const writeData = async ( + key: TIntegrationAirtableCredential, + configData: TIntegrationAirtableConfigData, + values: string[][] +) => { + const responses = values[0]; + const questions = values[1]; + + // 1) Build the record payload + const data: Record = {}; + for (let i = 0; i < questions.length; i++) { + data[questions[i]] = + responses[i].length > AIRTABLE_MESSAGE_LIMIT + ? truncateText(responses[i], AIRTABLE_MESSAGE_LIMIT) + : responses[i]; + } + + // 2) Figure out which fields need creating + const existingFields = await getExistingFields(key, configData.baseId, configData.tableId); + const fieldsToCreate = questions.filter((q) => !existingFields.has(q)); + + // 3) Create any missing fields with throttling to respect Airtable's 5 req/sec per base limit + if (fieldsToCreate.length > 0) { + // Sequential processing with delays + const DELAY_BETWEEN_REQUESTS = 250; // 250ms = 4 requests per second (staying under 5/sec limit) + + for (let i = 0; i < fieldsToCreate.length; i++) { + const fieldName = fieldsToCreate[i]; + + const createRes = await addField(key, configData.baseId, configData.tableId, { + name: fieldName, + type: "singleLineText", + }); + + if (createRes?.error) { + throw new Error(`Failed to create field "${fieldName}": ${JSON.stringify(createRes)}`); + } + + // Add delay between requests (except for the last one) + if (i < fieldsToCreate.length - 1) { + await delay(DELAY_BETWEEN_REQUESTS); + } + } + + // 4) Wait for the new fields to show up + await waitForFieldsToExist(key, configData, fieldsToCreate); + } + + // 5) Finally, add the records + await addRecords(key, configData.baseId, configData.tableId, data); +}; + +async function waitForFieldsToExist( + key: TIntegrationAirtableCredential, + configData: TIntegrationAirtableConfigData, + fieldNames: string[], + maxRetries = 5, + intervalMs = 2000 +) { + let existingFields: Set = new Set(), + missingFields: string[] = []; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + existingFields = await getExistingFields(key, configData.baseId, configData.tableId); + missingFields = fieldNames.filter((f) => !existingFields.has(f)); + + if (missingFields.length === 0) { + return; + } + + if (attempt < maxRetries) { + logger.error( + `Attempt ${attempt}/${maxRetries}: ${missingFields.length} field(s) still missing [${missingFields.join( + ", " + )}], retrying in ${intervalMs / 1000}s…` + ); + + await new Promise((r) => setTimeout(r, intervalMs)); + } + } + + throw new Error( + `Timed out waiting for ${missingFields.length} field(s) [${missingFields.join( + ", " + )}] to become available. Available fields: [${Array.from(existingFields).join(", ")}]` + ); +} diff --git a/apps/web/lib/auth.test.ts b/apps/web/lib/auth.test.ts new file mode 100644 index 000000000000..e25322491137 --- /dev/null +++ b/apps/web/lib/auth.test.ts @@ -0,0 +1,209 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { AuthenticationError } from "@formbricks/types/errors"; +import { + hasOrganizationAccess, + hasOrganizationAuthority, + hasOrganizationOwnership, + hashPassword, + isManagerOrOwner, + isOwner, + verifyPassword, +} from "./auth"; + +// Mock prisma +vi.mock("@formbricks/database", () => ({ + prisma: { + membership: { + findUnique: vi.fn(), + }, + }, +})); + +describe("Password Management", () => { + test("hashPassword should hash a password", async () => { + const password = "testPassword123"; + const hashedPassword = await hashPassword(password); + expect(hashedPassword).toBeDefined(); + expect(hashedPassword).not.toBe(password); + }); + + test("verifyPassword should verify a correct password", async () => { + const password = "testPassword123"; + const hashedPassword = await hashPassword(password); + const isValid = await verifyPassword(password, hashedPassword); + expect(isValid).toBe(true); + }); + + test("verifyPassword should reject an incorrect password", async () => { + const password = "testPassword123"; + const hashedPassword = await hashPassword(password); + const isValid = await verifyPassword("wrongPassword", hashedPassword); + expect(isValid).toBe(false); + }); +}); + +describe("Organization Access", () => { + const mockUserId = "user123"; + const mockOrgId = "org123"; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("hasOrganizationAccess should return true when user has membership", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue({ + userId: mockUserId, + organizationId: mockOrgId, + role: "member", + accepted: true, + deprecatedRole: null, + }); + + const hasAccess = await hasOrganizationAccess(mockUserId, mockOrgId); + expect(hasAccess).toBe(true); + }); + + test("hasOrganizationAccess should return false when user has no membership", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue(null); + + const hasAccess = await hasOrganizationAccess(mockUserId, mockOrgId); + expect(hasAccess).toBe(false); + }); + + test("isManagerOrOwner should return true for manager role", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue({ + userId: mockUserId, + organizationId: mockOrgId, + role: "manager", + accepted: true, + deprecatedRole: null, + }); + + const isManager = await isManagerOrOwner(mockUserId, mockOrgId); + expect(isManager).toBe(true); + }); + + test("isManagerOrOwner should return true for owner role", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue({ + userId: mockUserId, + organizationId: mockOrgId, + role: "owner", + accepted: true, + deprecatedRole: null, + }); + + const isOwner = await isManagerOrOwner(mockUserId, mockOrgId); + expect(isOwner).toBe(true); + }); + + test("isManagerOrOwner should return false for member role", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue({ + userId: mockUserId, + organizationId: mockOrgId, + role: "member", + accepted: true, + deprecatedRole: null, + }); + + const isManagerOrOwnerRole = await isManagerOrOwner(mockUserId, mockOrgId); + expect(isManagerOrOwnerRole).toBe(false); + }); + + test("isOwner should return true only for owner role", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue({ + userId: mockUserId, + organizationId: mockOrgId, + role: "owner", + accepted: true, + deprecatedRole: null, + }); + + const isOwnerRole = await isOwner(mockUserId, mockOrgId); + expect(isOwnerRole).toBe(true); + }); + + test("isOwner should return false for non-owner roles", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue({ + userId: mockUserId, + organizationId: mockOrgId, + role: "manager", + accepted: true, + deprecatedRole: null, + }); + + const isOwnerRole = await isOwner(mockUserId, mockOrgId); + expect(isOwnerRole).toBe(false); + }); +}); + +describe("Organization Authority", () => { + const mockUserId = "user123"; + const mockOrgId = "org123"; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("hasOrganizationAuthority should return true for manager", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue({ + userId: mockUserId, + organizationId: mockOrgId, + role: "manager", + accepted: true, + deprecatedRole: null, + }); + + const hasAuthority = await hasOrganizationAuthority(mockUserId, mockOrgId); + expect(hasAuthority).toBe(true); + }); + + test("hasOrganizationAuthority should throw for non-member", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue(null); + + await expect(hasOrganizationAuthority(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError); + }); + + test("hasOrganizationAuthority should throw for member role", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue({ + userId: mockUserId, + organizationId: mockOrgId, + role: "member", + accepted: true, + deprecatedRole: null, + }); + + await expect(hasOrganizationAuthority(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError); + }); + + test("hasOrganizationOwnership should return true for owner", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue({ + userId: mockUserId, + organizationId: mockOrgId, + role: "owner", + accepted: true, + deprecatedRole: null, + }); + + const hasOwnership = await hasOrganizationOwnership(mockUserId, mockOrgId); + expect(hasOwnership).toBe(true); + }); + + test("hasOrganizationOwnership should throw for non-member", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue(null); + + await expect(hasOrganizationOwnership(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError); + }); + + test("hasOrganizationOwnership should throw for non-owner roles", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue({ + userId: mockUserId, + organizationId: mockOrgId, + role: "manager", + accepted: true, + deprecatedRole: null, + }); + + await expect(hasOrganizationOwnership(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError); + }); +}); diff --git a/packages/lib/auth.ts b/apps/web/lib/auth.ts similarity index 100% rename from packages/lib/auth.ts rename to apps/web/lib/auth.ts diff --git a/apps/web/lib/cache/api-key.ts b/apps/web/lib/cache/api-key.ts deleted file mode 100644 index f0dc9158bbe0..000000000000 --- a/apps/web/lib/cache/api-key.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { revalidateTag } from "next/cache"; - -interface RevalidateProps { - id?: string; - hashedKey?: string; - organizationId?: string; -} - -export const apiKeyCache = { - tag: { - byId(id: string) { - return `apiKeys-${id}`; - }, - byHashedKey(hashedKey: string) { - return `apiKeys-${hashedKey}-apiKey`; - }, - byOrganizationId(organizationId: string) { - return `organizations-${organizationId}-apiKeys`; - }, - }, - revalidate({ id, hashedKey, organizationId }: RevalidateProps): void { - if (id) { - revalidateTag(this.tag.byId(id)); - } - - if (hashedKey) { - revalidateTag(this.tag.byHashedKey(hashedKey)); - } - - if (organizationId) { - revalidateTag(this.tag.byOrganizationId(organizationId)); - } - }, -}; diff --git a/apps/web/lib/cache/contact-attribute-key.ts b/apps/web/lib/cache/contact-attribute-key.ts deleted file mode 100644 index f1654ed80bbf..000000000000 --- a/apps/web/lib/cache/contact-attribute-key.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { revalidateTag } from "next/cache"; - -interface RevalidateProps { - id?: string; - environmentId?: string; - key?: string; -} - -export const contactAttributeKeyCache = { - tag: { - byId(id: string) { - return `contactAttributeKey-${id}`; - }, - byEnvironmentId(environmentId: string) { - return `environments-${environmentId}-contactAttributeKeys`; - }, - byEnvironmentIdAndKey(environmentId: string, key: string) { - return `contactAttributeKey-environment-${environmentId}-key-${key}`; - }, - }, - revalidate: ({ id, environmentId, key }: RevalidateProps): void => { - if (id) { - revalidateTag(contactAttributeKeyCache.tag.byId(id)); - } - - if (environmentId) { - revalidateTag(contactAttributeKeyCache.tag.byEnvironmentId(environmentId)); - } - - if (environmentId && key) { - revalidateTag(contactAttributeKeyCache.tag.byEnvironmentIdAndKey(environmentId, key)); - } - }, -}; diff --git a/apps/web/lib/cache/contact-attribute.ts b/apps/web/lib/cache/contact-attribute.ts deleted file mode 100644 index 16d8621275de..000000000000 --- a/apps/web/lib/cache/contact-attribute.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { revalidateTag } from "next/cache"; - -interface RevalidateProps { - environmentId?: string; - contactId?: string; - userId?: string; - key?: string; -} - -export const contactAttributeCache = { - tag: { - byContactId(contactId: string): string { - return `contact-${contactId}-contactAttributes`; - }, - byEnvironmentIdAndUserId(environmentId: string, userId: string): string { - return `environments-${environmentId}-contact-userId-${userId}-contactAttributes`; - }, - byKeyAndContactId(key: string, contactId: string): string { - return `contact-${contactId}-contactAttribute-${key}`; - }, - byEnvironmentId(environmentId: string): string { - return `contactAttributes-${environmentId}`; - }, - }, - revalidate: ({ contactId, environmentId, userId, key }: RevalidateProps): void => { - if (environmentId) { - revalidateTag(contactAttributeCache.tag.byEnvironmentId(environmentId)); - } - - if (environmentId && userId) { - revalidateTag(contactAttributeCache.tag.byEnvironmentIdAndUserId(environmentId, userId)); - } - if (contactId) { - revalidateTag(contactAttributeCache.tag.byContactId(contactId)); - } - if (contactId && key) { - revalidateTag(contactAttributeCache.tag.byKeyAndContactId(key, contactId)); - } - }, -}; diff --git a/apps/web/lib/cache/contact.ts b/apps/web/lib/cache/contact.ts deleted file mode 100644 index d9d99a1c5733..000000000000 --- a/apps/web/lib/cache/contact.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { revalidateTag } from "next/cache"; - -interface RevalidateProps { - id?: string; - environmentId?: string; - userId?: string; -} - -export const contactCache = { - tag: { - byId(id: string): string { - return `contacts-${id}`; - }, - byEnvironmentId(environmentId: string): string { - return `environments-${environmentId}-contacts`; - }, - byEnvironmentIdAndUserId(environmentId: string, userId: string): string { - return `environments-${environmentId}-contactByUserId-${userId}`; - }, - }, - revalidate: ({ id, environmentId, userId }: RevalidateProps): void => { - if (id) { - revalidateTag(contactCache.tag.byId(id)); - } - - if (environmentId) { - revalidateTag(contactCache.tag.byEnvironmentId(environmentId)); - } - - if (environmentId && userId) { - revalidateTag(contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)); - } - }, -}; diff --git a/apps/web/lib/cache/document.ts b/apps/web/lib/cache/document.ts deleted file mode 100644 index 97dc8b3bb184..000000000000 --- a/apps/web/lib/cache/document.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { revalidateTag } from "next/cache"; -import { type TSurveyQuestionId } from "@formbricks/types/surveys/types"; - -interface RevalidateProps { - id?: string; - environmentId?: string | null; - surveyId?: string | null; - responseId?: string | null; - questionId?: string | null; - insightId?: string | null; -} - -export const documentCache = { - tag: { - byId(id: string) { - return `documents-${id}`; - }, - byEnvironmentId(environmentId: string) { - return `environments-${environmentId}-documents`; - }, - byResponseId(responseId: string) { - return `responses-${responseId}-documents`; - }, - byResponseIdQuestionId(responseId: string, questionId: TSurveyQuestionId) { - return `responses-${responseId}-questions-${questionId}-documents`; - }, - bySurveyId(surveyId: string) { - return `surveys-${surveyId}-documents`; - }, - bySurveyIdQuestionId(surveyId: string, questionId: TSurveyQuestionId) { - return `surveys-${surveyId}-questions-${questionId}-documents`; - }, - byInsightId(insightId: string) { - return `insights-${insightId}-documents`; - }, - byInsightIdSurveyIdQuestionId(insightId: string, surveyId: string, questionId: TSurveyQuestionId) { - return `insights-${insightId}-surveys-${surveyId}-questions-${questionId}-documents`; - }, - }, - revalidate: ({ id, environmentId, surveyId, responseId, questionId, insightId }: RevalidateProps): void => { - if (id) { - revalidateTag(documentCache.tag.byId(id)); - } - if (environmentId) { - revalidateTag(documentCache.tag.byEnvironmentId(environmentId)); - } - if (responseId) { - revalidateTag(documentCache.tag.byResponseId(responseId)); - } - if (surveyId) { - revalidateTag(documentCache.tag.bySurveyId(surveyId)); - } - if (responseId && questionId) { - revalidateTag(documentCache.tag.byResponseIdQuestionId(responseId, questionId)); - } - if (surveyId && questionId) { - revalidateTag(documentCache.tag.bySurveyIdQuestionId(surveyId, questionId)); - } - if (insightId) { - revalidateTag(documentCache.tag.byInsightId(insightId)); - } - if (insightId && surveyId && questionId) { - revalidateTag(documentCache.tag.byInsightIdSurveyIdQuestionId(insightId, surveyId, questionId)); - } - }, -}; diff --git a/apps/web/lib/cache/insight.ts b/apps/web/lib/cache/insight.ts deleted file mode 100644 index 420154e69e54..000000000000 --- a/apps/web/lib/cache/insight.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { revalidateTag } from "next/cache"; - -interface RevalidateProps { - id?: string; - environmentId?: string; -} - -export const insightCache = { - tag: { - byId(id: string) { - return `documentGroups-${id}`; - }, - byEnvironmentId(environmentId: string) { - return `environments-${environmentId}-documentGroups`; - }, - }, - revalidate: ({ id, environmentId }: RevalidateProps): void => { - if (id) { - revalidateTag(insightCache.tag.byId(id)); - } - if (environmentId) { - revalidateTag(insightCache.tag.byEnvironmentId(environmentId)); - } - }, -}; diff --git a/apps/web/lib/cache/invite.ts b/apps/web/lib/cache/invite.ts deleted file mode 100644 index 5bd15057d498..000000000000 --- a/apps/web/lib/cache/invite.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { revalidateTag } from "next/cache"; - -interface RevalidateProps { - id?: string; - organizationId?: string; -} - -export const inviteCache = { - tag: { - byId(id: string) { - return `invites-${id}`; - }, - byOrganizationId(organizationId: string) { - return `organizations-${organizationId}-invites`; - }, - }, - revalidate({ id, organizationId }: RevalidateProps): void { - if (id) { - revalidateTag(this.tag.byId(id)); - } - - if (organizationId) { - revalidateTag(this.tag.byOrganizationId(organizationId)); - } - }, -}; diff --git a/apps/web/lib/cache/membership.ts b/apps/web/lib/cache/membership.ts deleted file mode 100644 index ed6ecde13cc3..000000000000 --- a/apps/web/lib/cache/membership.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { revalidateTag } from "next/cache"; - -interface RevalidateProps { - userId?: string; - organizationId?: string; -} - -export const membershipCache = { - tag: { - byOrganizationId(organizationId: string) { - return `organizations-${organizationId}-memberships`; - }, - byUserId(userId: string) { - return `users-${userId}-memberships`; - }, - }, - revalidate: ({ organizationId, userId }: RevalidateProps): void => { - if (organizationId) { - revalidateTag(membershipCache.tag.byOrganizationId(organizationId)); - } - - if (userId) { - revalidateTag(membershipCache.tag.byUserId(userId)); - } - }, -}; diff --git a/apps/web/lib/cache/organization.ts b/apps/web/lib/cache/organization.ts deleted file mode 100644 index 1d483e7155d0..000000000000 --- a/apps/web/lib/cache/organization.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { revalidateTag } from "next/cache"; - -interface RevalidateProps { - id?: string; - userId?: string; - environmentId?: string; - count?: boolean; -} - -export const organizationCache = { - tag: { - byId(id: string) { - return `organizations-${id}`; - }, - byUserId(userId: string) { - return `users-${userId}-organizations`; - }, - byEnvironmentId(environmentId: string) { - return `environments-${environmentId}-organizations`; - }, - byCount() { - return "organizations-count"; - }, - }, - revalidate: ({ id, userId, environmentId, count }: RevalidateProps): void => { - if (id) { - revalidateTag(organizationCache.tag.byId(id)); - } - - if (userId) { - revalidateTag(organizationCache.tag.byUserId(userId)); - } - - if (environmentId) { - revalidateTag(organizationCache.tag.byEnvironmentId(environmentId)); - } - - if (count) { - revalidateTag(organizationCache.tag.byCount()); - } - }, -}; diff --git a/apps/web/lib/cache/team.ts b/apps/web/lib/cache/team.ts deleted file mode 100644 index 7d9ba48a469f..000000000000 --- a/apps/web/lib/cache/team.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { revalidateTag } from "next/cache"; - -interface RevalidateProps { - id?: string; - userId?: string; - projectId?: string; - organizationId?: string; -} - -export const teamCache = { - tag: { - byId(id: string) { - return `team-${id}`; - }, - byProjectId(projectId: string) { - return `project-teams-${projectId}`; - }, - byUserId(userId: string) { - return `user-${userId}-teams`; - }, - byOrganizationId(organizationId: string) { - return `organization-${organizationId}-teams`; - }, - }, - revalidate: ({ id, projectId, userId, organizationId }: RevalidateProps): void => { - if (id) { - revalidateTag(teamCache.tag.byId(id)); - } - if (projectId) { - revalidateTag(teamCache.tag.byProjectId(projectId)); - } - if (userId) { - revalidateTag(teamCache.tag.byUserId(userId)); - } - if (organizationId) { - revalidateTag(teamCache.tag.byOrganizationId(organizationId)); - } - }, -}; diff --git a/apps/web/lib/cache/webhook.ts b/apps/web/lib/cache/webhook.ts deleted file mode 100644 index a56d21c4737e..000000000000 --- a/apps/web/lib/cache/webhook.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Webhook } from "@prisma/client"; -import { revalidateTag } from "next/cache"; - -interface RevalidateProps { - id?: string; - environmentId?: string; - source?: Webhook["source"]; -} - -export const webhookCache = { - tag: { - byId(id: string) { - return `webhooks-${id}`; - }, - byEnvironmentId(environmentId: string) { - return `environments-${environmentId}-webhooks`; - }, - byEnvironmentIdAndSource(environmentId: string, source?: Webhook["source"]) { - return `environments-${environmentId}-sources-${source}-webhooks`; - }, - }, - revalidate({ id, environmentId, source }: RevalidateProps): void { - if (id) { - revalidateTag(this.tag.byId(id)); - } - - if (environmentId) { - revalidateTag(this.tag.byEnvironmentId(environmentId)); - } - - if (environmentId && source) { - revalidateTag(this.tag.byEnvironmentIdAndSource(environmentId, source)); - } - }, -}; diff --git a/packages/lib/cn.ts b/apps/web/lib/cn.ts similarity index 100% rename from packages/lib/cn.ts rename to apps/web/lib/cn.ts diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts new file mode 100644 index 000000000000..482f632c8050 --- /dev/null +++ b/apps/web/lib/constants.ts @@ -0,0 +1,269 @@ +import "server-only"; +import { TUserLocale } from "@formbricks/types/user"; +import { env } from "./env"; + +export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1"; + +export const IS_PRODUCTION = env.NODE_ENV === "production"; + +export const IS_DEVELOPMENT = env.NODE_ENV === "development"; +export const E2E_TESTING = env.E2E_TESTING === "1"; + +// URLs +export const WEBAPP_URL = + env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000"; + +// encryption keys +export const ENCRYPTION_KEY = env.ENCRYPTION_KEY; + +// Other +export const CRON_SECRET = env.CRON_SECRET; +export const DEFAULT_BRAND_COLOR = "#64748b"; +export const FB_LOGO_URL = + "https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png"; + +export const PRIVACY_URL = env.PRIVACY_URL; +export const TERMS_URL = env.TERMS_URL; +export const IMPRINT_URL = env.IMPRINT_URL; +export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS; + +export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1"; +export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1"; + +export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET); +export const GITHUB_OAUTH_ENABLED = !!(env.GITHUB_ID && env.GITHUB_SECRET); +export const AZURE_OAUTH_ENABLED = !!(env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET); +export const OIDC_OAUTH_ENABLED = !!(env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER); +export const SAML_OAUTH_ENABLED = !!env.SAML_DATABASE_URL; +export const SAML_XML_DIR = "./saml-connection"; + +export const GITHUB_ID = env.GITHUB_ID; +export const GITHUB_SECRET = env.GITHUB_SECRET; +export const GOOGLE_CLIENT_ID = env.GOOGLE_CLIENT_ID; +export const GOOGLE_CLIENT_SECRET = env.GOOGLE_CLIENT_SECRET; + +export const AZUREAD_CLIENT_ID = env.AZUREAD_CLIENT_ID; +export const AZUREAD_CLIENT_SECRET = env.AZUREAD_CLIENT_SECRET; +export const AZUREAD_TENANT_ID = env.AZUREAD_TENANT_ID; + +export const OIDC_CLIENT_ID = env.OIDC_CLIENT_ID; +export const OIDC_CLIENT_SECRET = env.OIDC_CLIENT_SECRET; +export const OIDC_ISSUER = env.OIDC_ISSUER; +export const OIDC_DISPLAY_NAME = env.OIDC_DISPLAY_NAME; +export const OIDC_SIGNING_ALGORITHM = env.OIDC_SIGNING_ALGORITHM; + +export const SAML_DATABASE_URL = env.SAML_DATABASE_URL; +export const SAML_TENANT = "formbricks.com"; +export const SAML_PRODUCT = "formbricks"; +export const SAML_AUDIENCE = "https://saml.formbricks.com"; +export const SAML_PATH = "/api/auth/saml/callback"; + +export const SIGNUP_ENABLED = IS_FORMBRICKS_CLOUD || IS_DEVELOPMENT || E2E_TESTING; +export const EMAIL_AUTH_ENABLED = env.EMAIL_AUTH_DISABLED !== "1"; +export const INVITE_DISABLED = env.INVITE_DISABLED === "1"; + +export const SLACK_CLIENT_SECRET = env.SLACK_CLIENT_SECRET; +export const SLACK_CLIENT_ID = env.SLACK_CLIENT_ID; +export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read`; + +export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID; +export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET; +export const GOOGLE_SHEETS_REDIRECT_URL = env.GOOGLE_SHEETS_REDIRECT_URL; + +export const NOTION_OAUTH_CLIENT_ID = env.NOTION_OAUTH_CLIENT_ID; +export const NOTION_OAUTH_CLIENT_SECRET = env.NOTION_OAUTH_CLIENT_SECRET; +export const NOTION_REDIRECT_URI = `${WEBAPP_URL}/api/v1/integrations/notion/callback`; +export const NOTION_AUTH_URL = `https://api.notion.com/v1/oauth/authorize?client_id=${env.NOTION_OAUTH_CLIENT_ID}&response_type=code&owner=user&redirect_uri=${NOTION_REDIRECT_URI}`; + +export const AIRTABLE_CLIENT_ID = env.AIRTABLE_CLIENT_ID; + +export const SMTP_HOST = env.SMTP_HOST; +export const SMTP_PORT = env.SMTP_PORT; +export const SMTP_SECURE_ENABLED = env.SMTP_SECURE_ENABLED === "1" || env.SMTP_PORT === "465"; +export const SMTP_USER = env.SMTP_USER; +export const SMTP_PASSWORD = env.SMTP_PASSWORD; +export const SMTP_AUTHENTICATED = env.SMTP_AUTHENTICATED !== "0"; +export const SMTP_REJECT_UNAUTHORIZED_TLS = env.SMTP_REJECT_UNAUTHORIZED_TLS !== "0"; +export const MAIL_FROM = env.MAIL_FROM; +export const MAIL_FROM_NAME = env.MAIL_FROM_NAME; + +export const NEXTAUTH_SECRET = env.NEXTAUTH_SECRET; +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 MAX_RESPONSES_FOR_INSIGHT_GENERATION = 500; +export const MAX_OTHER_OPTION_LENGTH = 250; + +export const SKIP_INVITE_FOR_SSO = env.AUTH_SKIP_INVITE_FOR_SSO === "1"; +export const DEFAULT_TEAM_ID = env.AUTH_DEFAULT_TEAM_ID; + +export const SLACK_MESSAGE_LIMIT = 2995; +export const GOOGLE_SHEET_MESSAGE_LIMIT = 49995; +export const AIRTABLE_MESSAGE_LIMIT = 99995; +export const NOTION_RICH_TEXT_LIMIT = 1995; + +// Storage constants +export const S3_ACCESS_KEY = env.S3_ACCESS_KEY; +export const S3_SECRET_KEY = env.S3_SECRET_KEY; +export const S3_REGION = env.S3_REGION; +export const S3_ENDPOINT_URL = env.S3_ENDPOINT_URL; +export const S3_BUCKET_NAME = env.S3_BUCKET_NAME; +export const S3_FORCE_PATH_STYLE = env.S3_FORCE_PATH_STYLE === "1"; +export const UPLOADS_DIR = env.UPLOADS_DIR ?? "./uploads"; +export const MAX_SIZES = { + standard: 1024 * 1024 * 10, // 10MB + big: 1024 * 1024 * 1024, // 1GB +} as const; + +// Function to check if the necessary S3 configuration is set up +export const isS3Configured = () => { + // This function checks if the S3 bucket name environment variable is defined. + // The AWS SDK automatically resolves credentials through a chain, + // so we do not need to explicitly check for AWS credentials like access key, secret key, or region. + return !!S3_BUCKET_NAME; +}; + +// Colors for Survey Bg +export const SURVEY_BG_COLORS = [ + "#FFFFFF", + "#FFF2D8", + "#EAD7BB", + "#BCA37F", + "#113946", + "#04364A", + "#176B87", + "#64CCC5", + "#DAFFFB", + "#132043", + "#1F4172", + "#F1B4BB", + "#FDF0F0", + "#001524", + "#445D48", + "#D6CC99", + "#FDE5D4", + "#BEADFA", + "#D0BFFF", + "#DFCCFB", + "#FFF8C9", + "#FF8080", + "#FFCF96", + "#F6FDC3", + "#CDFAD5", +]; + +export const DEBUG = env.DEBUG === "1"; + +// Enterprise License constant +export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY; + +export const REDIS_URL = env.REDIS_URL; +export const REDIS_HTTP_URL = env.REDIS_HTTP_URL; +export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1"; + +export const BREVO_API_KEY = env.BREVO_API_KEY; +export const BREVO_LIST_ID = env.BREVO_LIST_ID; + +export const UNSPLASH_ACCESS_KEY = env.UNSPLASH_ACCESS_KEY; +export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"]; + +export const STRIPE_API_VERSION = "2024-06-20"; + +// Maximum number of attribute classes allowed: +export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150; + +export const DEFAULT_LOCALE = "en-US"; +export const AVAILABLE_LOCALES: TUserLocale[] = [ + "en-US", + "de-DE", + "pt-BR", + "fr-FR", + "zh-Hant-TW", + "pt-PT", + "ro-RO", +]; + +// Billing constants + +export enum PROJECT_FEATURE_KEYS { + FREE = "free", + STARTUP = "startup", + SCALE = "scale", + ENTERPRISE = "enterprise", +} + +export enum STRIPE_PROJECT_NAMES { + STARTUP = "Formbricks Startup", + SCALE = "Formbricks Scale", + ENTERPRISE = "Formbricks Enterprise", +} + +export enum STRIPE_PRICE_LOOKUP_KEYS { + STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY", + STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY", + SCALE_MONTHLY = "formbricks_scale_monthly", + SCALE_YEARLY = "formbricks_scale_yearly", +} + +export const BILLING_LIMITS = { + FREE: { + PROJECTS: 3, + RESPONSES: 1500, + MIU: 2000, + }, + STARTUP: { + PROJECTS: 3, + RESPONSES: 5000, + MIU: 7500, + }, + SCALE: { + PROJECTS: 5, + RESPONSES: 10000, + MIU: 30000, + }, +} as const; + +export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY; +export const INTERCOM_APP_ID = env.INTERCOM_APP_ID; +export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY); + +export const POSTHOG_API_KEY = env.POSTHOG_API_KEY; +export const POSTHOG_API_HOST = env.POSTHOG_API_HOST; +export const IS_POSTHOG_CONFIGURED = Boolean(POSTHOG_API_KEY && POSTHOG_API_HOST); + +export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY; +export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY; +export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY); + +export const RECAPTCHA_SITE_KEY = env.RECAPTCHA_SITE_KEY; +export const RECAPTCHA_SECRET_KEY = env.RECAPTCHA_SECRET_KEY; +export const IS_RECAPTCHA_CONFIGURED = Boolean(RECAPTCHA_SITE_KEY && RECAPTCHA_SECRET_KEY); + +// Use the app version for Sentry release (updated during build in production) +// Fallback to environment variable if package.json is not accessible +export const SENTRY_RELEASE = (() => { + if (process.env.NODE_ENV !== "production") { + return undefined; + } + + // Try to read from package.json with proper error handling + try { + const pkg = require("../package.json"); + return pkg.version === "0.0.0" ? undefined : `v${pkg.version}`; + } catch { + // If package.json can't be read (e.g., in some deployment scenarios), + // return undefined and let Sentry work without release tracking + return undefined; + } +})(); +export const SENTRY_ENVIRONMENT = env.SENTRY_ENVIRONMENT; +export const SENTRY_DSN = env.SENTRY_DSN; + +export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1"; + +export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ?? "manager"; + +export const AUDIT_LOG_ENABLED = env.AUDIT_LOG_ENABLED === "1"; +export const AUDIT_LOG_GET_USER_IP = env.AUDIT_LOG_GET_USER_IP === "1"; +export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400; diff --git a/apps/web/lib/crypto.test.ts b/apps/web/lib/crypto.test.ts new file mode 100644 index 000000000000..6592fcf1c80c --- /dev/null +++ b/apps/web/lib/crypto.test.ts @@ -0,0 +1,59 @@ +import { createCipheriv, randomBytes } from "crypto"; +import { describe, expect, test, vi } from "vitest"; +import { + generateLocalSignedUrl, + getHash, + symmetricDecrypt, + symmetricEncrypt, + validateLocalSignedUrl, +} from "./crypto"; + +vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) })); + +const key = "0".repeat(32); +const plain = "hello"; + +describe("crypto", () => { + test("encrypt + decrypt roundtrip", () => { + const cipher = symmetricEncrypt(plain, key); + expect(symmetricDecrypt(cipher, key)).toBe(plain); + }); + + test("decrypt V2 GCM payload", () => { + const iv = randomBytes(16); + const bufKey = Buffer.from(key, "utf8"); + const cipher = createCipheriv("aes-256-gcm", bufKey, iv); + let enc = cipher.update(plain, "utf8", "hex"); + enc += cipher.final("hex"); + const tag = cipher.getAuthTag().toString("hex"); + const payload = `${iv.toString("hex")}:${enc}:${tag}`; + expect(symmetricDecrypt(payload, key)).toBe(plain); + }); + + test("decrypt legacy (single-colon) payload", () => { + const iv = randomBytes(16); + const cipher = createCipheriv("aes256", Buffer.from(key, "utf8"), iv); // NOSONAR typescript:S5542 // We are testing backwards compatibility + let enc = cipher.update(plain, "utf8", "hex"); + enc += cipher.final("hex"); + const legacy = `${iv.toString("hex")}:${enc}`; + expect(symmetricDecrypt(legacy, key)).toBe(plain); + }); + + test("getHash returns a non-empty string", () => { + const h = getHash("abc"); + expect(typeof h).toBe("string"); + expect(h.length).toBeGreaterThan(0); + }); + + test("signed URL generation & validation", () => { + const { uuid, timestamp, signature } = generateLocalSignedUrl("f", "e", "t"); + expect(uuid).toHaveLength(32); + expect(typeof timestamp).toBe("number"); + expect(typeof signature).toBe("string"); + expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, signature, key)).toBe(true); + expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, "bad", key)).toBe(false); + expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp - 1000 * 60 * 6, signature, key)).toBe( + false + ); + }); +}); diff --git a/apps/web/lib/crypto.ts b/apps/web/lib/crypto.ts new file mode 100644 index 000000000000..b46294ef3ca8 --- /dev/null +++ b/apps/web/lib/crypto.ts @@ -0,0 +1,130 @@ +import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "crypto"; +import { logger } from "@formbricks/logger"; +import { ENCRYPTION_KEY } from "./constants"; + +const ALGORITHM_V1 = "aes256"; +const ALGORITHM_V2 = "aes-256-gcm"; +const INPUT_ENCODING = "utf8"; +const OUTPUT_ENCODING = "hex"; +const BUFFER_ENCODING = ENCRYPTION_KEY.length === 32 ? "latin1" : "hex"; +const IV_LENGTH = 16; // AES blocksize + +/** + * + * @param text Value to be encrypted + * @param key Key used to encrypt value must be 32 bytes for AES256 encryption algorithm + * + * @returns Encrypted value using key + */ +export const symmetricEncrypt = (text: string, key: string) => { + const _key = Buffer.from(key, BUFFER_ENCODING); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM_V2, _key, iv); + let ciphered = cipher.update(text, INPUT_ENCODING, OUTPUT_ENCODING); + ciphered += cipher.final(OUTPUT_ENCODING); + const tag = cipher.getAuthTag().toString(OUTPUT_ENCODING); + return `${iv.toString(OUTPUT_ENCODING)}:${ciphered}:${tag}`; +}; + +/** + * + * @param text Value to decrypt + * @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm + */ + +const symmetricDecryptV1 = (text: string, key: string): string => { + const _key = Buffer.from(key, BUFFER_ENCODING); + + const components = text.split(":"); + const iv_from_ciphertext = Buffer.from(components.shift() ?? "", OUTPUT_ENCODING); + const decipher = createDecipheriv(ALGORITHM_V1, _key, iv_from_ciphertext); + let deciphered = decipher.update(components.join(":"), OUTPUT_ENCODING, INPUT_ENCODING); + deciphered += decipher.final(INPUT_ENCODING); + + return deciphered; +}; + +/** + * + * @param text Value to decrypt + * @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm + */ + +const symmetricDecryptV2 = (text: string, key: string): string => { + // split into [ivHex, encryptedHex, tagHex] + const [ivHex, encryptedHex, tagHex] = text.split(":"); + const _key = Buffer.from(key, BUFFER_ENCODING); + const iv = Buffer.from(ivHex, OUTPUT_ENCODING); + const decipher = createDecipheriv(ALGORITHM_V2, _key, iv); + decipher.setAuthTag(Buffer.from(tagHex, OUTPUT_ENCODING)); + let decrypted = decipher.update(encryptedHex, OUTPUT_ENCODING, INPUT_ENCODING); + decrypted += decipher.final(INPUT_ENCODING); + return decrypted; +}; + +/** + * Decrypts an encrypted payload, automatically handling multiple encryption versions. + * + * If the payload contains exactly one “:”, it is treated as a legacy V1 format + * and `symmetricDecryptV1` is invoked. Otherwise, it attempts a V2 GCM decryption + * via `symmetricDecryptV2`, falling back to V1 on failure (e.g., authentication + * errors or bad formats). + * + * @param payload - The encrypted string to decrypt. + * @param key - The secret key used for decryption. + * @returns The decrypted plaintext. + */ + +export function symmetricDecrypt(payload: string, key: string): string { + // If it's clearly V1 (only one “:”), skip straight to V1 + if (payload.split(":").length === 2) { + return symmetricDecryptV1(payload, key); + } + + // Otherwise try GCM first, then fall back to CBC + try { + return symmetricDecryptV2(payload, key); + } catch (err) { + logger.warn(err, "AES-GCM decryption failed; refusing to fall back to insecure CBC"); + + throw err; + } +} + +export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex"); + +export const generateLocalSignedUrl = ( + fileName: string, + environmentId: string, + fileType: string +): { signature: string; uuid: string; timestamp: number } => { + const uuid = randomBytes(16).toString("hex"); + const timestamp = Date.now(); + const data = `${uuid}:${fileName}:${environmentId}:${fileType}:${timestamp}`; + const signature = createHmac("sha256", ENCRYPTION_KEY).update(data).digest("hex"); + return { signature, uuid, timestamp }; +}; + +export const validateLocalSignedUrl = ( + uuid: string, + fileName: string, + environmentId: string, + fileType: string, + timestamp: number, + signature: string, + secret: string +): boolean => { + const data = `${uuid}:${fileName}:${environmentId}:${fileType}:${timestamp}`; + const expectedSignature = createHmac("sha256", secret).update(data).digest("hex"); + + if (expectedSignature !== signature) { + return false; + } + + // valid for 5 minutes + if (Date.now() - timestamp > 1000 * 60 * 5) { + return false; + } + + return true; +}; diff --git a/apps/web/lib/display/service.ts b/apps/web/lib/display/service.ts new file mode 100644 index 000000000000..63321e79b4d5 --- /dev/null +++ b/apps/web/lib/display/service.ts @@ -0,0 +1,64 @@ +import "server-only"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { ZId } from "@formbricks/types/common"; +import { TDisplay, TDisplayFilters } from "@formbricks/types/displays"; +import { DatabaseError } from "@formbricks/types/errors"; +import { validateInputs } from "../utils/validate"; + +export const selectDisplay = { + id: true, + createdAt: true, + updatedAt: true, + surveyId: true, + contactId: true, + status: true, +} satisfies Prisma.DisplaySelect; + +export const getDisplayCountBySurveyId = reactCache( + async (surveyId: string, filters?: TDisplayFilters): Promise => { + validateInputs([surveyId, ZId]); + + try { + const displayCount = await prisma.display.count({ + where: { + surveyId: surveyId, + ...(filters && + filters.createdAt && { + createdAt: { + gte: filters.createdAt.min, + lte: filters.createdAt.max, + }, + }), + }, + }); + return displayCount; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } + } +); + +export const deleteDisplay = async (displayId: string): Promise => { + validateInputs([displayId, ZId]); + try { + const display = await prisma.display.delete({ + where: { + id: displayId, + }, + select: selectDisplay, + }); + + return display; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; diff --git a/packages/lib/display/tests/__mocks__/data.mock.ts b/apps/web/lib/display/tests/__mocks__/data.mock.ts similarity index 100% rename from packages/lib/display/tests/__mocks__/data.mock.ts rename to apps/web/lib/display/tests/__mocks__/data.mock.ts diff --git a/apps/web/lib/display/tests/display.test.ts b/apps/web/lib/display/tests/display.test.ts new file mode 100644 index 000000000000..ada1880765cc --- /dev/null +++ b/apps/web/lib/display/tests/display.test.ts @@ -0,0 +1,110 @@ +import { mockContact } from "../../response/tests/__mocks__/data.mock"; +import { + mockDisplay, + mockDisplayInput, + mockDisplayInputWithUserId, + mockDisplayWithPersonId, + mockEnvironment, + mockEnvironmentId, + mockSurveyId, +} from "./__mocks__/data.mock"; +import { prisma } from "@/lib/__mocks__/database"; +import { createDisplay } from "@/app/api/v1/client/[environmentId]/displays/lib/display"; +import { Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { testInputValidation } from "vitestSetup"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { DatabaseError } from "@formbricks/types/errors"; +import { deleteDisplay } from "../service"; + +beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +beforeEach(() => { + prisma.contact.findFirst.mockResolvedValue(mockContact); + prisma.survey.findUnique.mockResolvedValue({ + id: mockSurveyId, + name: "Test Survey", + environmentId: mockEnvironmentId, + } as any); +}); + +describe("Tests for createDisplay service", () => { + describe("Happy Path", () => { + test("Creates a new display when a userId exists", async () => { + prisma.environment.findUnique.mockResolvedValue(mockEnvironment as any); + prisma.display.create.mockResolvedValue(mockDisplayWithPersonId as any); + + const display = await createDisplay(mockDisplayInputWithUserId); + expect(display).toEqual(mockDisplayWithPersonId); + }); + + test("Creates a new display when a userId does not exists", async () => { + prisma.display.create.mockResolvedValue(mockDisplay as any); + + const display = await createDisplay(mockDisplayInput); + expect(display).toEqual(mockDisplay); + }); + }); + + describe("Sad Path", () => { + testInputValidation(createDisplay, "123"); + + test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => { + const mockErrorMessage = "Mock error message"; + prisma.environment.findUnique.mockResolvedValue(mockEnvironment as any); + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + + prisma.display.create.mockRejectedValue(errToThrow); + + await expect(createDisplay(mockDisplayInputWithUserId)).rejects.toThrow(DatabaseError); + }); + + test("Throws a generic Error for other exceptions", async () => { + const mockErrorMessage = "Mock error message"; + prisma.display.create.mockRejectedValue(new Error(mockErrorMessage)); + + await expect(createDisplay(mockDisplayInput)).rejects.toThrow(Error); + }); + }); +}); + +describe("Tests for delete display service", () => { + describe("Happy Path", () => { + test("Deletes a display", async () => { + prisma.display.delete.mockResolvedValue(mockDisplay as any); + + const display = await deleteDisplay(mockDisplay.id); + expect(display).toEqual(mockDisplay); + }); + }); + describe("Sad Path", () => { + test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => { + const mockErrorMessage = "Mock error message"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + + prisma.display.delete.mockRejectedValue(errToThrow); + + await expect(deleteDisplay(mockDisplay.id)).rejects.toThrow(DatabaseError); + }); + + test("Throws a generic Error for other exceptions", async () => { + const mockErrorMessage = "Mock error message"; + prisma.display.delete.mockRejectedValue(new Error(mockErrorMessage)); + + await expect(deleteDisplay(mockDisplay.id)).rejects.toThrow(Error); + }); + }); +}); diff --git a/packages/lib/env.d.ts b/apps/web/lib/env.d.ts similarity index 100% rename from packages/lib/env.d.ts rename to apps/web/lib/env.d.ts diff --git a/packages/lib/env.ts b/apps/web/lib/env.ts similarity index 79% rename from packages/lib/env.ts rename to apps/web/lib/env.ts index d8017affa7aa..1229e4d745c7 100644 --- a/packages/lib/env.ts +++ b/apps/web/lib/env.ts @@ -7,12 +7,6 @@ export const env = createEnv({ * Will throw if you access these variables on the client. */ server: { - AI_AZURE_EMBEDDINGS_API_KEY: z.string().optional(), - AI_AZURE_LLM_API_KEY: z.string().optional(), - AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: z.string().optional(), - AI_AZURE_LLM_DEPLOYMENT_ID: z.string().optional(), - AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: z.string().optional(), - AI_AZURE_LLM_RESSOURCE_NAME: z.string().optional(), AIRTABLE_CLIENT_ID: z.string().optional(), AZUREAD_CLIENT_ID: z.string().optional(), AZUREAD_CLIENT_SECRET: z.string().optional(), @@ -23,20 +17,13 @@ export const env = createEnv({ DATABASE_URL: z.string().url(), DEBUG: z.enum(["1", "0"]).optional(), DOCKER_CRON_ENABLED: z.enum(["1", "0"]).optional(), - DEFAULT_ORGANIZATION_ID: z.string().optional(), - DEFAULT_ORGANIZATION_ROLE: z.enum(["owner", "manager", "member", "billing"]).optional(), + AUTH_DEFAULT_TEAM_ID: z.string().optional(), + AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(), E2E_TESTING: z.enum(["1", "0"]).optional(), EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(), EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(), ENCRYPTION_KEY: z.string(), ENTERPRISE_LICENSE_KEY: z.string().optional(), - FORMBRICKS_ENCRYPTION_KEY: z.string().length(24).or(z.string().length(0)).optional(), - FORMBRICKS_API_HOST: z - .string() - .url() - .optional() - .or(z.string().refine((str) => str === "")), - FORMBRICKS_ENVIRONMENT_ID: z.string().optional(), GITHUB_ID: z.string().optional(), GITHUB_SECRET: z.string().optional(), GOOGLE_CLIENT_ID: z.string().optional(), @@ -69,7 +56,6 @@ export const env = createEnv({ OIDC_SIGNING_ALGORITHM: z.string().optional(), OPENTELEMETRY_LISTENER_URL: z.string().optional(), REDIS_URL: z.string().optional(), - REDIS_DEFAULT_TTL: z.string().optional(), REDIS_HTTP_URL: z.string().optional(), PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(), POSTHOG_API_HOST: z.string().optional(), @@ -88,7 +74,6 @@ export const env = createEnv({ S3_FORCE_PATH_STYLE: z.enum(["1", "0"]).optional(), SAML_DATABASE_URL: z.string().optional(), SENTRY_DSN: z.string().optional(), - SIGNUP_DISABLED: z.enum(["1", "0"]).optional(), SLACK_CLIENT_ID: z.string().optional(), SLACK_CLIENT_SECRET: z.string().optional(), SMTP_HOST: z.string().min(1).optional(), @@ -100,7 +85,23 @@ export const env = createEnv({ SMTP_REJECT_UNAUTHORIZED_TLS: z.enum(["1", "0"]).optional(), STRIPE_SECRET_KEY: z.string().optional(), STRIPE_WEBHOOK_SECRET: z.string().optional(), - SURVEY_URL: z.string().optional(), + PUBLIC_URL: z + .string() + .url() + .refine( + (url) => { + try { + const parsed = new URL(url); + return parsed.host && parsed.host.length > 0; + } catch { + return false; + } + }, + { + message: "PUBLIC_URL must be a valid URL with a proper host (e.g., https://example.com)", + } + ) + .optional(), TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(), TERMS_URL: z .string() @@ -109,17 +110,24 @@ export const env = createEnv({ .or(z.string().refine((str) => str === "")), TURNSTILE_SECRET_KEY: z.string().optional(), TURNSTILE_SITE_KEY: z.string().optional(), + RECAPTCHA_SITE_KEY: z.string().optional(), + RECAPTCHA_SECRET_KEY: z.string().optional(), UPLOADS_DIR: z.string().min(1).optional(), VERCEL_URL: z.string().optional(), WEBAPP_URL: z.string().url().optional(), UNSPLASH_ACCESS_KEY: z.string().optional(), - LANGFUSE_SECRET_KEY: z.string().optional(), - LANGFUSE_PUBLIC_KEY: z.string().optional(), - LANGFUSE_BASEURL: z.string().optional(), - UNKEY_ROOT_KEY: z.string().optional(), + NODE_ENV: z.enum(["development", "production", "test"]).optional(), PROMETHEUS_EXPORTER_PORT: z.string().optional(), PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(), + USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).optional(), + AUDIT_LOG_ENABLED: z.enum(["1", "0"]).optional(), + AUDIT_LOG_GET_USER_IP: z.enum(["1", "0"]).optional(), + SESSION_MAX_AGE: z + .string() + .transform((val) => parseInt(val)) + .optional(), + SENTRY_ENVIRONMENT: z.string().optional(), }, /* @@ -129,15 +137,6 @@ export const env = createEnv({ * 💡 You'll get type errors if not all variables from `server` & `client` are included here. */ runtimeEnv: { - AI_AZURE_EMBEDDINGS_API_KEY: process.env.AI_AZURE_EMBEDDINGS_API_KEY, - AI_AZURE_LLM_API_KEY: process.env.AI_AZURE_LLM_API_KEY, - AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: process.env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID, - AI_AZURE_LLM_DEPLOYMENT_ID: process.env.AI_AZURE_LLM_DEPLOYMENT_ID, - AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: process.env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME, - AI_AZURE_LLM_RESSOURCE_NAME: process.env.AI_AZURE_LLM_RESSOURCE_NAME, - LANGFUSE_SECRET_KEY: process.env.LANGFUSE_SECRET_KEY, - LANGFUSE_PUBLIC_KEY: process.env.LANGFUSE_PUBLIC_KEY, - LANGFUSE_BASEURL: process.env.LANGFUSE_BASEURL, AIRTABLE_CLIENT_ID: process.env.AIRTABLE_CLIENT_ID, AZUREAD_CLIENT_ID: process.env.AZUREAD_CLIENT_ID, AZUREAD_CLIENT_SECRET: process.env.AZUREAD_CLIENT_SECRET, @@ -147,17 +146,14 @@ export const env = createEnv({ CRON_SECRET: process.env.CRON_SECRET, DATABASE_URL: process.env.DATABASE_URL, DEBUG: process.env.DEBUG, - DEFAULT_ORGANIZATION_ID: process.env.DEFAULT_ORGANIZATION_ID, - DEFAULT_ORGANIZATION_ROLE: process.env.DEFAULT_ORGANIZATION_ROLE, + AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID, + AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO, DOCKER_CRON_ENABLED: process.env.DOCKER_CRON_ENABLED, E2E_TESTING: process.env.E2E_TESTING, EMAIL_AUTH_DISABLED: process.env.EMAIL_AUTH_DISABLED, EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED, ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY, - FORMBRICKS_ENCRYPTION_KEY: process.env.FORMBRICKS_ENCRYPTION_KEY, - FORMBRICKS_API_HOST: process.env.FORMBRICKS_API_HOST, - FORMBRICKS_ENVIRONMENT_ID: process.env.FORMBRICKS_ENVIRONMENT_ID, GITHUB_ID: process.env.GITHUB_ID, GITHUB_SECRET: process.env.GITHUB_SECRET, GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, @@ -189,7 +185,6 @@ export const env = createEnv({ OIDC_ISSUER: process.env.OIDC_ISSUER, OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM, REDIS_URL: process.env.REDIS_URL, - REDIS_DEFAULT_TTL: process.env.REDIS_DEFAULT_TTL, REDIS_HTTP_URL: process.env.REDIS_HTTP_URL, PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED, PRIVACY_URL: process.env.PRIVACY_URL, @@ -201,7 +196,6 @@ export const env = createEnv({ S3_ENDPOINT_URL: process.env.S3_ENDPOINT_URL, S3_FORCE_PATH_STYLE: process.env.S3_FORCE_PATH_STYLE, SAML_DATABASE_URL: process.env.SAML_DATABASE_URL, - SIGNUP_DISABLED: process.env.SIGNUP_DISABLED, SLACK_CLIENT_ID: process.env.SLACK_CLIENT_ID, SLACK_CLIENT_SECRET: process.env.SLACK_CLIENT_SECRET, SMTP_HOST: process.env.SMTP_HOST, @@ -213,18 +207,24 @@ export const env = createEnv({ SMTP_AUTHENTICATED: process.env.SMTP_AUTHENTICATED, STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, - SURVEY_URL: process.env.SURVEY_URL, + PUBLIC_URL: process.env.PUBLIC_URL, TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED, TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY, TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY, + RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY, + RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY, TERMS_URL: process.env.TERMS_URL, UPLOADS_DIR: process.env.UPLOADS_DIR, VERCEL_URL: process.env.VERCEL_URL, WEBAPP_URL: process.env.WEBAPP_URL, UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY, - UNKEY_ROOT_KEY: process.env.UNKEY_ROOT_KEY, NODE_ENV: process.env.NODE_ENV, PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED, PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT, + USER_MANAGEMENT_MINIMUM_ROLE: process.env.USER_MANAGEMENT_MINIMUM_ROLE, + AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED, + AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP, + SESSION_MAX_AGE: process.env.SESSION_MAX_AGE, + SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT, }, }); diff --git a/apps/web/lib/environment/auth.test.ts b/apps/web/lib/environment/auth.test.ts new file mode 100644 index 000000000000..5e820a01f4c9 --- /dev/null +++ b/apps/web/lib/environment/auth.test.ts @@ -0,0 +1,86 @@ +import { Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { hasUserEnvironmentAccess } from "./auth"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + membership: { + findFirst: vi.fn(), + }, + teamUser: { + findFirst: vi.fn(), + }, + }, +})); + +describe("hasUserEnvironmentAccess", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test("returns true for owner role", async () => { + vi.mocked(prisma.membership.findFirst).mockResolvedValue({ + role: "owner", + } as any); + + const result = await hasUserEnvironmentAccess("user1", "env1"); + expect(result).toBe(true); + }); + + test("returns true for manager role", async () => { + vi.mocked(prisma.membership.findFirst).mockResolvedValue({ + role: "manager", + } as any); + + const result = await hasUserEnvironmentAccess("user1", "env1"); + expect(result).toBe(true); + }); + + test("returns true for billing role", async () => { + vi.mocked(prisma.membership.findFirst).mockResolvedValue({ + role: "billing", + } as any); + + const result = await hasUserEnvironmentAccess("user1", "env1"); + expect(result).toBe(true); + }); + + test("returns true when user has team membership", async () => { + vi.mocked(prisma.membership.findFirst).mockResolvedValue({ + role: "member", + } as any); + vi.mocked(prisma.teamUser.findFirst).mockResolvedValue({ + userId: "user1", + } as any); + + const result = await hasUserEnvironmentAccess("user1", "env1"); + expect(result).toBe(true); + }); + + test("returns false when user has no access", async () => { + vi.mocked(prisma.membership.findFirst).mockResolvedValue({ + role: "member", + } as any); + vi.mocked(prisma.teamUser.findFirst).mockResolvedValue(null); + + const result = await hasUserEnvironmentAccess("user1", "env1"); + expect(result).toBe(false); + }); + + test("throws DatabaseError on Prisma error", async () => { + vi.mocked(prisma.membership.findFirst).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Test error", { + code: "P2002", + clientVersion: "1.0.0", + }) + ); + + await expect(hasUserEnvironmentAccess("user1", "env1")).rejects.toThrow(DatabaseError); + }); +}); diff --git a/apps/web/lib/environment/auth.ts b/apps/web/lib/environment/auth.ts new file mode 100644 index 000000000000..292dd1c6f4ff --- /dev/null +++ b/apps/web/lib/environment/auth.ts @@ -0,0 +1,65 @@ +import { Prisma } from "@prisma/client"; +import { prisma } from "@formbricks/database"; +import { ZId } from "@formbricks/types/common"; +import { DatabaseError } from "@formbricks/types/errors"; +import { validateInputs } from "../utils/validate"; + +export const hasUserEnvironmentAccess = async (userId: string, environmentId: string) => { + validateInputs([userId, ZId], [environmentId, ZId]); + + try { + const orgMembership = await prisma.membership.findFirst({ + where: { + userId, + organization: { + projects: { + some: { + environments: { + some: { + id: environmentId, + }, + }, + }, + }, + }, + }, + }); + + if (!orgMembership) return false; + + if ( + orgMembership.role === "owner" || + orgMembership.role === "manager" || + orgMembership.role === "billing" + ) + return true; + + const teamMembership = await prisma.teamUser.findFirst({ + where: { + userId, + team: { + projectTeams: { + some: { + project: { + environments: { + some: { + id: environmentId, + }, + }, + }, + }, + }, + }, + }, + }); + + if (teamMembership) return true; + + return false; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}; diff --git a/apps/web/lib/environment/service.test.ts b/apps/web/lib/environment/service.test.ts new file mode 100644 index 000000000000..bfbf3a5cdad9 --- /dev/null +++ b/apps/web/lib/environment/service.test.ts @@ -0,0 +1,181 @@ +import { EnvironmentType, Prisma } from "@prisma/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { getEnvironment, getEnvironments, updateEnvironment } from "./service"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + environment: { + findUnique: vi.fn(), + update: vi.fn(), + }, + project: { + findFirst: vi.fn(), + }, + }, +})); + +vi.mock("../utils/validate", () => ({ + validateInputs: vi.fn(), +})); + +describe("Environment Service", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getEnvironment", () => { + test("should return environment when found", async () => { + const mockEnvironment = { + id: "clh6pzwx90000e9ogjr0mf7sx", + type: EnvironmentType.production, + projectId: "clh6pzwx90000e9ogjr0mf7sy", + createdAt: new Date(), + updatedAt: new Date(), + appSetupCompleted: false, + widgetSetupCompleted: false, + }; + + vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironment); + + const result = await getEnvironment("clh6pzwx90000e9ogjr0mf7sx"); + + expect(result).toEqual(mockEnvironment); + expect(prisma.environment.findUnique).toHaveBeenCalledWith({ + where: { + id: "clh6pzwx90000e9ogjr0mf7sx", + }, + }); + }); + + test("should return null when environment not found", async () => { + vi.mocked(prisma.environment.findUnique).mockResolvedValue(null); + + const result = await getEnvironment("clh6pzwx90000e9ogjr0mf7sx"); + + expect(result).toBeNull(); + }); + + test("should throw DatabaseError when prisma throws", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError); + + await expect(getEnvironment("clh6pzwx90000e9ogjr0mf7sx")).rejects.toThrow(DatabaseError); + }); + }); + + describe("getEnvironments", () => { + test("should return environments when project exists", async () => { + const mockEnvironments = [ + { + id: "clh6pzwx90000e9ogjr0mf7sx", + type: EnvironmentType.production, + projectId: "clh6pzwx90000e9ogjr0mf7sy", + createdAt: new Date(), + updatedAt: new Date(), + appSetupCompleted: false, + }, + { + id: "clh6pzwx90000e9ogjr0mf7sz", + type: EnvironmentType.development, + projectId: "clh6pzwx90000e9ogjr0mf7sy", + createdAt: new Date(), + updatedAt: new Date(), + appSetupCompleted: true, + }, + ]; + + vi.mocked(prisma.project.findFirst).mockResolvedValue({ + id: "clh6pzwx90000e9ogjr0mf7sy", + name: "Test Project", + environments: [ + { + ...mockEnvironments[0], + widgetSetupCompleted: false, + }, + { + ...mockEnvironments[1], + widgetSetupCompleted: true, + }, + ], + }); + + const result = await getEnvironments("clh6pzwx90000e9ogjr0mf7sy"); + + expect(result).toEqual(mockEnvironments); + expect(prisma.project.findFirst).toHaveBeenCalledWith({ + where: { + id: "clh6pzwx90000e9ogjr0mf7sy", + }, + include: { + environments: true, + }, + }); + }); + + test("should throw ResourceNotFoundError when project not found", async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue(null); + + await expect(getEnvironments("clh6pzwx90000e9ogjr0mf7sy")).rejects.toThrow(ResourceNotFoundError); + }); + + test("should throw DatabaseError when prisma throws", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.project.findFirst).mockRejectedValue(prismaError); + + await expect(getEnvironments("clh6pzwx90000e9ogjr0mf7sy")).rejects.toThrow(DatabaseError); + }); + }); + + describe("updateEnvironment", () => { + test("should update environment successfully", async () => { + const mockEnvironment = { + id: "clh6pzwx90000e9ogjr0mf7sx", + type: EnvironmentType.production, + projectId: "clh6pzwx90000e9ogjr0mf7sy", + createdAt: new Date(), + updatedAt: new Date(), + appSetupCompleted: false, + widgetSetupCompleted: false, + }; + + vi.mocked(prisma.environment.update).mockResolvedValue(mockEnvironment); + + const updateData = { + appSetupCompleted: true, + }; + + const result = await updateEnvironment("clh6pzwx90000e9ogjr0mf7sx", updateData); + + expect(result).toEqual(mockEnvironment); + expect(prisma.environment.update).toHaveBeenCalledWith({ + where: { + id: "clh6pzwx90000e9ogjr0mf7sx", + }, + data: expect.objectContaining({ + appSetupCompleted: true, + updatedAt: expect.any(Date), + }), + }); + }); + + test("should throw DatabaseError when prisma throws", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.environment.update).mockRejectedValue(prismaError); + + await expect( + updateEnvironment("clh6pzwx90000e9ogjr0mf7sx", { appSetupCompleted: true }) + ).rejects.toThrow(DatabaseError); + }); + }); +}); diff --git a/apps/web/lib/environment/service.ts b/apps/web/lib/environment/service.ts new file mode 100644 index 000000000000..044d9e10a5de --- /dev/null +++ b/apps/web/lib/environment/service.ts @@ -0,0 +1,187 @@ +import "server-only"; +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 } from "@formbricks/types/common"; +import type { + TEnvironment, + TEnvironmentCreateInput, + TEnvironmentUpdateInput, +} from "@formbricks/types/environment"; +import { + ZEnvironment, + ZEnvironmentCreateInput, + ZEnvironmentUpdateInput, +} from "@formbricks/types/environment"; +import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors"; +import { getOrganizationsByUserId } from "../organization/service"; +import { capturePosthogEnvironmentEvent } from "../posthogServer"; +import { getUserProjects } from "../project/service"; +import { validateInputs } from "../utils/validate"; + +export const getEnvironment = reactCache(async (environmentId: string): Promise => { + validateInputs([environmentId, ZId]); + + try { + const environment = await prisma.environment.findUnique({ + where: { + id: environmentId, + }, + }); + return environment; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error getting environment"); + throw new DatabaseError(error.message); + } + + throw error; + } +}); + +export const getEnvironments = reactCache(async (projectId: string): Promise => { + validateInputs([projectId, ZId]); + let projectPrisma; + try { + projectPrisma = await prisma.project.findFirst({ + where: { + id: projectId, + }, + include: { + environments: true, + }, + }); + + if (!projectPrisma) { + throw new ResourceNotFoundError("Project", projectId); + } + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } + + const environments: TEnvironment[] = []; + for (let environment of projectPrisma.environments) { + let targetEnvironment: TEnvironment = ZEnvironment.parse(environment); + environments.push(targetEnvironment); + } + + try { + return environments; + } catch (error) { + if (error instanceof z.ZodError) { + logger.error(error, "Error getting environments"); + } + throw new ValidationError("Data validation of environments array failed"); + } +}); + +export const updateEnvironment = async ( + environmentId: string, + data: Partial +): Promise => { + validateInputs([environmentId, ZId], [data, ZEnvironmentUpdateInput.partial()]); + const newData = { ...data, updatedAt: new Date() }; + let updatedEnvironment; + try { + updatedEnvironment = await prisma.environment.update({ + where: { + id: environmentId, + }, + data: newData, + }); + + return updatedEnvironment; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const getFirstEnvironmentIdByUserId = async (userId: string): Promise => { + try { + const organizations = await getOrganizationsByUserId(userId); + if (organizations.length === 0) { + return null; + } + const firstOrganization = organizations[0]; + const projects = await getUserProjects(userId, firstOrganization.id); + if (projects.length === 0) { + return null; + } + const firstProject = projects[0]; + const productionEnvironment = firstProject.environments.find( + (environment) => environment.type === "production" + ); + if (!productionEnvironment) { + return null; + } + return productionEnvironment.id; + } catch (error) { + throw error; + } +}; + +export const createEnvironment = async ( + projectId: string, + environmentInput: Partial +): Promise => { + validateInputs([projectId, ZId], [environmentInput, ZEnvironmentCreateInput]); + + try { + const environment = await prisma.environment.create({ + data: { + type: environmentInput.type || "development", + project: { connect: { id: projectId } }, + appSetupCompleted: environmentInput.appSetupCompleted || false, + attributeKeys: { + create: [ + { + key: "userId", + name: "User Id", + description: "The user id of a contact", + type: "default", + isUnique: true, + }, + { + key: "email", + name: "Email", + description: "The email of a contact", + type: "default", + isUnique: true, + }, + { + key: "firstName", + name: "First Name", + description: "Your contact's first name", + type: "default", + }, + { + key: "lastName", + name: "Last Name", + description: "Your contact's last name", + type: "default", + }, + ], + }, + }, + }); + + await capturePosthogEnvironmentEvent(environment.id, "environment created", { + environmentType: environment.type, + }); + + return environment; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}; diff --git a/apps/web/lib/fileValidation.test.ts b/apps/web/lib/fileValidation.test.ts new file mode 100644 index 000000000000..82a0c069f34c --- /dev/null +++ b/apps/web/lib/fileValidation.test.ts @@ -0,0 +1,316 @@ +import * as storageUtils from "@/lib/storage/utils"; +import { describe, expect, test, vi } from "vitest"; +import { ZAllowedFileExtension } from "@formbricks/types/common"; +import { TResponseData } from "@formbricks/types/responses"; +import { TSurveyQuestion } from "@formbricks/types/surveys/types"; +import { + isAllowedFileExtension, + isValidFileTypeForExtension, + isValidImageFile, + validateFile, + validateFileUploads, + validateSingleFile, +} from "./fileValidation"; + +// Mock getOriginalFileNameFromUrl function +vi.mock("@/lib/storage/utils", () => ({ + getOriginalFileNameFromUrl: vi.fn((url) => { + // Extract filename from the URL for testing purposes + const parts = url.split("/"); + return parts[parts.length - 1]; + }), +})); + +describe("fileValidation", () => { + describe("isAllowedFileExtension", () => { + test("should return false for a file with no extension", () => { + expect(isAllowedFileExtension("filename")).toBe(false); + }); + + test("should return false for a file with extension not in allowed list", () => { + expect(isAllowedFileExtension("malicious.exe")).toBe(false); + expect(isAllowedFileExtension("script.php")).toBe(false); + expect(isAllowedFileExtension("config.js")).toBe(false); + expect(isAllowedFileExtension("page.html")).toBe(false); + }); + + test("should return true for an allowed file extension", () => { + Object.values(ZAllowedFileExtension.enum).forEach((ext) => { + expect(isAllowedFileExtension(`file.${ext}`)).toBe(true); + }); + }); + + test("should handle case insensitivity correctly", () => { + expect(isAllowedFileExtension("image.PNG")).toBe(true); + expect(isAllowedFileExtension("document.PDF")).toBe(true); + }); + + test("should handle filenames with multiple dots", () => { + expect(isAllowedFileExtension("example.backup.pdf")).toBe(true); + expect(isAllowedFileExtension("document.old.exe")).toBe(false); + }); + }); + + describe("isValidFileTypeForExtension", () => { + test("should return false for a file with no extension", () => { + expect(isValidFileTypeForExtension("filename", "application/octet-stream")).toBe(false); + }); + + test("should return true for valid extension and MIME type combinations", () => { + expect(isValidFileTypeForExtension("image.jpg", "image/jpeg")).toBe(true); + expect(isValidFileTypeForExtension("image.png", "image/png")).toBe(true); + expect(isValidFileTypeForExtension("document.pdf", "application/pdf")).toBe(true); + }); + + test("should return false for mismatched extension and MIME type", () => { + expect(isValidFileTypeForExtension("image.jpg", "image/png")).toBe(false); + expect(isValidFileTypeForExtension("document.pdf", "image/jpeg")).toBe(false); + expect(isValidFileTypeForExtension("image.png", "application/pdf")).toBe(false); + }); + + test("should handle case insensitivity correctly", () => { + expect(isValidFileTypeForExtension("image.JPG", "image/jpeg")).toBe(true); + expect(isValidFileTypeForExtension("image.jpg", "IMAGE/JPEG")).toBe(true); + }); + }); + + describe("validateFile", () => { + test("should return valid: false when file extension is not allowed", () => { + const result = validateFile("script.php", "application/php"); + expect(result.valid).toBe(false); + expect(result.error).toContain("File type not allowed"); + }); + + test("should return valid: false when file type does not match extension", () => { + const result = validateFile("image.png", "application/pdf"); + expect(result.valid).toBe(false); + expect(result.error).toContain("File type doesn't match"); + }); + + test("should return valid: true when file is allowed and type matches extension", () => { + const result = validateFile("image.jpg", "image/jpeg"); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + test("should return valid: true for allowed file types", () => { + Object.values(ZAllowedFileExtension.enum).forEach((ext) => { + // Skip testing extensions that don't have defined MIME types in the test + if (["jpg", "png", "pdf"].includes(ext)) { + const mimeType = ext === "jpg" ? "image/jpeg" : ext === "png" ? "image/png" : "application/pdf"; + const result = validateFile(`file.${ext}`, mimeType); + expect(result.valid).toBe(true); + } + }); + }); + + test("should return valid: false for files with no extension", () => { + const result = validateFile("noextension", "application/octet-stream"); + expect(result.valid).toBe(false); + }); + + test("should handle attempts to bypass with double extension", () => { + const result = validateFile("malicious.jpg.php", "image/jpeg"); + expect(result.valid).toBe(false); + }); + }); + + describe("validateSingleFile", () => { + test("should return true for allowed file extension", () => { + vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("image.jpg"); + expect(validateSingleFile("https://example.com/image.jpg", ["jpg", "png"])).toBe(true); + }); + + test("should return false for disallowed file extension", () => { + vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("malicious.exe"); + expect(validateSingleFile("https://example.com/malicious.exe", ["jpg", "png"])).toBe(false); + }); + + test("should return true when no allowed extensions are specified", () => { + vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("image.jpg"); + expect(validateSingleFile("https://example.com/image.jpg")).toBe(true); + }); + + test("should return false when file name cannot be extracted", () => { + vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce(undefined); + expect(validateSingleFile("https://example.com/unknown")).toBe(false); + }); + + test("should return false when file has no extension", () => { + vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("filewithoutextension"); + expect(validateSingleFile("https://example.com/filewithoutextension", ["jpg"])).toBe(false); + }); + }); + + describe("validateFileUploads", () => { + test("should return true for valid file uploads in response data", () => { + const responseData = { + question1: ["https://example.com/storage/file1.jpg", "https://example.com/storage/file2.pdf"], + }; + + const questions = [ + { + id: "question1", + type: "fileUpload" as const, + allowedFileExtensions: ["jpg", "pdf"], + } as TSurveyQuestion, + ]; + + expect(validateFileUploads(responseData, questions)).toBe(true); + }); + + test("should return false when file url is not a string", () => { + const responseData = { + question1: [123, "https://example.com/storage/file.jpg"], + } as TResponseData; + + const questions = [ + { + id: "question1", + type: "fileUpload" as const, + allowedFileExtensions: ["jpg"], + } as TSurveyQuestion, + ]; + + expect(validateFileUploads(responseData, questions)).toBe(false); + }); + + test("should return false when file urls are not in an array", () => { + const responseData = { + question1: "https://example.com/storage/file.jpg", + }; + + const questions = [ + { + id: "question1", + type: "fileUpload" as const, + allowedFileExtensions: ["jpg"], + } as TSurveyQuestion, + ]; + + expect(validateFileUploads(responseData, questions)).toBe(false); + }); + + test("should return false when file extension is not allowed", () => { + const responseData = { + question1: ["https://example.com/storage/file.exe"], + }; + + const questions = [ + { + id: "question1", + type: "fileUpload" as const, + allowedFileExtensions: ["jpg", "pdf"], + } as TSurveyQuestion, + ]; + + expect(validateFileUploads(responseData, questions)).toBe(false); + }); + + test("should return false when file name cannot be extracted", () => { + // Mock implementation to return null for this specific URL + vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => undefined); + + const responseData = { + question1: ["https://example.com/invalid-url"], + }; + + const questions = [ + { + id: "question1", + type: "fileUpload" as const, + allowedFileExtensions: ["jpg"], + } as TSurveyQuestion, + ]; + + expect(validateFileUploads(responseData, questions)).toBe(false); + }); + + test("should return false when file has no extension", () => { + vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce( + () => "file-without-extension" + ); + + const responseData = { + question1: ["https://example.com/storage/file-without-extension"], + }; + + const questions = [ + { + id: "question1", + type: "fileUpload" as const, + allowedFileExtensions: ["jpg"], + } as TSurveyQuestion, + ]; + + expect(validateFileUploads(responseData, questions)).toBe(false); + }); + + test("should ignore non-fileUpload questions", () => { + const responseData = { + question1: ["https://example.com/storage/file.jpg"], + question2: "Some text answer", + }; + + const questions = [ + { + id: "question1", + type: "fileUpload" as const, + allowedFileExtensions: ["jpg"], + }, + { + id: "question2", + type: "text" as const, + }, + ] as TSurveyQuestion[]; + + expect(validateFileUploads(responseData, questions)).toBe(true); + }); + + test("should return true when no questions are provided", () => { + const responseData = { + question1: ["https://example.com/storage/file.jpg"], + }; + + expect(validateFileUploads(responseData)).toBe(true); + }); + }); + + describe("isValidImageFile", () => { + test("should return true for valid image file extensions", () => { + expect(isValidImageFile("https://example.com/image.jpg")).toBe(true); + expect(isValidImageFile("https://example.com/image.jpeg")).toBe(true); + expect(isValidImageFile("https://example.com/image.png")).toBe(true); + expect(isValidImageFile("https://example.com/image.webp")).toBe(true); + expect(isValidImageFile("https://example.com/image.heic")).toBe(true); + }); + + test("should return false for non-image file extensions", () => { + expect(isValidImageFile("https://example.com/document.pdf")).toBe(false); + expect(isValidImageFile("https://example.com/document.docx")).toBe(false); + expect(isValidImageFile("https://example.com/document.txt")).toBe(false); + }); + + test("should return false when file name cannot be extracted", () => { + vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => undefined); + expect(isValidImageFile("https://example.com/invalid-url")).toBe(false); + }); + + test("should return false when file has no extension", () => { + vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce( + () => "image-without-extension" + ); + expect(isValidImageFile("https://example.com/image-without-extension")).toBe(false); + }); + + test("should return false when file name ends with a dot", () => { + vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => "image."); + expect(isValidImageFile("https://example.com/image.")).toBe(false); + }); + + test("should handle case insensitivity correctly", () => { + vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => "image.JPG"); + expect(isValidImageFile("https://example.com/image.JPG")).toBe(true); + }); + }); +}); diff --git a/apps/web/lib/fileValidation.ts b/apps/web/lib/fileValidation.ts new file mode 100644 index 000000000000..f19fdf98bb28 --- /dev/null +++ b/apps/web/lib/fileValidation.ts @@ -0,0 +1,95 @@ +import { getOriginalFileNameFromUrl } from "@/lib/storage/utils"; +import { TAllowedFileExtension, ZAllowedFileExtension, mimeTypes } from "@formbricks/types/common"; +import { TResponseData } from "@formbricks/types/responses"; +import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; + +/** + * Validates if the file extension is allowed + * @param fileName The name of the file to validate + * @returns {boolean} True if the file extension is allowed, false otherwise + */ +export const isAllowedFileExtension = (fileName: string): boolean => { + // Extract the file extension + const extension = fileName.split(".").pop()?.toLowerCase(); + if (!extension || extension === fileName.toLowerCase()) return false; + + // Check if the extension is in the allowed list + return Object.values(ZAllowedFileExtension.enum).includes(extension as TAllowedFileExtension); +}; + +/** + * Validates if the file type matches the extension + * @param fileName The name of the file + * @param mimeType The MIME type of the file + * @returns {boolean} True if the file type matches the extension, false otherwise + */ +export const isValidFileTypeForExtension = (fileName: string, mimeType: string): boolean => { + const extension = fileName.split(".").pop()?.toLowerCase(); + if (!extension || extension === fileName.toLowerCase()) return false; + + // Basic MIME type validation for common file types + const mimeTypeLower = mimeType.toLowerCase(); + + // Check if the MIME type matches the expected type for this extension + return mimeTypes[extension] === mimeTypeLower; +}; + +/** + * Validates a file for security concerns + * @param fileName The name of the file to validate + * @param mimeType The MIME type of the file + * @returns {object} An object with validation result and error message if any + */ +export const validateFile = (fileName: string, mimeType: string): { valid: boolean; error?: string } => { + // Check for disallowed extensions + if (!isAllowedFileExtension(fileName)) { + return { valid: false, error: "File type not allowed for security reasons." }; + } + + // Check if the file type matches the extension + if (!isValidFileTypeForExtension(fileName, mimeType)) { + return { valid: false, error: "File type doesn't match the file extension." }; + } + + return { valid: true }; +}; + +export const validateSingleFile = ( + fileUrl: string, + allowedFileExtensions?: TAllowedFileExtension[] +): boolean => { + const fileName = getOriginalFileNameFromUrl(fileUrl); + if (!fileName) return false; + const extension = fileName.split(".").pop(); + if (!extension) return false; + return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension); +}; + +export const validateFileUploads = (data?: TResponseData, questions?: TSurveyQuestion[]): boolean => { + if (!data) return true; + for (const key of Object.keys(data)) { + const question = questions?.find((q) => q.id === key); + if (!question || question.type !== TSurveyQuestionTypeEnum.FileUpload) continue; + + const fileUrls = data[key]; + + if (!Array.isArray(fileUrls) || !fileUrls.every((url) => typeof url === "string")) return false; + + for (const fileUrl of fileUrls) { + if (!validateSingleFile(fileUrl, question.allowedFileExtensions)) return false; + } + } + + return true; +}; + +export const isValidImageFile = (fileUrl: string): boolean => { + const fileName = getOriginalFileNameFromUrl(fileUrl); + if (!fileName || fileName.endsWith(".")) return false; + + const extension = fileName.split(".").pop()?.toLowerCase(); + if (!extension) return false; + + const imageExtensions = ["png", "jpeg", "jpg", "webp", "heic"]; + return imageExtensions.includes(extension); +}; diff --git a/apps/web/lib/getPublicUrl.test.ts b/apps/web/lib/getPublicUrl.test.ts new file mode 100644 index 000000000000..435441bb0e7e --- /dev/null +++ b/apps/web/lib/getPublicUrl.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock constants module +const envMock = { + env: { + WEBAPP_URL: "http://localhost:3000", + PUBLIC_URL: undefined as string | undefined, + }, +}; + +vi.mock("@/lib/env", () => envMock); + +describe("getPublicDomain", () => { + beforeEach(() => { + vi.resetModules(); + }); + + test("should return WEBAPP_URL when PUBLIC_URL is not set", async () => { + const { getPublicDomain } = await import("./getPublicUrl"); + const domain = getPublicDomain(); + expect(domain).toBe("http://localhost:3000"); + }); + + test("should return PUBLIC_URL when it is set", async () => { + envMock.env.PUBLIC_URL = "https://surveys.example.com"; + const { getPublicDomain } = await import("./getPublicUrl"); + const domain = getPublicDomain(); + expect(domain).toBe("https://surveys.example.com"); + }); + + test("should handle empty string PUBLIC_URL by returning WEBAPP_URL", async () => { + envMock.env.PUBLIC_URL = ""; + const { getPublicDomain } = await import("./getPublicUrl"); + const domain = getPublicDomain(); + expect(domain).toBe("http://localhost:3000"); + }); + + test("should handle undefined PUBLIC_URL by returning WEBAPP_URL", async () => { + envMock.env.PUBLIC_URL = undefined; + const { getPublicDomain } = await import("./getPublicUrl"); + const domain = getPublicDomain(); + expect(domain).toBe("http://localhost:3000"); + }); +}); diff --git a/apps/web/lib/getPublicUrl.ts b/apps/web/lib/getPublicUrl.ts new file mode 100644 index 000000000000..aa2270c26a79 --- /dev/null +++ b/apps/web/lib/getPublicUrl.ts @@ -0,0 +1,13 @@ +import "server-only"; +import { env } from "./env"; + +const WEBAPP_URL = + env.WEBAPP_URL ?? (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : "") ?? "http://localhost:3000"; + +/** + * Returns the public domain URL + * Uses PUBLIC_URL if set, otherwise falls back to WEBAPP_URL + */ +export const getPublicDomain = (): string => { + return env.PUBLIC_URL && env.PUBLIC_URL.trim() !== "" ? env.PUBLIC_URL : WEBAPP_URL; +}; diff --git a/apps/web/lib/googleSheet/service.ts b/apps/web/lib/googleSheet/service.ts new file mode 100644 index 000000000000..b927e6f13404 --- /dev/null +++ b/apps/web/lib/googleSheet/service.ts @@ -0,0 +1,129 @@ +import "server-only"; +import { + GOOGLE_SHEETS_CLIENT_ID, + GOOGLE_SHEETS_CLIENT_SECRET, + GOOGLE_SHEETS_REDIRECT_URL, +} from "@/lib/constants"; +import { GOOGLE_SHEET_MESSAGE_LIMIT } from "@/lib/constants"; +import { createOrUpdateIntegration } from "@/lib/integration/service"; +import { Prisma } from "@prisma/client"; +import { z } from "zod"; +import { ZString } from "@formbricks/types/common"; +import { DatabaseError, UnknownError } from "@formbricks/types/errors"; +import { + TIntegrationGoogleSheets, + ZIntegrationGoogleSheets, +} from "@formbricks/types/integration/google-sheet"; +import { truncateText } from "../utils/strings"; +import { validateInputs } from "../utils/validate"; + +const { google } = require("googleapis"); + +export const writeData = async ( + integrationData: TIntegrationGoogleSheets, + spreadsheetId: string, + values: string[][] +) => { + validateInputs( + [integrationData, ZIntegrationGoogleSheets], + [spreadsheetId, ZString], + [values, z.array(z.array(ZString))] + ); + + try { + const authClient = await authorize(integrationData); + const sheets = google.sheets({ version: "v4", auth: authClient }); + const responses = { + values: [ + values[0].map((value) => + value.length > GOOGLE_SHEET_MESSAGE_LIMIT ? truncateText(value, GOOGLE_SHEET_MESSAGE_LIMIT) : value + ), + ], + }; + const question = { values: [values[1]] }; + sheets.spreadsheets.values.update( + { + spreadsheetId: spreadsheetId, + range: "A1", + valueInputOption: "RAW", + resource: question, + }, + (err: Error) => { + if (err) { + throw new UnknownError(`Error while appending data: ${err.message}`); + } + } + ); + + sheets.spreadsheets.values.append( + { + spreadsheetId: spreadsheetId, + range: "A2", + valueInputOption: "RAW", + resource: responses, + }, + (err: Error) => { + if (err) { + throw new UnknownError(`Error while appending data: ${err.message}`); + } + } + ); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const getSpreadsheetNameById = async ( + googleSheetIntegrationData: TIntegrationGoogleSheets, + spreadsheetId: string +): Promise => { + validateInputs([googleSheetIntegrationData, ZIntegrationGoogleSheets]); + + try { + const authClient = await authorize(googleSheetIntegrationData); + const sheets = google.sheets({ version: "v4", auth: authClient }); + + return new Promise((resolve, reject) => { + sheets.spreadsheets.get({ spreadsheetId }, (err, response) => { + if (err) { + reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`)); + return; + } + const spreadsheetTitle = response.data.properties.title; + resolve(spreadsheetTitle); + }); + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}; + +const authorize = async (googleSheetIntegrationData: TIntegrationGoogleSheets) => { + const client_id = GOOGLE_SHEETS_CLIENT_ID; + const client_secret = GOOGLE_SHEETS_CLIENT_SECRET; + const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL; + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri); + const refresh_token = googleSheetIntegrationData.config.key.refresh_token; + oAuth2Client.setCredentials({ + refresh_token, + }); + const { credentials } = await oAuth2Client.refreshAccessToken(); + await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, { + type: "googleSheets", + config: { + data: googleSheetIntegrationData.config?.data ?? [], + email: googleSheetIntegrationData.config?.email ?? "", + key: credentials, + }, + }); + + oAuth2Client.setCredentials(credentials); + + return oAuth2Client; +}; diff --git a/apps/web/lib/hash-string.test.ts b/apps/web/lib/hash-string.test.ts new file mode 100644 index 000000000000..ff7e6566be56 --- /dev/null +++ b/apps/web/lib/hash-string.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; +import { hashString } from "./hash-string"; + +describe("hashString", () => { + test("should return a string", () => { + const input = "test string"; + const hash = hashString(input); + + expect(typeof hash).toBe("string"); + expect(hash.length).toBeGreaterThan(0); + }); + + test("should produce consistent hashes for the same input", () => { + const input = "test string"; + const hash1 = hashString(input); + const hash2 = hashString(input); + + expect(hash1).toBe(hash2); + }); + + test("should handle empty strings", () => { + const hash = hashString(""); + + expect(typeof hash).toBe("string"); + expect(hash.length).toBeGreaterThan(0); + }); + + test("should handle special characters", () => { + const input = "!@#$%^&*()_+{}|:<>?"; + const hash = hashString(input); + + expect(typeof hash).toBe("string"); + expect(hash.length).toBeGreaterThan(0); + }); + + test("should handle unicode characters", () => { + const input = "Hello, 世界!"; + const hash = hashString(input); + + expect(typeof hash).toBe("string"); + expect(hash.length).toBeGreaterThan(0); + }); + + test("should handle long strings", () => { + const input = "a".repeat(1000); + const hash = hashString(input); + + expect(typeof hash).toBe("string"); + expect(hash.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/lib/hashString.ts b/apps/web/lib/hash-string.ts similarity index 100% rename from packages/lib/hashString.ts rename to apps/web/lib/hash-string.ts diff --git a/apps/web/lib/i18n/constants.ts b/apps/web/lib/i18n/constants.ts new file mode 100644 index 000000000000..3d7ed3bc41fb --- /dev/null +++ b/apps/web/lib/i18n/constants.ts @@ -0,0 +1,2 @@ +export const INVISIBLE_CHARACTERS = ["\u200C", "\u200D"]; +export const INVISIBLE_REGEX = RegExp(`([${INVISIBLE_CHARACTERS.join("")}]{9})+`, "gu"); diff --git a/packages/lib/i18n/i18n.mock.ts b/apps/web/lib/i18n/i18n.mock.ts similarity index 98% rename from packages/lib/i18n/i18n.mock.ts rename to apps/web/lib/i18n/i18n.mock.ts index ef813b5e1876..758014f39f5b 100644 --- a/packages/lib/i18n/i18n.mock.ts +++ b/apps/web/lib/i18n/i18n.mock.ts @@ -1,4 +1,4 @@ -import { mockSurveyLanguages } from "survey/tests/__mock__/survey.mock"; +import { mockSurveyLanguages } from "@/lib/survey/__mock__/survey.mock"; import { TSurvey, TSurveyCTAQuestion, @@ -44,6 +44,11 @@ export const mockOpenTextQuestion: TSurveyOpenTextQuestion = { placeholder: { default: "Type your answer here...", }, + charLimit: { + min: 0, + max: 1000, + enabled: true, + }, }; export const mockSingleSelectQuestion: TSurveyMultipleChoiceQuestion = { @@ -304,13 +309,13 @@ export const mockSurvey: TSurvey = { isVerifyEmailEnabled: false, projectOverwrites: null, styling: null, + recaptcha: null, surveyClosedMessage: null, singleUse: { enabled: false, isEncrypted: true, }, pin: null, - resultShareKey: null, triggers: [], languages: mockSurveyLanguages, segment: null, diff --git a/apps/web/lib/i18n/i18n.test.ts b/apps/web/lib/i18n/i18n.test.ts new file mode 100644 index 000000000000..3ea1f4677978 --- /dev/null +++ b/apps/web/lib/i18n/i18n.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; +import { createI18nString } from "./utils"; + +describe("createI18nString", () => { + test("should create an i18n string from a regular string", () => { + const result = createI18nString("Hello", ["default"]); + expect(result).toEqual({ default: "Hello" }); + }); + + test("should create a new i18n string with i18n enabled from a previous i18n string", () => { + const result = createI18nString({ default: "Hello" }, ["default", "es"]); + expect(result).toEqual({ default: "Hello", es: "" }); + }); + + test("should add a new field key value pair when a new language is added", () => { + const i18nObject = { default: "Hello", es: "Hola" }; + const newLanguages = ["default", "es", "de"]; + const result = createI18nString(i18nObject, newLanguages); + expect(result).toEqual({ + default: "Hello", + es: "Hola", + de: "", + }); + }); + + test("should remove the translation that are not present in newLanguages", () => { + const i18nObject = { default: "Hello", es: "hola" }; + const newLanguages = ["default"]; + const result = createI18nString(i18nObject, newLanguages); + expect(result).toEqual({ + default: "Hello", + }); + }); +}); diff --git a/apps/web/lib/i18n/utils.ts b/apps/web/lib/i18n/utils.ts new file mode 100644 index 000000000000..f28c4abcf354 --- /dev/null +++ b/apps/web/lib/i18n/utils.ts @@ -0,0 +1,219 @@ +import { INVISIBLE_REGEX } from "@/lib/i18n/constants"; +import { structuredClone } from "@/lib/pollyfills/structuredClone"; +import { iso639Languages } from "@formbricks/i18n-utils/src/utils"; +import { TLanguage } from "@formbricks/types/project"; +import { TI18nString, TSurveyLanguage } from "@formbricks/types/surveys/types"; + +// https://github.com/tolgee/tolgee-js/blob/main/packages/web/src/package/observers/invisible/secret.ts +const removeTolgeeInvisibleMarks = (str: string) => { + return str.replace(INVISIBLE_REGEX, ""); +}; + +// Helper function to create an i18nString from a regular string. +export const createI18nString = ( + text: string | TI18nString, + languages: string[], + targetLanguageCode?: string +): TI18nString => { + if (typeof text === "object") { + // It's already an i18n object, so clone it + const i18nString: TI18nString = structuredClone(text); + // Add new language keys with empty strings if they don't exist + languages?.forEach((language) => { + if (!(language in i18nString)) { + i18nString[language] = ""; + } + }); + + // Remove language keys that are not in the languages array + Object.keys(i18nString).forEach((key) => { + if (key !== (targetLanguageCode ?? "default") && languages && !languages.includes(key)) { + delete i18nString[key]; + } + }); + + return i18nString; + } else { + // It's a regular string, so create a new i18n object + const i18nString: any = { + [targetLanguageCode ?? "default"]: removeTolgeeInvisibleMarks(text), + }; + + // Initialize all provided languages with empty strings + languages?.forEach((language) => { + if (language !== (targetLanguageCode ?? "default")) { + i18nString[language] = ""; + } + }); + + return i18nString; + } +}; + +// Type guard to check if an object is an I18nString +export const isI18nObject = (obj: any): obj is TI18nString => { + return typeof obj === "object" && obj !== null && Object.keys(obj).includes("default"); +}; + +export const isLabelValidForAllLanguages = (label: TI18nString, languages: string[]): boolean => { + return languages.every((language) => label[language] && label[language].trim() !== ""); +}; + +export const getLocalizedValue = (value: TI18nString | undefined, languageId: string): string => { + if (!value) { + return ""; + } + if (isI18nObject(value)) { + if (value[languageId]) { + return value[languageId]; + } + return ""; + } + return ""; +}; + +export const extractLanguageCodes = (surveyLanguages: TSurveyLanguage[]): string[] => { + if (!surveyLanguages) return []; + return surveyLanguages.map((surveyLanguage) => + surveyLanguage.default ? "default" : surveyLanguage.language.code + ); +}; + +export const getEnabledLanguages = (surveyLanguages: TSurveyLanguage[]) => { + return surveyLanguages.filter((surveyLanguage) => surveyLanguage.enabled); +}; + +export const extractLanguageIds = (languages: TLanguage[]): string[] => { + return languages.map((language) => language.code); +}; + +export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => { + if (!surveyLanguages?.length || !languageCode) return "default"; + const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode); + return language?.default ? "default" : language?.language.code || "default"; +}; + +export const iso639Identifiers = iso639Languages.map((language) => language.alpha2); + +// Helper function to add language keys to a multi-language object (e.g. survey or question) +// Iterates over the object recursively and adds empty strings for new language keys +export const addMultiLanguageLabels = (object: any, languageSymbols: string[]): any => { + // Helper function to add language keys to a multi-language object + function addLanguageKeys(obj: { default: string; [key: string]: string }) { + languageSymbols.forEach((lang) => { + if (!obj.hasOwnProperty(lang)) { + obj[lang] = ""; // Add empty string for new language keys + } + }); + } + + // Recursive function to process an object or array + function processObject(obj: any) { + if (Array.isArray(obj)) { + obj.forEach((item) => processObject(item)); + } else if (obj && typeof obj === "object") { + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + if (key === "default" && typeof obj[key] === "string") { + addLanguageKeys(obj); + } else { + processObject(obj[key]); + } + } + } + } + } + + // Start processing the question object + processObject(object); + + return object; +}; + +export const appLanguages = [ + { + code: "en-US", + label: { + "en-US": "English (US)", + "de-DE": "Englisch (US)", + "pt-BR": "Inglês (EUA)", + "fr-FR": "Anglais (États-Unis)", + "zh-Hant-TW": "英文 (美國)", + "pt-PT": "Inglês (EUA)", + "ro-RO": "Engleză (SUA)", + }, + }, + { + code: "de-DE", + label: { + "en-US": "German", + "de-DE": "Deutsch", + "pt-BR": "Alemão", + "fr-FR": "Allemand", + "zh-Hant-TW": "德語", + "pt-PT": "Alemão", + "ro-RO": "Germană", + }, + }, + { + code: "pt-BR", + label: { + "en-US": "Portuguese (Brazil)", + "de-DE": "Portugiesisch (Brasilien)", + "pt-BR": "Português (Brasil)", + "fr-FR": "Portugais (Brésil)", + "zh-Hant-TW": "葡萄牙語 (巴西)", + "pt-PT": "Português (Brasil)", + "ro-RO": "Portugheză (Brazilia)", + }, + }, + { + code: "fr-FR", + label: { + "en-US": "French", + "de-DE": "Französisch", + "pt-BR": "Francês", + "fr-FR": "Français", + "zh-Hant-TW": "法語", + "pt-PT": "Francês", + "ro-RO": "Franceză", + }, + }, + { + code: "zh-Hant-TW", + label: { + "en-US": "Chinese (Traditional)", + "de-DE": "Chinesisch (Traditionell)", + "pt-BR": "Chinês (Tradicional)", + "fr-FR": "Chinois (Traditionnel)", + "zh-Hant-TW": "繁體中文", + "pt-PT": "Chinês (Tradicional)", + "ro-RO": "Chineză (Tradicională)", + }, + }, + { + code: "pt-PT", + label: { + "en-US": "Portuguese (Portugal)", + "de-DE": "Portugiesisch (Portugal)", + "pt-BR": "Português (Portugal)", + "fr-FR": "Portugais (Portugal)", + "zh-Hant-TW": "葡萄牙語 (葡萄牙)", + "pt-PT": "Português (Portugal)", + "ro-RO": "Portugheză (Portugalia)", + }, + }, + { + code: "ro-RO", + label: { + "en-US": "Romanian", + "de-DE": "Rumänisch", + "pt-BR": "Romeno", + "fr-FR": "Roumain", + "zh-Hant-TW": "羅馬尼亞語", + "pt-PT": "Romeno", + "ro-RO": "Română", + }, + }, +]; +export { iso639Languages }; diff --git a/apps/web/lib/instance/service.ts b/apps/web/lib/instance/service.ts new file mode 100644 index 000000000000..2b73b179cb0e --- /dev/null +++ b/apps/web/lib/instance/service.ts @@ -0,0 +1,32 @@ +import "server-only"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; + +// Function to check if there are any users in the database +export const getIsFreshInstance = reactCache(async (): Promise => { + try { + const userCount = await prisma.user.count(); + if (userCount === 0) return true; + else return false; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}); + +// Function to check if there are any organizations in the database +export const gethasNoOrganizations = reactCache(async (): Promise => { + try { + const organizationCount = await prisma.organization.count(); + return organizationCount === 0; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}); diff --git a/apps/web/lib/integration/service.test.ts b/apps/web/lib/integration/service.test.ts new file mode 100644 index 000000000000..1b1d1fed324c --- /dev/null +++ b/apps/web/lib/integration/service.test.ts @@ -0,0 +1,291 @@ +import { IntegrationType, Prisma } from "@prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TIntegrationInput } from "@formbricks/types/integration"; +import { ITEMS_PER_PAGE } from "../constants"; +import { + createOrUpdateIntegration, + deleteIntegration, + getIntegration, + getIntegrationByType, + getIntegrations, +} from "./service"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + integration: { + upsert: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +describe("Integration Service", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const mockIntegrationConfig = { + email: "test@example.com", + key: { + scope: "https://www.googleapis.com/auth/spreadsheets", + token_type: "Bearer" as const, + expiry_date: 1234567890, + access_token: "mock-access-token", + refresh_token: "mock-refresh-token", + }, + data: [ + { + spreadsheetId: "spreadsheet123", + spreadsheetName: "Test Spreadsheet", + surveyId: "survey123", + surveyName: "Test Survey", + questionIds: ["q1", "q2"], + questions: "Question 1, Question 2", + createdAt: new Date(), + includeHiddenFields: false, + includeMetadata: true, + includeCreatedAt: true, + includeVariables: false, + }, + ], + }; + + describe("createOrUpdateIntegration", () => { + const mockEnvironmentId = "clg123456789012345678901234"; + const mockIntegrationData: TIntegrationInput = { + type: "googleSheets", + config: mockIntegrationConfig, + }; + + test("should create a new integration", async () => { + const mockIntegration = { + id: "int_123", + environmentId: mockEnvironmentId, + ...mockIntegrationData, + }; + + vi.mocked(prisma.integration.upsert).mockResolvedValue(mockIntegration); + + const result = await createOrUpdateIntegration(mockEnvironmentId, mockIntegrationData); + + expect(prisma.integration.upsert).toHaveBeenCalledWith({ + where: { + type_environmentId: { + environmentId: mockEnvironmentId, + type: mockIntegrationData.type, + }, + }, + update: { + ...mockIntegrationData, + environment: { connect: { id: mockEnvironmentId } }, + }, + create: { + ...mockIntegrationData, + environment: { connect: { id: mockEnvironmentId } }, + }, + }); + + expect(result).toEqual(mockIntegration); + }); + + test("should throw DatabaseError when Prisma throws an error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", { + code: "P2002", + clientVersion: "5.0.0", + }); + + vi.mocked(prisma.integration.upsert).mockRejectedValue(prismaError); + + await expect(createOrUpdateIntegration(mockEnvironmentId, mockIntegrationData)).rejects.toThrow( + DatabaseError + ); + }); + }); + + describe("getIntegrations", () => { + const mockEnvironmentId = "clg123456789012345678901234"; + const mockIntegrations = [ + { + id: "int_123", + environmentId: mockEnvironmentId, + type: IntegrationType.googleSheets, + config: mockIntegrationConfig, + }, + ]; + + test("should get all integrations for an environment", async () => { + vi.mocked(prisma.integration.findMany).mockResolvedValue(mockIntegrations); + + const result = await getIntegrations(mockEnvironmentId); + + expect(prisma.integration.findMany).toHaveBeenCalledWith({ + where: { + environmentId: mockEnvironmentId, + }, + }); + + expect(result).toEqual(mockIntegrations); + }); + + test("should get paginated integrations", async () => { + const page = 2; + vi.mocked(prisma.integration.findMany).mockResolvedValue(mockIntegrations); + + const result = await getIntegrations(mockEnvironmentId, page); + + expect(prisma.integration.findMany).toHaveBeenCalledWith({ + where: { + environmentId: mockEnvironmentId, + }, + take: ITEMS_PER_PAGE, + skip: ITEMS_PER_PAGE * (page - 1), + }); + + expect(result).toEqual(mockIntegrations); + }); + + test("should throw DatabaseError when Prisma throws an error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", { + code: "P2002", + clientVersion: "5.0.0", + }); + + vi.mocked(prisma.integration.findMany).mockRejectedValue(prismaError); + + await expect(getIntegrations(mockEnvironmentId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getIntegration", () => { + const mockIntegrationId = "int_123"; + const mockIntegration = { + id: mockIntegrationId, + environmentId: "clg123456789012345678901234", + type: IntegrationType.googleSheets, + config: mockIntegrationConfig, + }; + + test("should get an integration by ID", async () => { + vi.mocked(prisma.integration.findUnique).mockResolvedValue(mockIntegration); + + const result = await getIntegration(mockIntegrationId); + + expect(prisma.integration.findUnique).toHaveBeenCalledWith({ + where: { + id: mockIntegrationId, + }, + }); + + expect(result).toEqual(mockIntegration); + }); + + test("should return null when integration is not found", async () => { + vi.mocked(prisma.integration.findUnique).mockResolvedValue(null); + + const result = await getIntegration(mockIntegrationId); + + expect(result).toBeNull(); + }); + + test("should throw DatabaseError when Prisma throws an error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", { + code: "P2002", + clientVersion: "5.0.0", + }); + + vi.mocked(prisma.integration.findUnique).mockRejectedValue(prismaError); + + await expect(getIntegration(mockIntegrationId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getIntegrationByType", () => { + const mockEnvironmentId = "clg123456789012345678901234"; + const mockType = IntegrationType.googleSheets; + const mockIntegration = { + id: "int_123", + environmentId: mockEnvironmentId, + type: mockType, + config: mockIntegrationConfig, + }; + + test("should get an integration by type", async () => { + vi.mocked(prisma.integration.findUnique).mockResolvedValue(mockIntegration); + + const result = await getIntegrationByType(mockEnvironmentId, mockType); + + expect(prisma.integration.findUnique).toHaveBeenCalledWith({ + where: { + type_environmentId: { + environmentId: mockEnvironmentId, + type: mockType, + }, + }, + }); + + expect(result).toEqual(mockIntegration); + }); + + test("should return null when integration is not found", async () => { + vi.mocked(prisma.integration.findUnique).mockResolvedValue(null); + + const result = await getIntegrationByType(mockEnvironmentId, mockType); + + expect(result).toBeNull(); + }); + + test("should throw DatabaseError when Prisma throws an error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", { + code: "P2002", + clientVersion: "5.0.0", + }); + + vi.mocked(prisma.integration.findUnique).mockRejectedValue(prismaError); + + await expect(getIntegrationByType(mockEnvironmentId, mockType)).rejects.toThrow(DatabaseError); + }); + }); + + describe("deleteIntegration", () => { + const mockIntegrationId = "int_123"; + const mockIntegration = { + id: mockIntegrationId, + environmentId: "clg123456789012345678901234", + type: IntegrationType.googleSheets, + config: mockIntegrationConfig, + }; + + test("should delete an integration", async () => { + vi.mocked(prisma.integration.delete).mockResolvedValue(mockIntegration); + + const result = await deleteIntegration(mockIntegrationId); + + expect(prisma.integration.delete).toHaveBeenCalledWith({ + where: { + id: mockIntegrationId, + }, + }); + + expect(result).toEqual(mockIntegration); + }); + + test("should throw DatabaseError when Prisma throws an error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", { + code: "P2002", + clientVersion: "5.0.0", + }); + + vi.mocked(prisma.integration.delete).mockRejectedValue(prismaError); + + await expect(deleteIntegration(mockIntegrationId)).rejects.toThrow(DatabaseError); + }); + }); +}); diff --git a/apps/web/lib/integration/service.ts b/apps/web/lib/integration/service.ts new file mode 100644 index 000000000000..6102d24dfeed --- /dev/null +++ b/apps/web/lib/integration/service.ts @@ -0,0 +1,137 @@ +import "server-only"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration"; +import { ITEMS_PER_PAGE } from "../constants"; +import { validateInputs } from "../utils/validate"; + +const transformIntegration = (integration: TIntegration): TIntegration => { + return { + ...integration, + config: { + ...integration.config, + data: integration.config.data.map((data) => ({ + ...data, + createdAt: new Date(data.createdAt), + })), + }, + }; +}; + +export const createOrUpdateIntegration = async ( + environmentId: string, + integrationData: TIntegrationInput +): Promise => { + validateInputs([environmentId, ZId]); + + try { + const integration = await prisma.integration.upsert({ + where: { + type_environmentId: { + environmentId, + type: integrationData.type, + }, + }, + update: { + ...integrationData, + environment: { connect: { id: environmentId } }, + }, + create: { + ...integrationData, + environment: { connect: { id: environmentId } }, + }, + }); + return integration; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error creating or updating integration"); + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const getIntegrations = reactCache( + async (environmentId: string, page?: number): Promise => { + validateInputs([environmentId, ZId], [page, ZOptionalNumber]); + + try { + const integrations = await prisma.integration.findMany({ + where: { + environmentId, + }, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + }); + return integrations.map((integration) => transformIntegration(integration)); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } + } +); + +export const getIntegration = reactCache(async (integrationId: string): Promise => { + try { + const integration = await prisma.integration.findUnique({ + where: { + id: integrationId, + }, + }); + return integration ? transformIntegration(integration) : null; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}); + +export const getIntegrationByType = reactCache( + async (environmentId: string, type: TIntegrationInput["type"]): Promise => { + validateInputs([environmentId, ZId], [type, ZIntegrationType]); + + try { + const integration = await prisma.integration.findUnique({ + where: { + type_environmentId: { + environmentId, + type, + }, + }, + }); + return integration ? transformIntegration(integration) : null; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } + } +); + +export const deleteIntegration = async (integrationId: string): Promise => { + validateInputs([integrationId, ZString]); + + try { + const integrationData = await prisma.integration.delete({ + where: { + id: integrationId, + }, + }); + + return integrationData; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; diff --git a/apps/web/lib/jwt.test.ts b/apps/web/lib/jwt.test.ts new file mode 100644 index 000000000000..de2a4b5a490b --- /dev/null +++ b/apps/web/lib/jwt.test.ts @@ -0,0 +1,171 @@ +import { env } from "@/lib/env"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { + createEmailChangeToken, + createEmailToken, + createInviteToken, + createToken, + createTokenForLinkSurvey, + getEmailFromEmailToken, + verifyEmailChangeToken, + verifyInviteToken, + verifyToken, + verifyTokenForLinkSurvey, +} from "./jwt"; + +// Mock environment variables +vi.mock("@/lib/env", () => ({ + env: { + ENCRYPTION_KEY: "0".repeat(32), // 32-byte key for AES-256-GCM + NEXTAUTH_SECRET: "test-nextauth-secret", + } as typeof env, +})); + +// Mock prisma +vi.mock("@formbricks/database", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + }, + }, +})); + +describe("JWT Functions", () => { + const mockUser = { + id: "test-user-id", + email: "test@example.com", + }; + + beforeEach(() => { + vi.clearAllMocks(); + (prisma.user.findUnique as any).mockResolvedValue(mockUser); + }); + + describe("createToken", () => { + test("should create a valid token", () => { + const token = createToken(mockUser.id, mockUser.email); + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + }); + }); + + describe("createTokenForLinkSurvey", () => { + test("should create a valid survey link token", () => { + const surveyId = "test-survey-id"; + const token = createTokenForLinkSurvey(surveyId, mockUser.email); + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + }); + }); + + describe("createEmailToken", () => { + test("should create a valid email token", () => { + const token = createEmailToken(mockUser.email); + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + }); + + test("should throw error if NEXTAUTH_SECRET is not set", () => { + const originalSecret = env.NEXTAUTH_SECRET; + try { + (env as any).NEXTAUTH_SECRET = undefined; + expect(() => createEmailToken(mockUser.email)).toThrow("NEXTAUTH_SECRET is not set"); + } finally { + (env as any).NEXTAUTH_SECRET = originalSecret; + } + }); + }); + + describe("getEmailFromEmailToken", () => { + test("should extract email from valid token", () => { + const token = createEmailToken(mockUser.email); + const extractedEmail = getEmailFromEmailToken(token); + expect(extractedEmail).toBe(mockUser.email); + }); + }); + + describe("createInviteToken", () => { + test("should create a valid invite token", () => { + const inviteId = "test-invite-id"; + const token = createInviteToken(inviteId, mockUser.email); + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + }); + }); + + describe("verifyTokenForLinkSurvey", () => { + test("should verify valid survey link token", () => { + const surveyId = "test-survey-id"; + const token = createTokenForLinkSurvey(surveyId, mockUser.email); + const verifiedEmail = verifyTokenForLinkSurvey(token, surveyId); + expect(verifiedEmail).toBe(mockUser.email); + }); + + test("should return null for invalid token", () => { + const result = verifyTokenForLinkSurvey("invalid-token", "test-survey-id"); + expect(result).toBeNull(); + }); + }); + + describe("verifyToken", () => { + test("should verify valid token", async () => { + const token = createToken(mockUser.id, mockUser.email); + const verified = await verifyToken(token); + expect(verified).toEqual({ + id: mockUser.id, + email: mockUser.email, + }); + }); + + test("should throw error if user not found", async () => { + (prisma.user.findUnique as any).mockResolvedValue(null); + const token = createToken(mockUser.id, mockUser.email); + await expect(verifyToken(token)).rejects.toThrow("User not found"); + }); + }); + + describe("verifyInviteToken", () => { + test("should verify valid invite token", () => { + const inviteId = "test-invite-id"; + const token = createInviteToken(inviteId, mockUser.email); + const verified = verifyInviteToken(token); + expect(verified).toEqual({ + inviteId, + email: mockUser.email, + }); + }); + + test("should throw error for invalid token", () => { + expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token"); + }); + }); + + describe("verifyEmailChangeToken", () => { + test("should verify and decrypt valid email change token", async () => { + const userId = "test-user-id"; + const email = "test@example.com"; + const token = createEmailChangeToken(userId, email); + const result = await verifyEmailChangeToken(token); + expect(result).toEqual({ id: userId, email }); + }); + + test("should throw error if token is invalid or missing fields", async () => { + // Create a token with missing fields + const jwt = await import("jsonwebtoken"); + const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string); + await expect(verifyEmailChangeToken(token)).rejects.toThrow( + "Token is invalid or missing required fields" + ); + }); + + test("should return original id/email if decryption fails", async () => { + // Create a token with non-encrypted id/email + const jwt = await import("jsonwebtoken"); + const payload = { id: "plain-id", email: "plain@example.com" }; + const token = jwt.sign(payload, env.NEXTAUTH_SECRET as string); + const result = await verifyEmailChangeToken(token); + expect(result).toEqual(payload); + }); + }); +}); diff --git a/packages/lib/jwt.ts b/apps/web/lib/jwt.ts similarity index 77% rename from packages/lib/jwt.ts rename to apps/web/lib/jwt.ts index 07af577b132a..88095db6bcc9 100644 --- a/packages/lib/jwt.ts +++ b/apps/web/lib/jwt.ts @@ -1,31 +1,64 @@ +import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto"; +import { env } from "@/lib/env"; import jwt, { JwtPayload } from "jsonwebtoken"; import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; -import { symmetricDecrypt, symmetricEncrypt } from "./crypto"; -import { env } from "./env"; export const createToken = (userId: string, userEmail: string, options = {}): string => { - if (!env.ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); - } - const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY); return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options); }; export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => { - if (!env.ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); - } - const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY); return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId); }; -export const createEmailToken = (email: string): string => { - if (!env.ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); +export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => { + if (!env.NEXTAUTH_SECRET) { + throw new Error("NEXTAUTH_SECRET is not set"); + } + + const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as { id: string; email: string }; + + if (!payload?.id || !payload?.email) { + throw new Error("Token is invalid or missing required fields"); + } + + let decryptedId: string; + let decryptedEmail: string; + + try { + decryptedId = symmetricDecrypt(payload.id, env.ENCRYPTION_KEY); + } catch { + decryptedId = payload.id; + } + + try { + decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY); + } catch { + decryptedEmail = payload.email; } + return { + id: decryptedId, + email: decryptedEmail, + }; +}; + +export const createEmailChangeToken = (userId: string, email: string): string => { + const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY); + const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY); + + const payload = { + id: encryptedUserId, + email: encryptedEmail, + }; + + return jwt.sign(payload, env.NEXTAUTH_SECRET as string, { + expiresIn: "1d", + }); +}; +export const createEmailToken = (email: string): string => { if (!env.NEXTAUTH_SECRET) { throw new Error("NEXTAUTH_SECRET is not set"); } @@ -35,10 +68,6 @@ export const createEmailToken = (email: string): string => { }; export const getEmailFromEmailToken = (token: string): string => { - if (!env.ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); - } - if (!env.NEXTAUTH_SECRET) { throw new Error("NEXTAUTH_SECRET is not set"); } @@ -55,10 +84,6 @@ export const getEmailFromEmailToken = (token: string): string => { }; export const createInviteToken = (inviteId: string, email: string, options = {}): string => { - if (!env.ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); - } - if (!env.NEXTAUTH_SECRET) { throw new Error("NEXTAUTH_SECRET is not set"); } @@ -87,9 +112,6 @@ export const verifyTokenForLinkSurvey = (token: string, surveyId: string): strin }; export const verifyToken = async (token: string): Promise => { - if (!env.ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); - } // First decode to get the ID const decoded = jwt.decode(token); const payload: JwtPayload = decoded as JwtPayload; @@ -127,10 +149,6 @@ export const verifyToken = async (token: string): Promise => { export const verifyInviteToken = (token: string): { inviteId: string; email: string } => { try { - if (!env.ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); - } - const decoded = jwt.decode(token); const payload: JwtPayload = decoded as JwtPayload; diff --git a/apps/web/lib/language/service.ts b/apps/web/lib/language/service.ts new file mode 100644 index 000000000000..af4e15ab496c --- /dev/null +++ b/apps/web/lib/language/service.ts @@ -0,0 +1,158 @@ +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { ZId } from "@formbricks/types/common"; +import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors"; +import { + TLanguage, + TLanguageInput, + TLanguageUpdate, + ZLanguageInput, + ZLanguageUpdate, +} from "@formbricks/types/project"; +import { getProject } from "../project/service"; +import { validateInputs } from "../utils/validate"; + +const languageSelect = { + id: true, + code: true, + alias: true, + projectId: true, + createdAt: true, + updatedAt: true, +}; + +export const getLanguage = async (languageId: string): Promise => { + try { + validateInputs([languageId, ZId]); + + const language = await prisma.language.findFirst({ + where: { id: languageId }, + select: { ...languageSelect, projectId: true }, + }); + + if (!language) { + throw new ResourceNotFoundError("Language", languageId); + } + + return language; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error getting language"); + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const createLanguage = async ( + projectId: string, + languageInput: TLanguageInput +): Promise => { + try { + validateInputs([projectId, ZId], [languageInput, ZLanguageInput]); + const project = await getProject(projectId); + if (!project) throw new ResourceNotFoundError("Project not found", projectId); + if (!languageInput.code) { + throw new ValidationError("Language code is required"); + } + + const language = await prisma.language.create({ + data: { + ...languageInput, + project: { + connect: { id: projectId }, + }, + }, + select: languageSelect, + }); + + return language; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error creating language"); + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const getSurveysUsingGivenLanguage = reactCache(async (languageId: string): Promise => { + try { + // Check if the language is used in any survey + const surveys = await prisma.surveyLanguage.findMany({ + where: { + languageId: languageId, + }, + select: { + survey: { + select: { + name: true, + }, + }, + }, + }); + + // Extracting survey names + const surveyNames = surveys.map((s) => s.survey.name); + return surveyNames; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error getting surveys using given language"); + throw new DatabaseError(error.message); + } + throw error; + } +}); + +export const deleteLanguage = async (languageId: string, projectId: string): Promise => { + try { + validateInputs([languageId, ZId], [projectId, ZId]); + const project = await getProject(projectId); + if (!project) throw new ResourceNotFoundError("Project not found", projectId); + const prismaLanguage = await prisma.language.delete({ + where: { id: languageId }, + select: { ...languageSelect, surveyLanguages: { select: { surveyId: true } } }, + }); + + // delete unused surveyLanguages + const language = { ...prismaLanguage, surveyLanguages: undefined }; + + return language; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error deleting language"); + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const updateLanguage = async ( + projectId: string, + languageId: string, + languageInput: TLanguageUpdate +): Promise => { + try { + validateInputs([languageId, ZId], [languageInput, ZLanguageUpdate], [projectId, ZId]); + const project = await getProject(projectId); + if (!project) throw new ResourceNotFoundError("Project not found", projectId); + const prismaLanguage = await prisma.language.update({ + where: { id: languageId }, + data: { ...languageInput, updatedAt: new Date() }, + select: { ...languageSelect, surveyLanguages: { select: { surveyId: true } } }, + }); + + // delete unused surveyLanguages + const language = { ...prismaLanguage, surveyLanguages: undefined }; + + return language; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error updating language"); + throw new DatabaseError(error.message); + } + throw error; + } +}; diff --git a/packages/lib/language/tests/__mocks__/data.mock.ts b/apps/web/lib/language/tests/__mocks__/data.mock.ts similarity index 100% rename from packages/lib/language/tests/__mocks__/data.mock.ts rename to apps/web/lib/language/tests/__mocks__/data.mock.ts diff --git a/apps/web/lib/language/tests/language.test.ts b/apps/web/lib/language/tests/language.test.ts new file mode 100644 index 000000000000..8a299a835cca --- /dev/null +++ b/apps/web/lib/language/tests/language.test.ts @@ -0,0 +1,129 @@ +import { + mockLanguage, + mockLanguageId, + mockLanguageInput, + mockLanguageUpdate, + mockProjectId, + mockUpdatedLanguage, +} from "./__mocks__/data.mock"; +import { getProject } from "@/lib/project/service"; +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, ValidationError } from "@formbricks/types/errors"; +import { TProject } from "@formbricks/types/project"; +import { createLanguage, deleteLanguage, updateLanguage } from "../service"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + language: { + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +// stub out project/service and caches +vi.mock("@/lib/project/service", () => ({ + getProject: vi.fn(), +})); + +const fakeProject = { + id: mockProjectId, + environments: [{ id: "env1" }, { id: "env2" }], +} as TProject; + +const testInputValidation = async ( + service: (projectId: string, ...functionArgs: any[]) => Promise, + ...args: any[] +): Promise => { + test("throws ValidationError on bad input", async () => { + await expect(service(...args)).rejects.toThrow(ValidationError); + }); +}; + +describe("createLanguage", () => { + beforeEach(() => { + vi.mocked(getProject).mockResolvedValue(fakeProject); + }); + + test("happy path creates a new Language", async () => { + vi.mocked(prisma.language.create).mockResolvedValue(mockLanguage); + const result = await createLanguage(mockProjectId, mockLanguageInput); + expect(result).toEqual(mockLanguage); + }); + + describe("sad path", () => { + testInputValidation(createLanguage, "bad-id", {}); + + test("throws DatabaseError when PrismaKnownRequestError", async () => { + const err = new Prisma.PrismaClientKnownRequestError("dup", { + code: "P2002", + clientVersion: "1", + }); + vi.mocked(prisma.language.create).mockRejectedValue(err); + await expect(createLanguage(mockProjectId, mockLanguageInput)).rejects.toThrow(DatabaseError); + }); + }); +}); + +describe("updateLanguage", () => { + beforeEach(() => { + vi.mocked(getProject).mockResolvedValue(fakeProject); + }); + + test("happy path updates a language", async () => { + const mockUpdatedLanguageWithSurveyLanguage = { + ...mockUpdatedLanguage, + surveyLanguages: [ + { + id: "surveyLanguageId", + }, + ], + }; + vi.mocked(prisma.language.update).mockResolvedValue(mockUpdatedLanguageWithSurveyLanguage); + const result = await updateLanguage(mockProjectId, mockLanguageId, mockLanguageUpdate); + expect(result).toEqual(mockUpdatedLanguage); + }); + + describe("sad path", () => { + testInputValidation(updateLanguage, "bad-id", mockLanguageId, {}); + + test("throws DatabaseError on PrismaKnownRequestError", async () => { + const err = new Prisma.PrismaClientKnownRequestError("dup", { + code: "P2002", + clientVersion: "1", + }); + vi.mocked(prisma.language.update).mockRejectedValue(err); + await expect(updateLanguage(mockProjectId, mockLanguageId, mockLanguageUpdate)).rejects.toThrow( + DatabaseError + ); + }); + }); +}); + +describe("deleteLanguage", () => { + beforeEach(() => { + vi.mocked(getProject).mockResolvedValue(fakeProject); + }); + + test("happy path deletes a language", async () => { + vi.mocked(prisma.language.delete).mockResolvedValue(mockLanguage); + const result = await deleteLanguage(mockLanguageId, mockProjectId); + expect(result).toEqual(mockLanguage); + }); + + describe("sad path", () => { + testInputValidation(deleteLanguage, "bad-id", mockProjectId); + + test("throws DatabaseError on PrismaKnownRequestError", async () => { + const err = new Prisma.PrismaClientKnownRequestError("dup", { + code: "P2002", + clientVersion: "1", + }); + vi.mocked(prisma.language.delete).mockRejectedValue(err); + await expect(deleteLanguage(mockLanguageId, mockProjectId)).rejects.toThrow(DatabaseError); + }); + }); +}); diff --git a/packages/lib/localStorage.ts b/apps/web/lib/localStorage.ts similarity index 100% rename from packages/lib/localStorage.ts rename to apps/web/lib/localStorage.ts diff --git a/packages/lib/markdownIt.ts b/apps/web/lib/markdownIt.ts similarity index 100% rename from packages/lib/markdownIt.ts rename to apps/web/lib/markdownIt.ts diff --git a/packages/lib/membership/hooks/actions.ts b/apps/web/lib/membership/hooks/actions.ts similarity index 100% rename from packages/lib/membership/hooks/actions.ts rename to apps/web/lib/membership/hooks/actions.ts diff --git a/apps/web/lib/membership/hooks/useMembershipRole.test.tsx b/apps/web/lib/membership/hooks/useMembershipRole.test.tsx new file mode 100644 index 000000000000..0d7d0f0e70ab --- /dev/null +++ b/apps/web/lib/membership/hooks/useMembershipRole.test.tsx @@ -0,0 +1,53 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TOrganizationRole } from "@formbricks/types/memberships"; +import { getMembershipByUserIdOrganizationIdAction } from "./actions"; +import { useMembershipRole } from "./useMembershipRole"; + +vi.mock("./actions", () => ({ + getMembershipByUserIdOrganizationIdAction: vi.fn(), +})); + +describe("useMembershipRole", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should fetch and return membership role", async () => { + const mockRole: TOrganizationRole = "owner"; + vi.mocked(getMembershipByUserIdOrganizationIdAction).mockResolvedValue(mockRole); + + const { result } = renderHook(() => useMembershipRole("env-123", "user-123")); + + expect(result.current.isLoading).toBe(true); + expect(result.current.membershipRole).toBeUndefined(); + expect(result.current.error).toBe(""); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.membershipRole).toBe(mockRole); + expect(result.current.error).toBe(""); + expect(getMembershipByUserIdOrganizationIdAction).toHaveBeenCalledWith("env-123", "user-123"); + }); + + test("should handle error when fetching membership role fails", async () => { + const errorMessage = "Failed to fetch role"; + vi.mocked(getMembershipByUserIdOrganizationIdAction).mockRejectedValue(new Error(errorMessage)); + + const { result } = renderHook(() => useMembershipRole("env-123", "user-123")); + + expect(result.current.isLoading).toBe(true); + expect(result.current.membershipRole).toBeUndefined(); + expect(result.current.error).toBe(""); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.membershipRole).toBeUndefined(); + expect(result.current.error).toBe(errorMessage); + expect(getMembershipByUserIdOrganizationIdAction).toHaveBeenCalledWith("env-123", "user-123"); + }); +}); diff --git a/packages/lib/membership/hooks/useMembershipRole.tsx b/apps/web/lib/membership/hooks/useMembershipRole.tsx similarity index 96% rename from packages/lib/membership/hooks/useMembershipRole.tsx rename to apps/web/lib/membership/hooks/useMembershipRole.tsx index 307f75d49fca..5cec1e7c09f6 100644 --- a/packages/lib/membership/hooks/useMembershipRole.tsx +++ b/apps/web/lib/membership/hooks/useMembershipRole.tsx @@ -17,6 +17,7 @@ export const useMembershipRole = (environmentId: string, userId: string) => { } catch (err: any) { const error = err?.message || "Something went wrong"; setError(error); + setIsLoading(false); } }; getRole(); diff --git a/apps/web/lib/membership/service.test.ts b/apps/web/lib/membership/service.test.ts new file mode 100644 index 000000000000..107bdde6e24c --- /dev/null +++ b/apps/web/lib/membership/service.test.ts @@ -0,0 +1,165 @@ +import { Prisma } from "@prisma/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, UnknownError } from "@formbricks/types/errors"; +import { TMembership } from "@formbricks/types/memberships"; +import { createMembership, getMembershipByUserIdOrganizationId } from "./service"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + membership: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }, +})); + +describe("Membership Service", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getMembershipByUserIdOrganizationId", () => { + const mockUserId = "user123"; + const mockOrgId = "org123"; + + test("returns membership when found", async () => { + const mockMembership: TMembership = { + organizationId: mockOrgId, + userId: mockUserId, + accepted: true, + role: "owner", + }; + + vi.mocked(prisma.membership.findUnique).mockResolvedValue(mockMembership); + + const result = await getMembershipByUserIdOrganizationId(mockUserId, mockOrgId); + expect(result).toEqual(mockMembership); + expect(prisma.membership.findUnique).toHaveBeenCalledWith({ + where: { + userId_organizationId: { + userId: mockUserId, + organizationId: mockOrgId, + }, + }, + }); + }); + + test("returns null when membership not found", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue(null); + + const result = await getMembershipByUserIdOrganizationId(mockUserId, mockOrgId); + expect(result).toBeNull(); + }); + + test("throws DatabaseError on Prisma error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.membership.findUnique).mockRejectedValue(prismaError); + + await expect(getMembershipByUserIdOrganizationId(mockUserId, mockOrgId)).rejects.toThrow(DatabaseError); + }); + + test("throws UnknownError on unknown error", async () => { + vi.mocked(prisma.membership.findUnique).mockRejectedValue(new Error("Unknown error")); + + await expect(getMembershipByUserIdOrganizationId(mockUserId, mockOrgId)).rejects.toThrow(UnknownError); + }); + }); + + describe("createMembership", () => { + const mockUserId = "user123"; + const mockOrgId = "org123"; + const mockMembershipData: Partial = { + accepted: true, + role: "member", + }; + + test("creates new membership when none exists", async () => { + vi.mocked(prisma.membership.findUnique).mockResolvedValue(null); + + const mockCreatedMembership = { + organizationId: mockOrgId, + userId: mockUserId, + accepted: true, + role: "member", + } as TMembership; + + vi.mocked(prisma.membership.create).mockResolvedValue(mockCreatedMembership as any); + + const result = await createMembership(mockOrgId, mockUserId, mockMembershipData); + expect(result).toEqual(mockCreatedMembership); + expect(prisma.membership.create).toHaveBeenCalledWith({ + data: { + userId: mockUserId, + organizationId: mockOrgId, + accepted: mockMembershipData.accepted, + role: mockMembershipData.role, + }, + }); + }); + + test("returns existing membership if role matches", async () => { + const existingMembership = { + organizationId: mockOrgId, + userId: mockUserId, + accepted: true, + role: "member", + } as TMembership; + + vi.mocked(prisma.membership.findUnique).mockResolvedValue(existingMembership as any); + + const result = await createMembership(mockOrgId, mockUserId, mockMembershipData); + expect(result).toEqual(existingMembership); + expect(prisma.membership.create).not.toHaveBeenCalled(); + expect(prisma.membership.update).not.toHaveBeenCalled(); + }); + + test("updates existing membership if role differs", async () => { + const existingMembership = { + organizationId: mockOrgId, + userId: mockUserId, + accepted: true, + role: "member", + } as TMembership; + + const updatedMembership = { + ...existingMembership, + role: "owner", + } as TMembership; + + vi.mocked(prisma.membership.findUnique).mockResolvedValue(existingMembership as any); + vi.mocked(prisma.membership.update).mockResolvedValue(updatedMembership as any); + + const result = await createMembership(mockOrgId, mockUserId, { ...mockMembershipData, role: "owner" }); + expect(result).toEqual(updatedMembership); + expect(prisma.membership.update).toHaveBeenCalledWith({ + where: { + userId_organizationId: { + userId: mockUserId, + organizationId: mockOrgId, + }, + }, + data: { + accepted: mockMembershipData.accepted, + role: "owner", + }, + }); + }); + + test("throws DatabaseError on Prisma error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.membership.findUnique).mockRejectedValue(prismaError); + + await expect(createMembership(mockOrgId, mockUserId, mockMembershipData)).rejects.toThrow( + DatabaseError + ); + }); + }); +}); diff --git a/apps/web/lib/membership/service.ts b/apps/web/lib/membership/service.ts new file mode 100644 index 000000000000..1e2cf29c893a --- /dev/null +++ b/apps/web/lib/membership/service.ts @@ -0,0 +1,93 @@ +import "server-only"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { ZString } from "@formbricks/types/common"; +import { DatabaseError, UnknownError } from "@formbricks/types/errors"; +import { TMembership, ZMembership } from "@formbricks/types/memberships"; +import { validateInputs } from "../utils/validate"; + +export const getMembershipByUserIdOrganizationId = reactCache( + async (userId: string, organizationId: string): Promise => { + validateInputs([userId, ZString], [organizationId, ZString]); + + try { + const membership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId, + organizationId, + }, + }, + }); + + if (!membership) return null; + + return membership; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error getting membership by user id and organization id"); + throw new DatabaseError(error.message); + } + + throw new UnknownError("Error while fetching membership"); + } + } +); + +export const createMembership = async ( + organizationId: string, + userId: string, + data: Partial +): Promise => { + validateInputs([organizationId, ZString], [userId, ZString], [data, ZMembership.partial()]); + + try { + const existingMembership = await prisma.membership.findUnique({ + where: { + userId_organizationId: { + userId, + organizationId, + }, + }, + }); + + if (existingMembership && existingMembership.role === data.role) { + return existingMembership; + } + + let membership: TMembership; + if (!existingMembership) { + membership = await prisma.membership.create({ + data: { + userId, + organizationId, + accepted: data.accepted, + role: data.role as TMembership["role"], + }, + }); + } else { + membership = await prisma.membership.update({ + where: { + userId_organizationId: { + userId, + organizationId, + }, + }, + data: { + accepted: data.accepted, + role: data.role as TMembership["role"], + }, + }); + } + + return membership; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; diff --git a/apps/web/lib/membership/utils.test.ts b/apps/web/lib/membership/utils.test.ts new file mode 100644 index 000000000000..d4017ecec966 --- /dev/null +++ b/apps/web/lib/membership/utils.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; +import { TOrganizationRole } from "@formbricks/types/memberships"; +import { getAccessFlags } from "./utils"; + +describe("getAccessFlags", () => { + test("should return correct flags for owner role", () => { + const role: TOrganizationRole = "owner"; + const flags = getAccessFlags(role); + expect(flags).toEqual({ + isManager: false, + isOwner: true, + isBilling: false, + isMember: false, + }); + }); + + test("should return correct flags for manager role", () => { + const role: TOrganizationRole = "manager"; + const flags = getAccessFlags(role); + expect(flags).toEqual({ + isManager: true, + isOwner: false, + isBilling: false, + isMember: false, + }); + }); + + test("should return correct flags for billing role", () => { + const role: TOrganizationRole = "billing"; + const flags = getAccessFlags(role); + expect(flags).toEqual({ + isManager: false, + isOwner: false, + isBilling: true, + isMember: false, + }); + }); + + test("should return correct flags for member role", () => { + const role: TOrganizationRole = "member"; + const flags = getAccessFlags(role); + expect(flags).toEqual({ + isManager: false, + isOwner: false, + isBilling: false, + isMember: true, + }); + }); + + test("should return all flags as false when role is undefined", () => { + const flags = getAccessFlags(undefined); + expect(flags).toEqual({ + isManager: false, + isOwner: false, + isBilling: false, + isMember: false, + }); + }); +}); diff --git a/apps/web/lib/membership/utils.ts b/apps/web/lib/membership/utils.ts new file mode 100644 index 000000000000..7b9596ee04ba --- /dev/null +++ b/apps/web/lib/membership/utils.ts @@ -0,0 +1,33 @@ +import { TOrganizationRole } from "@formbricks/types/memberships"; + +export const getAccessFlags = (role?: TOrganizationRole) => { + const isOwner = role === "owner"; + const isManager = role === "manager"; + const isBilling = role === "billing"; + const isMember = role === "member"; + + return { + isManager, + isOwner, + isBilling, + isMember, + }; +}; + +export const getUserManagementAccess = ( + role: TOrganizationRole, + minimumRole: "owner" | "manager" | "disabled" +): boolean => { + // If minimum role is "disabled", no one has access + if (minimumRole === "disabled") { + return false; + } + if (minimumRole === "owner") { + return role === "owner"; + } + + if (minimumRole === "manager") { + return role === "owner" || role === "manager"; + } + return false; +}; diff --git a/apps/web/lib/notion/service.ts b/apps/web/lib/notion/service.ts new file mode 100644 index 000000000000..d50847378379 --- /dev/null +++ b/apps/web/lib/notion/service.ts @@ -0,0 +1,71 @@ +import { ENCRYPTION_KEY } from "@/lib/constants"; +import { symmetricDecrypt } from "@/lib/crypto"; +import { + TIntegrationNotion, + TIntegrationNotionConfig, + TIntegrationNotionDatabase, +} from "@formbricks/types/integration/notion"; +import { getIntegrationByType } from "../integration/service"; + +const fetchPages = async (config: TIntegrationNotionConfig) => { + try { + const res = await fetch("https://api.notion.com/v1/search", { + headers: getHeaders(config), + method: "POST", + body: JSON.stringify({ + page_size: 100, + filter: { + value: "database", + property: "object", + }, + }), + }); + return (await res.json()).results; + } catch (error) { + throw error; + } +}; + +export const getNotionDatabases = async (environmentId: string): Promise => { + let results: TIntegrationNotionDatabase[] = []; + try { + const notionIntegration = (await getIntegrationByType(environmentId, "notion")) as TIntegrationNotion; + if (notionIntegration && notionIntegration.config?.key.bot_id) { + results = await fetchPages(notionIntegration.config); + } + return results; + } catch (error) { + throw error; + } +}; + +export const writeData = async ( + databaseId: string, + properties: Record, + config: TIntegrationNotionConfig +) => { + try { + await fetch(`https://api.notion.com/v1/pages`, { + headers: getHeaders(config), + method: "POST", + body: JSON.stringify({ + parent: { + database_id: databaseId, + }, + properties: properties, + }), + }); + } catch (error) { + throw error; + } +}; + +const getHeaders = (config: TIntegrationNotionConfig) => { + const decryptedToken = symmetricDecrypt(config.key.access_token, ENCRYPTION_KEY!); + return { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${decryptedToken}`, + "Notion-Version": "2022-06-28", + }; +}; diff --git a/apps/web/lib/organization/auth.test.ts b/apps/web/lib/organization/auth.test.ts new file mode 100644 index 000000000000..868a4436c9a9 --- /dev/null +++ b/apps/web/lib/organization/auth.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test, vi } from "vitest"; +import { TMembership } from "@formbricks/types/memberships"; +import { TOrganization } from "@formbricks/types/organizations"; +import { getMembershipByUserIdOrganizationId } from "../membership/service"; +import { getAccessFlags } from "../membership/utils"; +import { canUserAccessOrganization, verifyUserRoleAccess } from "./auth"; +import { getOrganizationsByUserId } from "./service"; + +vi.mock("./service", () => ({ + getOrganizationsByUserId: vi.fn(), +})); + +vi.mock("../membership/service", () => ({ + getMembershipByUserIdOrganizationId: vi.fn(), +})); + +vi.mock("../membership/utils", () => ({ + getAccessFlags: vi.fn(), +})); + +describe("auth", () => { + describe("canUserAccessOrganization", () => { + test("returns true when user has access to organization", async () => { + const mockOrganizations: TOrganization[] = [ + { + id: "org1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Org 1", + billing: { + stripeCustomerId: null, + plan: "free", + period: "monthly", + limits: { + projects: 3, + monthly: { + responses: 1500, + miu: 2000, + }, + }, + periodStart: new Date(), + }, + isAIEnabled: false, + }, + ]; + vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations); + + const result = await canUserAccessOrganization("user1", "org1"); + expect(result).toBe(true); + }); + }); + + describe("verifyUserRoleAccess", () => { + test("returns all access for owner role", async () => { + const mockMembership: TMembership = { + organizationId: "org1", + userId: "user1", + accepted: true, + role: "owner", + }; + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getAccessFlags).mockReturnValue({ + isOwner: true, + isManager: false, + isBilling: false, + isMember: false, + }); + + const result = await verifyUserRoleAccess("org1", "user1"); + expect(result).toEqual({ + hasCreateOrUpdateAccess: true, + hasDeleteAccess: true, + hasCreateOrUpdateMembersAccess: true, + hasDeleteMembersAccess: true, + hasBillingAccess: true, + }); + }); + + test("returns limited access for manager role", async () => { + const mockMembership: TMembership = { + organizationId: "org1", + userId: "user1", + accepted: true, + role: "manager", + }; + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getAccessFlags).mockReturnValue({ + isOwner: false, + isManager: true, + isBilling: false, + isMember: false, + }); + + const result = await verifyUserRoleAccess("org1", "user1"); + expect(result).toEqual({ + hasCreateOrUpdateAccess: false, + hasDeleteAccess: false, + hasCreateOrUpdateMembersAccess: true, + hasDeleteMembersAccess: true, + hasBillingAccess: true, + }); + }); + + test("returns no access for member role", async () => { + const mockMembership: TMembership = { + organizationId: "org1", + userId: "user1", + accepted: true, + role: "member", + }; + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getAccessFlags).mockReturnValue({ + isOwner: false, + isManager: false, + isBilling: false, + isMember: true, + }); + + const result = await verifyUserRoleAccess("org1", "user1"); + expect(result).toEqual({ + hasCreateOrUpdateAccess: false, + hasDeleteAccess: false, + hasCreateOrUpdateMembersAccess: false, + hasDeleteMembersAccess: false, + hasBillingAccess: false, + }); + }); + }); +}); diff --git a/apps/web/lib/organization/auth.ts b/apps/web/lib/organization/auth.ts new file mode 100644 index 000000000000..460235f3e56d --- /dev/null +++ b/apps/web/lib/organization/auth.ts @@ -0,0 +1,62 @@ +import "server-only"; +import { ZId } from "@formbricks/types/common"; +import { getMembershipByUserIdOrganizationId } from "../membership/service"; +import { getAccessFlags } from "../membership/utils"; +import { validateInputs } from "../utils/validate"; +import { getOrganizationsByUserId } from "./service"; + +export const canUserAccessOrganization = async (userId: string, organizationId: string): Promise => { + validateInputs([userId, ZId], [organizationId, ZId]); + + try { + const userOrganizations = await getOrganizationsByUserId(userId); + + const givenOrganizationExists = userOrganizations.filter( + (organization) => (organization.id = organizationId) + ); + if (!givenOrganizationExists) { + return false; + } + return true; + } catch (error) { + throw error; + } +}; + +export const verifyUserRoleAccess = async ( + organizationId: string, + userId: string +): Promise<{ + hasCreateOrUpdateAccess: boolean; + hasDeleteAccess: boolean; + hasCreateOrUpdateMembersAccess: boolean; + hasDeleteMembersAccess: boolean; + hasBillingAccess: boolean; +}> => { + const accessObject = { + hasCreateOrUpdateAccess: true, + hasDeleteAccess: true, + hasCreateOrUpdateMembersAccess: true, + hasDeleteMembersAccess: true, + hasBillingAccess: true, + }; + + const currentUserMembership = await getMembershipByUserIdOrganizationId(userId, organizationId); + const { isOwner, isManager } = getAccessFlags(currentUserMembership?.role); + + if (!isOwner) { + accessObject.hasCreateOrUpdateAccess = false; + accessObject.hasDeleteAccess = false; + accessObject.hasCreateOrUpdateMembersAccess = false; + accessObject.hasDeleteMembersAccess = false; + accessObject.hasBillingAccess = false; + } + + if (isManager) { + accessObject.hasCreateOrUpdateMembersAccess = true; + accessObject.hasDeleteMembersAccess = true; + accessObject.hasBillingAccess = true; + } + + return accessObject; +}; diff --git a/apps/web/lib/organization/service.test.ts b/apps/web/lib/organization/service.test.ts new file mode 100644 index 000000000000..546253983a8e --- /dev/null +++ b/apps/web/lib/organization/service.test.ts @@ -0,0 +1,325 @@ +import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants"; +import { updateUser } from "@/lib/user/service"; +import { Prisma } from "@prisma/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { + createOrganization, + getOrganization, + getOrganizationsByUserId, + subscribeOrganizationMembersToSurveyResponses, + updateOrganization, +} from "./service"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + organization: { + findUnique: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/user/service", () => ({ + updateUser: vi.fn(), +})); + +describe("Organization Service", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getOrganization", () => { + test("should return organization when found", async () => { + const mockOrganization = { + id: "org1", + name: "Test Org", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + plan: PROJECT_FEATURE_KEYS.FREE, + limits: { + projects: BILLING_LIMITS.FREE.PROJECTS, + monthly: { + responses: BILLING_LIMITS.FREE.RESPONSES, + miu: BILLING_LIMITS.FREE.MIU, + }, + }, + stripeCustomerId: null, + periodStart: new Date(), + period: "monthly" as const, + }, + isAIEnabled: false, + whitelabel: false, + }; + + vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization); + + const result = await getOrganization("org1"); + + expect(result).toEqual(mockOrganization); + expect(prisma.organization.findUnique).toHaveBeenCalledWith({ + where: { id: "org1" }, + select: expect.any(Object), + }); + }); + + test("should return null when organization not found", async () => { + vi.mocked(prisma.organization.findUnique).mockResolvedValue(null); + + const result = await getOrganization("nonexistent"); + + expect(result).toBeNull(); + }); + + test("should throw DatabaseError on prisma error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.organization.findUnique).mockRejectedValue(prismaError); + + await expect(getOrganization("org1")).rejects.toThrow(DatabaseError); + }); + }); + + describe("getOrganizationsByUserId", () => { + test("should return organizations for user", async () => { + const mockOrganizations = [ + { + id: "org1", + name: "Test Org 1", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + plan: PROJECT_FEATURE_KEYS.FREE, + limits: { + projects: BILLING_LIMITS.FREE.PROJECTS, + monthly: { + responses: BILLING_LIMITS.FREE.RESPONSES, + miu: BILLING_LIMITS.FREE.MIU, + }, + }, + stripeCustomerId: null, + periodStart: new Date(), + period: "monthly" as const, + }, + isAIEnabled: false, + whitelabel: false, + }, + ]; + + vi.mocked(prisma.organization.findMany).mockResolvedValue(mockOrganizations); + + const result = await getOrganizationsByUserId("user1"); + + expect(result).toEqual(mockOrganizations); + expect(prisma.organization.findMany).toHaveBeenCalledWith({ + where: { + memberships: { + some: { + userId: "user1", + }, + }, + }, + select: expect.any(Object), + }); + }); + + test("should throw DatabaseError on prisma error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.organization.findMany).mockRejectedValue(prismaError); + + await expect(getOrganizationsByUserId("user1")).rejects.toThrow(DatabaseError); + }); + }); + + describe("createOrganization", () => { + test("should create organization with default billing settings", async () => { + const mockOrganization = { + id: "org1", + name: "Test Org", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + plan: PROJECT_FEATURE_KEYS.FREE, + limits: { + projects: BILLING_LIMITS.FREE.PROJECTS, + monthly: { + responses: BILLING_LIMITS.FREE.RESPONSES, + miu: BILLING_LIMITS.FREE.MIU, + }, + }, + stripeCustomerId: null, + periodStart: new Date(), + period: "monthly" as const, + }, + isAIEnabled: false, + whitelabel: false, + }; + + vi.mocked(prisma.organization.create).mockResolvedValue(mockOrganization); + + const result = await createOrganization({ name: "Test Org" }); + + expect(result).toEqual(mockOrganization); + expect(prisma.organization.create).toHaveBeenCalledWith({ + data: { + name: "Test Org", + billing: { + plan: PROJECT_FEATURE_KEYS.FREE, + limits: { + projects: BILLING_LIMITS.FREE.PROJECTS, + monthly: { + responses: BILLING_LIMITS.FREE.RESPONSES, + miu: BILLING_LIMITS.FREE.MIU, + }, + }, + stripeCustomerId: null, + periodStart: expect.any(Date), + period: "monthly", + }, + }, + select: expect.any(Object), + }); + }); + + test("should throw DatabaseError on prisma error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.organization.create).mockRejectedValue(prismaError); + + await expect(createOrganization({ name: "Test Org" })).rejects.toThrow(DatabaseError); + }); + }); + + describe("updateOrganization", () => { + test("should update organization and revalidate cache", async () => { + const mockOrganization = { + id: "org1", + name: "Updated Org", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + plan: PROJECT_FEATURE_KEYS.FREE, + limits: { + projects: BILLING_LIMITS.FREE.PROJECTS, + monthly: { + responses: BILLING_LIMITS.FREE.RESPONSES, + miu: BILLING_LIMITS.FREE.MIU, + }, + }, + stripeCustomerId: null, + periodStart: new Date(), + period: "monthly" as const, + }, + isAIEnabled: false, + whitelabel: false, + memberships: [{ userId: "user1" }, { userId: "user2" }], + projects: [ + { + environments: [{ id: "env1" }, { id: "env2" }], + }, + ], + }; + + vi.mocked(prisma.organization.update).mockResolvedValue(mockOrganization); + + const result = await updateOrganization("org1", { name: "Updated Org" }); + + expect(result).toEqual({ + id: "org1", + name: "Updated Org", + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + billing: { + plan: PROJECT_FEATURE_KEYS.FREE, + limits: { + projects: BILLING_LIMITS.FREE.PROJECTS, + monthly: { + responses: BILLING_LIMITS.FREE.RESPONSES, + miu: BILLING_LIMITS.FREE.MIU, + }, + }, + stripeCustomerId: null, + periodStart: expect.any(Date), + period: "monthly", + }, + isAIEnabled: false, + whitelabel: false, + }); + expect(prisma.organization.update).toHaveBeenCalledWith({ + where: { id: "org1" }, + data: { name: "Updated Org" }, + select: expect.any(Object), + }); + }); + }); + + describe("subscribeOrganizationMembersToSurveyResponses", () => { + test("should subscribe user to survey responses when not unsubscribed", async () => { + const mockUser = { + id: "user-123", + notificationSettings: { + alert: { "existing-survey-id": true }, + unsubscribedOrganizationIds: [], // User is subscribed to all organizations + }, + } as any; + + const surveyId = "survey-123"; + const userId = "user-123"; + const organizationId = "org-123"; + + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser); + vi.mocked(updateUser).mockResolvedValueOnce({} as any); + + await subscribeOrganizationMembersToSurveyResponses(surveyId, userId, organizationId); + + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { id: userId }, + }); + expect(updateUser).toHaveBeenCalledWith(userId, { + notificationSettings: { + alert: { + "existing-survey-id": true, + "survey-123": true, + }, + + unsubscribedOrganizationIds: [], + }, + }); + }); + + test("should not subscribe user when unsubscribed from organization", async () => { + const mockUser = { + id: "user-123", + notificationSettings: { + alert: { "existing-survey-id": true }, + unsubscribedOrganizationIds: ["org-123"], // User has unsubscribed from this organization + }, + } as any; + + const surveyId = "survey-123"; + const userId = "user-123"; + const organizationId = "org-123"; + + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser); + + await subscribeOrganizationMembersToSurveyResponses(surveyId, userId, organizationId); + + // Should not call updateUser because user is unsubscribed from this organization + expect(updateUser).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/lib/organization/service.ts b/apps/web/lib/organization/service.ts new file mode 100644 index 000000000000..93a3e7d55a7e --- /dev/null +++ b/apps/web/lib/organization/service.ts @@ -0,0 +1,346 @@ +import "server-only"; +import { BILLING_LIMITS, ITEMS_PER_PAGE, PROJECT_FEATURE_KEYS } from "@/lib/constants"; +import { getProjects } from "@/lib/project/service"; +import { updateUser } from "@/lib/user/service"; +import { getBillingPeriodStartDate } from "@/lib/utils/billing"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { logger } from "@formbricks/logger"; +import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { + TOrganization, + TOrganizationCreateInput, + TOrganizationUpdateInput, + ZOrganizationCreateInput, +} from "@formbricks/types/organizations"; +import { TUserNotificationSettings } from "@formbricks/types/user"; +import { validateInputs } from "../utils/validate"; + +export const select: Prisma.OrganizationSelect = { + id: true, + createdAt: true, + updatedAt: true, + name: true, + billing: true, + isAIEnabled: true, + whitelabel: true, +}; + +export const getOrganizationsTag = (organizationId: string) => `organizations-${organizationId}`; +export const getOrganizationsByUserIdCacheTag = (userId: string) => `users-${userId}-organizations`; +export const getOrganizationByEnvironmentIdCacheTag = (environmentId: string) => + `environments-${environmentId}-organization`; + +export const getOrganizationsByUserId = reactCache( + async (userId: string, page?: number): Promise => { + validateInputs([userId, ZString], [page, ZOptionalNumber]); + + try { + const organizations = await prisma.organization.findMany({ + where: { + memberships: { + some: { + userId, + }, + }, + }, + select, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + }); + if (!organizations) { + throw new ResourceNotFoundError("Organizations by UserId", userId); + } + return organizations; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + } +); + +export const getOrganizationByEnvironmentId = reactCache( + async (environmentId: string): Promise => { + validateInputs([environmentId, ZId]); + + try { + const organization = await prisma.organization.findFirst({ + where: { + projects: { + some: { + environments: { + some: { + id: environmentId, + }, + }, + }, + }, + }, + select: { ...select, memberships: true }, // include memberships + }); + + return organization; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error getting organization by environment id"); + throw new DatabaseError(error.message); + } + + throw error; + } + } +); + +export const getOrganization = reactCache(async (organizationId: string): Promise => { + validateInputs([organizationId, ZString]); + + try { + const organization = await prisma.organization.findUnique({ + where: { + id: organizationId, + }, + select, + }); + return organization; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}); + +export const createOrganization = async ( + organizationInput: TOrganizationCreateInput +): Promise => { + try { + validateInputs([organizationInput, ZOrganizationCreateInput]); + + const organization = await prisma.organization.create({ + data: { + ...organizationInput, + billing: { + plan: PROJECT_FEATURE_KEYS.FREE, + limits: { + projects: BILLING_LIMITS.FREE.PROJECTS, + monthly: { + responses: BILLING_LIMITS.FREE.RESPONSES, + miu: BILLING_LIMITS.FREE.MIU, + }, + }, + stripeCustomerId: null, + periodStart: new Date(), + period: "monthly", + }, + }, + select, + }); + + return organization; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const updateOrganization = async ( + organizationId: string, + data: Partial +): Promise => { + try { + const updatedOrganization = await prisma.organization.update({ + where: { + id: organizationId, + }, + data, + select: { ...select, memberships: true, projects: { select: { environments: true } } }, // include memberships & environments + }); + + const organization = { + ...updatedOrganization, + memberships: undefined, + projects: undefined, + }; + + return organization; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === PrismaErrorType.RecordDoesNotExist + ) { + throw new ResourceNotFoundError("Organization", organizationId); + } + throw error; // Re-throw any other errors + } +}; + +export const deleteOrganization = async (organizationId: string) => { + validateInputs([organizationId, ZId]); + try { + await prisma.organization.delete({ + where: { + id: organizationId, + }, + select: { + id: true, + name: true, + memberships: { + select: { + userId: true, + }, + }, + projects: { + select: { + id: true, + environments: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const getMonthlyActiveOrganizationPeopleCount = reactCache( + async (organizationId: string): Promise => { + validateInputs([organizationId, ZId]); + + // temporary solution until we have a better way to track active users + return 0; + } +); + +export const getMonthlyOrganizationResponseCount = reactCache( + async (organizationId: string): Promise => { + validateInputs([organizationId, ZId]); + + try { + const organization = await getOrganization(organizationId); + if (!organization) { + throw new ResourceNotFoundError("Organization", organizationId); + } + + // Use the utility function to calculate the start date + const startDate = getBillingPeriodStartDate(organization.billing); + + // Get all environment IDs for the organization + const projects = await getProjects(organizationId); + const environmentIds = projects.flatMap((project) => project.environments.map((env) => env.id)); + + // Use Prisma's aggregate to count responses for all environments + const responseAggregations = await prisma.response.aggregate({ + _count: { + id: true, + }, + where: { + AND: [{ survey: { environmentId: { in: environmentIds } } }, { createdAt: { gte: startDate } }], + }, + }); + + // The result is an aggregation of the total count + return responseAggregations._count.id; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + } +); + +export const subscribeOrganizationMembersToSurveyResponses = async ( + surveyId: string, + createdBy: string, + organizationId: string +): Promise => { + try { + const surveyCreator = await prisma.user.findUnique({ + where: { + id: createdBy, + }, + }); + + if (!surveyCreator) { + throw new ResourceNotFoundError("User", createdBy); + } + + if (surveyCreator.notificationSettings?.unsubscribedOrganizationIds?.includes(organizationId)) { + return; + } + + const defaultSettings = { alert: {} }; + const updatedNotificationSettings: TUserNotificationSettings = { + ...defaultSettings, + ...surveyCreator.notificationSettings, + }; + + updatedNotificationSettings.alert[surveyId] = true; + + await updateUser(surveyCreator.id, { + notificationSettings: updatedNotificationSettings, + }); + } catch (error) { + throw error; + } +}; + +export const getOrganizationsWhereUserIsSingleOwner = reactCache( + async (userId: string): Promise => { + validateInputs([userId, ZString]); + try { + const orgs = await prisma.organization.findMany({ + where: { + memberships: { + some: { + userId, + role: "owner", + }, + }, + }, + select: { + ...select, + memberships: { + where: { + role: "owner", + }, + }, + }, + }); + + // Filter to only include orgs where there is exactly one owner + const filteredOrgs = orgs + .filter((org) => org.memberships.length === 1) + .map((org) => ({ + ...org, + memberships: undefined, // Remove memberships from the return object to match TOrganization type + })); + + return filteredOrgs; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } + } +); diff --git a/apps/web/lib/otelSetup.ts b/apps/web/lib/otelSetup.ts deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/apps/web/lib/pollyfills/structuredClone.ts b/apps/web/lib/pollyfills/structuredClone.ts new file mode 100644 index 000000000000..9d343725af57 --- /dev/null +++ b/apps/web/lib/pollyfills/structuredClone.ts @@ -0,0 +1,6 @@ +import structuredClonePolyfill from "@ungap/structured-clone"; + +const structuredCloneExport = + typeof structuredClone === "undefined" ? structuredClonePolyfill : structuredClone; + +export { structuredCloneExport as structuredClone }; diff --git a/packages/lib/posthogServer.ts b/apps/web/lib/posthogServer.ts similarity index 86% rename from packages/lib/posthogServer.ts rename to apps/web/lib/posthogServer.ts index 09bcde0e082e..93f20ba00659 100644 --- a/packages/lib/posthogServer.ts +++ b/apps/web/lib/posthogServer.ts @@ -1,7 +1,7 @@ +import { createCacheKey, withCache } from "@/modules/cache/lib/withCache"; import { PostHog } from "posthog-node"; import { logger } from "@formbricks/logger"; import { TOrganizationBillingPlan, TOrganizationBillingPlanLimits } from "@formbricks/types/organizations"; -import { cache } from "./cache"; import { IS_POSTHOG_CONFIGURED, IS_PRODUCTION, POSTHOG_API_HOST, POSTHOG_API_KEY } from "./constants"; const enabled = IS_PRODUCTION && IS_POSTHOG_CONFIGURED; @@ -37,8 +37,8 @@ export const sendPlanLimitsReachedEventToPosthogWeekly = ( plan: TOrganizationBillingPlan; limits: TOrganizationBillingPlanLimits; } -): Promise => - cache( +) => + withCache( async () => { try { await capturePosthogEnvironmentEvent(environmentId, "plan limit reached", { @@ -50,8 +50,8 @@ export const sendPlanLimitsReachedEventToPosthogWeekly = ( throw error; } }, - [`sendPlanLimitsReachedEventToPosthogWeekly-${billing.plan}-${environmentId}`], { - revalidate: 60 * 60 * 24 * 7, // 7 days + key: createCacheKey.custom("analytics", environmentId, `plan_limits_${billing.plan}`), + ttl: 60 * 60 * 24 * 7 * 1000, // 7 days in milliseconds } )(); diff --git a/apps/web/lib/project/service.test.ts b/apps/web/lib/project/service.test.ts new file mode 100644 index 000000000000..c07068830018 --- /dev/null +++ b/apps/web/lib/project/service.test.ts @@ -0,0 +1,683 @@ +import { createId } from "@paralleldrive/cuid2"; +import { OrganizationRole, Prisma, WidgetPlacement } from "@prisma/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, ValidationError } from "@formbricks/types/errors"; +import { ITEMS_PER_PAGE } from "../constants"; +import { + getProject, + getProjectByEnvironmentId, + getProjects, + getUserProjectEnvironmentsByOrganizationIds, + getUserProjects, +} from "./service"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + project: { + findUnique: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + }, + membership: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + }, +})); + +describe("Project Service", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("getProject should return a project when it exists", async () => { + const mockProject = { + id: createId(), + name: "Test Project", + organizationId: createId(), + createdAt: new Date(), + updatedAt: new Date(), + languages: ["en"], + recontactDays: 0, + linkSurveyBranding: true, + inAppSurveyBranding: true, + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, + clickOutsideClose: true, + darkOverlay: false, + environments: [], + styling: { + allowStyleOverwrite: true, + }, + logo: null, + brandColor: null, + highlightBorderColor: null, + }; + + vi.mocked(prisma.project.findUnique).mockResolvedValue(mockProject); + + const result = await getProject(mockProject.id); + + expect(result).toEqual(mockProject); + expect(prisma.project.findUnique).toHaveBeenCalledWith({ + where: { + id: mockProject.id, + }, + select: expect.any(Object), + }); + }); + + test("getProject should return null when project does not exist", async () => { + vi.mocked(prisma.project.findUnique).mockResolvedValue(null); + + const result = await getProject(createId()); + + expect(result).toBeNull(); + }); + + test("getProject should throw DatabaseError when prisma throws", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.project.findUnique).mockRejectedValue(prismaError); + + await expect(getProject(createId())).rejects.toThrow(DatabaseError); + }); + + test("getProjectByEnvironmentId should return a project when it exists", async () => { + const mockProject = { + id: createId(), + name: "Test Project", + organizationId: createId(), + createdAt: new Date(), + updatedAt: new Date(), + languages: ["en"], + recontactDays: 0, + linkSurveyBranding: true, + inAppSurveyBranding: true, + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, + clickOutsideClose: true, + darkOverlay: false, + environments: [], + styling: { + allowStyleOverwrite: true, + }, + logo: null, + brandColor: null, + highlightBorderColor: null, + }; + + vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject); + + const result = await getProjectByEnvironmentId(createId()); + + expect(result).toEqual(mockProject); + expect(prisma.project.findFirst).toHaveBeenCalledWith({ + where: { + environments: { + some: { + id: expect.any(String), + }, + }, + }, + select: expect.any(Object), + }); + }); + + test("getProjectByEnvironmentId should return null when project does not exist", async () => { + vi.mocked(prisma.project.findFirst).mockResolvedValue(null); + + const result = await getProjectByEnvironmentId(createId()); + + expect(result).toBeNull(); + }); + + test("getProjectByEnvironmentId should throw DatabaseError when prisma throws", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.project.findFirst).mockRejectedValue(prismaError); + + await expect(getProjectByEnvironmentId(createId())).rejects.toThrow(DatabaseError); + }); + + test("getUserProjects should return projects for admin user", async () => { + const userId = createId(); + const organizationId = createId(); + const mockProjects = [ + { + id: createId(), + name: "Test Project 1", + organizationId, + createdAt: new Date(), + updatedAt: new Date(), + languages: ["en"], + recontactDays: 0, + linkSurveyBranding: true, + inAppSurveyBranding: true, + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, + clickOutsideClose: true, + darkOverlay: false, + environments: [], + styling: { + allowStyleOverwrite: true, + }, + logo: null, + brandColor: null, + highlightBorderColor: null, + }, + { + id: createId(), + name: "Test Project 2", + organizationId, + createdAt: new Date(), + updatedAt: new Date(), + languages: ["en"], + recontactDays: 0, + linkSurveyBranding: true, + inAppSurveyBranding: true, + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, + clickOutsideClose: true, + darkOverlay: false, + environments: [], + styling: { + allowStyleOverwrite: true, + }, + logo: null, + brandColor: null, + highlightBorderColor: null, + }, + ]; + + vi.mocked(prisma.membership.findFirst).mockResolvedValue({ + userId, + organizationId, + role: OrganizationRole.owner, + accepted: true, + deprecatedRole: null, + }); + + vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects); + + const result = await getUserProjects(userId, organizationId); + + expect(result).toEqual(mockProjects); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + organizationId, + }, + select: expect.any(Object), + take: undefined, + skip: undefined, + }); + }); + + test("getUserProjects should return projects for member user", async () => { + const userId = createId(); + const organizationId = createId(); + const mockProjects = [ + { + id: createId(), + name: "Test Project 1", + organizationId, + createdAt: new Date(), + updatedAt: new Date(), + languages: ["en"], + recontactDays: 0, + linkSurveyBranding: true, + inAppSurveyBranding: true, + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, + clickOutsideClose: true, + darkOverlay: false, + environments: [], + styling: { + allowStyleOverwrite: true, + }, + logo: null, + brandColor: null, + highlightBorderColor: null, + }, + ]; + + vi.mocked(prisma.membership.findFirst).mockResolvedValue({ + userId, + organizationId, + role: OrganizationRole.member, + accepted: true, + deprecatedRole: null, + }); + + vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects); + + const result = await getUserProjects(userId, organizationId); + + expect(result).toEqual(mockProjects); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + organizationId, + projectTeams: { + some: { + team: { + teamUsers: { + some: { + userId, + }, + }, + }, + }, + }, + }, + select: expect.any(Object), + take: undefined, + skip: undefined, + }); + }); + + test("getUserProjects should throw ValidationError when user is not a member of organization", async () => { + const userId = createId(); + const organizationId = createId(); + + vi.mocked(prisma.membership.findFirst).mockResolvedValue(null); + + await expect(getUserProjects(userId, organizationId)).rejects.toThrow(ValidationError); + }); + + test("getUserProjects should handle pagination", async () => { + const userId = createId(); + const organizationId = createId(); + const mockProjects = [ + { + id: createId(), + name: "Test Project 1", + organizationId, + createdAt: new Date(), + updatedAt: new Date(), + languages: ["en"], + recontactDays: 0, + linkSurveyBranding: true, + inAppSurveyBranding: true, + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, + clickOutsideClose: true, + darkOverlay: false, + environments: [], + styling: { + allowStyleOverwrite: true, + }, + logo: null, + brandColor: null, + highlightBorderColor: null, + }, + ]; + + vi.mocked(prisma.membership.findFirst).mockResolvedValue({ + userId, + organizationId, + role: OrganizationRole.owner, + accepted: true, + deprecatedRole: null, + }); + + vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects); + + const page = 2; + const result = await getUserProjects(userId, organizationId, page); + + expect(result).toEqual(mockProjects); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + organizationId, + }, + select: expect.any(Object), + take: ITEMS_PER_PAGE, + skip: ITEMS_PER_PAGE * (page - 1), + }); + }); + + test("getProjects should return all projects for an organization", async () => { + const organizationId = createId(); + const mockProjects = [ + { + id: createId(), + name: "Test Project 1", + organizationId, + createdAt: new Date(), + updatedAt: new Date(), + languages: ["en"], + recontactDays: 0, + linkSurveyBranding: true, + inAppSurveyBranding: true, + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, + clickOutsideClose: true, + darkOverlay: false, + environments: [], + styling: { + allowStyleOverwrite: true, + }, + logo: null, + brandColor: null, + highlightBorderColor: null, + }, + { + id: createId(), + name: "Test Project 2", + organizationId, + createdAt: new Date(), + updatedAt: new Date(), + languages: ["en"], + recontactDays: 0, + linkSurveyBranding: true, + inAppSurveyBranding: true, + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, + clickOutsideClose: true, + darkOverlay: false, + environments: [], + styling: { + allowStyleOverwrite: true, + }, + logo: null, + brandColor: null, + highlightBorderColor: null, + }, + ]; + + vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects); + + const result = await getProjects(organizationId); + + expect(result).toEqual(mockProjects); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + organizationId, + }, + select: expect.any(Object), + take: undefined, + skip: undefined, + }); + }); + + test("getProjects should handle pagination", async () => { + const organizationId = createId(); + const mockProjects = [ + { + id: createId(), + name: "Test Project 1", + organizationId, + createdAt: new Date(), + updatedAt: new Date(), + languages: ["en"], + recontactDays: 0, + linkSurveyBranding: true, + inAppSurveyBranding: true, + config: { + channel: null, + industry: null, + }, + placement: WidgetPlacement.bottomRight, + clickOutsideClose: true, + darkOverlay: false, + environments: [], + styling: { + allowStyleOverwrite: true, + }, + logo: null, + brandColor: null, + highlightBorderColor: null, + }, + ]; + + vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects); + + const page = 2; + const result = await getProjects(organizationId, page); + + expect(result).toEqual(mockProjects); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + organizationId, + }, + select: expect.any(Object), + take: ITEMS_PER_PAGE, + skip: ITEMS_PER_PAGE * (page - 1), + }); + }); + + test("getProjects should throw DatabaseError when prisma throws", async () => { + const organizationId = createId(); + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError); + + await expect(getProjects(organizationId)).rejects.toThrow(DatabaseError); + }); + + test("getProjectsByOrganizationIds should return projects for given organization IDs", async () => { + const organizationId1 = createId(); + const organizationId2 = createId(); + const userId = createId(); + const mockProjects = [ + { + environments: [], + }, + { + environments: [], + }, + ]; + + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { + userId, + organizationId: organizationId1, + role: "owner" as any, + accepted: true, + deprecatedRole: null, + }, + { + userId, + organizationId: organizationId2, + role: "owner" as any, + accepted: true, + deprecatedRole: null, + }, + ]); + + vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any); + + const result = await getUserProjectEnvironmentsByOrganizationIds( + [organizationId1, organizationId2], + userId + ); + + expect(result).toEqual(mockProjects); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + OR: [{ organizationId: organizationId1 }, { organizationId: organizationId2 }], + }, + select: { environments: true }, + }); + }); + + test("getProjectsByOrganizationIds should return empty array when no projects are found", async () => { + const organizationId1 = createId(); + const organizationId2 = createId(); + const userId = createId(); + + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { + userId, + organizationId: organizationId1, + role: "owner" as any, + accepted: true, + deprecatedRole: null, + }, + { + userId, + organizationId: organizationId2, + role: "owner" as any, + accepted: true, + deprecatedRole: null, + }, + ]); + + vi.mocked(prisma.project.findMany).mockResolvedValue([]); + + const result = await getUserProjectEnvironmentsByOrganizationIds( + [organizationId1, organizationId2], + userId + ); + + expect(result).toEqual([]); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + OR: [{ organizationId: organizationId1 }, { organizationId: organizationId2 }], + }, + select: { environments: true }, + }); + }); + + test("getProjectsByOrganizationIds should throw DatabaseError when prisma throws", async () => { + const organizationId1 = createId(); + const organizationId2 = createId(); + const userId = createId(); + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { + userId, + organizationId: organizationId1, + role: "owner" as any, + accepted: true, + deprecatedRole: null, + }, + ]); + + vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError); + + await expect( + getUserProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2], userId) + ).rejects.toThrow(DatabaseError); + }); + + test("getProjectsByOrganizationIds should throw ValidationError with wrong input", async () => { + const userId = createId(); + await expect(getUserProjectEnvironmentsByOrganizationIds(["wrong-id"], userId)).rejects.toThrow( + ValidationError + ); + }); + + test("getProjectsByOrganizationIds should return empty array when user has no memberships", async () => { + const organizationId1 = createId(); + const organizationId2 = createId(); + const userId = createId(); + + // Mock no memberships found + vi.mocked(prisma.membership.findMany).mockResolvedValue([]); + + const result = await getUserProjectEnvironmentsByOrganizationIds( + [organizationId1, organizationId2], + userId + ); + + expect(result).toEqual([]); + expect(prisma.membership.findMany).toHaveBeenCalledWith({ + where: { + userId, + organizationId: { + in: [organizationId1, organizationId2], + }, + }, + }); + // Should not call project.findMany when no memberships + expect(prisma.project.findMany).not.toHaveBeenCalled(); + }); + + test("getProjectsByOrganizationIds should handle member role with team access", async () => { + const organizationId1 = createId(); + const organizationId2 = createId(); + const userId = createId(); + const mockProjects = [ + { + environments: [], + }, + ]; + + // Mock membership where user is a member + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { + userId, + organizationId: organizationId1, + role: "member" as any, + accepted: true, + deprecatedRole: null, + }, + ]); + + vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any); + + const result = await getUserProjectEnvironmentsByOrganizationIds( + [organizationId1, organizationId2], + userId + ); + + expect(result).toEqual(mockProjects); + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { + organizationId: organizationId1, + projectTeams: { + some: { + team: { + teamUsers: { + some: { + userId, + }, + }, + }, + }, + }, + }, + ], + }, + select: { environments: true }, + }); + }); +}); diff --git a/apps/web/lib/project/service.ts b/apps/web/lib/project/service.ts new file mode 100644 index 000000000000..cb644ed160df --- /dev/null +++ b/apps/web/lib/project/service.ts @@ -0,0 +1,236 @@ +import "server-only"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common"; +import { DatabaseError, ValidationError } from "@formbricks/types/errors"; +import type { TProject } from "@formbricks/types/project"; +import { ITEMS_PER_PAGE } from "../constants"; +import { validateInputs } from "../utils/validate"; + +const selectProject = { + id: true, + createdAt: true, + updatedAt: true, + name: true, + organizationId: true, + languages: true, + recontactDays: true, + linkSurveyBranding: true, + inAppSurveyBranding: true, + config: true, + placement: true, + clickOutsideClose: true, + darkOverlay: true, + environments: true, + styling: true, + logo: true, +}; + +export const getUserProjects = reactCache( + async (userId: string, organizationId: string, page?: number): Promise => { + validateInputs([userId, ZString], [organizationId, ZId], [page, ZOptionalNumber]); + + const orgMembership = await prisma.membership.findFirst({ + where: { + userId, + organizationId, + }, + }); + + if (!orgMembership) { + throw new ValidationError("User is not a member of this organization"); + } + + let projectWhereClause: Prisma.ProjectWhereInput = {}; + + if (orgMembership.role === "member") { + projectWhereClause = { + projectTeams: { + some: { + team: { + teamUsers: { + some: { + userId, + }, + }, + }, + }, + }, + }; + } + + try { + const projects = await prisma.project.findMany({ + where: { + organizationId, + ...projectWhereClause, + }, + select: selectProject, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + }); + return projects; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + } +); + +export const getProjects = reactCache(async (organizationId: string, page?: number): Promise => { + validateInputs([organizationId, ZId], [page, ZOptionalNumber]); + + try { + const projects = await prisma.project.findMany({ + where: { + organizationId, + }, + select: selectProject, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + }); + return projects; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}); + +export const getProjectByEnvironmentId = reactCache( + async (environmentId: string): Promise => { + validateInputs([environmentId, ZId]); + + let projectPrisma; + + try { + projectPrisma = await prisma.project.findFirst({ + where: { + environments: { + some: { + id: environmentId, + }, + }, + }, + select: selectProject, + }); + + return projectPrisma; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error getting project by environment id"); + throw new DatabaseError(error.message); + } + throw error; + } + } +); + +export const getProject = reactCache(async (projectId: string): Promise => { + let projectPrisma; + try { + projectPrisma = await prisma.project.findUnique({ + where: { + id: projectId, + }, + select: selectProject, + }); + + return projectPrisma; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}); + +export const getOrganizationProjectsCount = reactCache(async (organizationId: string): Promise => { + validateInputs([organizationId, ZId]); + + try { + const projects = await prisma.project.count({ + where: { + organizationId, + }, + }); + return projects; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}); + +export const getUserProjectEnvironmentsByOrganizationIds = reactCache( + async (organizationIds: string[], userId: string): Promise[]> => { + validateInputs([organizationIds, ZId.array()], [userId, ZId]); + try { + if (organizationIds.length === 0) { + return []; + } + + const memberships = await prisma.membership.findMany({ + where: { + userId, + organizationId: { + in: organizationIds, + }, + }, + }); + + if (memberships.length === 0) { + return []; + } + + const whereConditions: Prisma.ProjectWhereInput[] = memberships.map((membership) => { + let projectWhereClause: Prisma.ProjectWhereInput = { + organizationId: membership.organizationId, + }; + + if (membership.role === "member") { + projectWhereClause = { + ...projectWhereClause, + projectTeams: { + some: { + team: { + teamUsers: { + some: { + userId, + }, + }, + }, + }, + }, + }; + } + + return projectWhereClause; + }); + + const projects = await prisma.project.findMany({ + where: { + OR: whereConditions, + }, + select: { environments: true }, + }); + + return projects; + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(err.message); + } + + throw err; + } + } +); diff --git a/apps/web/lib/response/service.ts b/apps/web/lib/response/service.ts new file mode 100644 index 000000000000..2570ccf171c4 --- /dev/null +++ b/apps/web/lib/response/service.ts @@ -0,0 +1,591 @@ +import "server-only"; +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"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { + TResponse, + TResponseContact, + TResponseFilterCriteria, + TResponseUpdateInput, + ZResponseFilterCriteria, + ZResponseUpdateInput, +} from "@formbricks/types/responses"; +import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { TTag } from "@formbricks/types/tags"; +import { ITEMS_PER_PAGE, WEBAPP_URL } from "../constants"; +import { deleteDisplay } from "../display/service"; +import { deleteFile, putFile } from "../storage/service"; +import { getSurvey } from "../survey/service"; +import { convertToCsv, convertToXlsxBuffer } from "../utils/file-conversion"; +import { validateInputs } from "../utils/validate"; +import { + buildWhereClause, + calculateTtcTotal, + extractSurveyDetails, + getResponseContactAttributes, + getResponseHiddenFields, + getResponseMeta, + getResponsesFileName, + getResponsesJson, +} from "./utils"; + +const RESPONSES_PER_PAGE = 10; + +export const responseSelection = { + id: true, + createdAt: true, + updatedAt: true, + surveyId: true, + finished: true, + endingId: true, + data: true, + meta: true, + ttc: true, + variables: true, + contactAttributes: true, + singleUseId: true, + language: true, + displayId: true, + contact: { + select: { + id: true, + attributes: { + select: { attributeKey: true, value: true }, + }, + }, + }, + tags: { + select: { + tag: { + select: { + id: true, + createdAt: true, + updatedAt: true, + name: true, + environmentId: true, + }, + }, + }, + }, +} satisfies Prisma.ResponseSelect; + +export const getResponseContact = ( + responsePrisma: Prisma.ResponseGetPayload<{ select: typeof responseSelection }> +): TResponseContact | null => { + if (!responsePrisma.contact) return null; + + return { + id: responsePrisma.contact.id, + userId: responsePrisma.contact.attributes.find((attribute) => attribute.attributeKey.key === "userId") + ?.value as string, + }; +}; + +export const getResponsesByContactId = reactCache( + async (contactId: string, page?: number): Promise => { + validateInputs([contactId, ZId], [page, ZOptionalNumber]); + + try { + const responsePrisma = await prisma.response.findMany({ + where: { + contactId, + }, + select: responseSelection, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + orderBy: { + createdAt: "desc", + }, + }); + + if (!responsePrisma) { + throw new ResourceNotFoundError("Response from ContactId", contactId); + } + + let responses: TResponse[] = []; + + await Promise.all( + responsePrisma.map(async (response) => { + const responseContact: TResponseContact = { + id: response.contact?.id as string, + userId: response.contact?.attributes.find((attribute) => attribute.attributeKey.key === "userId") + ?.value as string, + }; + + responses.push({ + ...response, + contact: responseContact, + + tags: response.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), + }); + }) + ); + + return responses; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + } +); + +export const getResponseBySingleUseId = reactCache( + async (surveyId: string, singleUseId: string): Promise => { + validateInputs([surveyId, ZId], [singleUseId, ZString]); + + try { + const responsePrisma = await prisma.response.findUnique({ + where: { + surveyId_singleUseId: { surveyId, singleUseId }, + }, + select: responseSelection, + }); + + if (!responsePrisma) { + return null; + } + + const response: TResponse = { + ...responsePrisma, + contact: getResponseContact(responsePrisma), + tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), + }; + + return response; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + } +); + +export const getResponse = reactCache(async (responseId: string): Promise => { + validateInputs([responseId, ZId]); + + try { + const responsePrisma = await prisma.response.findUnique({ + where: { + id: responseId, + }, + select: responseSelection, + }); + + if (!responsePrisma) { + return null; + } + + const response: TResponse = { + ...responsePrisma, + contact: getResponseContact(responsePrisma), + tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), + }; + + return response; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}); + +export const getResponseFilteringValues = reactCache(async (surveyId: string) => { + validateInputs([surveyId, ZId]); + + try { + const survey = await getSurvey(surveyId); + if (!survey) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + const responses = await prisma.response.findMany({ + where: { + surveyId, + }, + select: { + data: true, + meta: true, + contactAttributes: true, + }, + }); + + const contactAttributes = getResponseContactAttributes(responses); + const meta = getResponseMeta(responses); + const hiddenFields = getResponseHiddenFields(survey, responses); + + return { contactAttributes, meta, hiddenFields }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}); + +export const getResponses = reactCache( + async ( + surveyId: string, + limit?: number, + offset?: number, + filterCriteria?: TResponseFilterCriteria, + cursor?: string + ): Promise => { + validateInputs( + [surveyId, ZId], + [limit, ZOptionalNumber], + [offset, ZOptionalNumber], + [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: whereClause, + select: responseSelection, + orderBy: [ + { + createdAt: "desc", + }, + { + id: "desc", // Secondary sort by ID for consistent pagination + }, + ], + take: limit, + skip: offset, + }); + + const transformedResponses: TResponse[] = await Promise.all( + responses.map((responsePrisma) => { + return { + ...responsePrisma, + contact: getResponseContact(responsePrisma), + tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), + }; + }) + ); + + return transformedResponses; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + } +); + +export const getResponseDownloadUrl = async ( + surveyId: string, + format: "csv" | "xlsx", + filterCriteria?: TResponseFilterCriteria +): Promise => { + validateInputs([surveyId, ZId], [format, ZString], [filterCriteria, ZResponseFilterCriteria.optional()]); + try { + const survey = await getSurvey(surveyId); + + if (!survey) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + const environmentId = survey.environmentId; + + const accessType = "private"; + const batchSize = 3000; + + // 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, + responses + ); + + const headers = [ + "No.", + "Response ID", + "Timestamp", + "Finished", + "Survey ID", + "Formbricks ID (internal)", + "User ID", + "Notes", + "Tags", + ...metaDataFields, + ...questions.flat(), + ...variables, + ...hiddenFields, + ...userAttributes, + ]; + + if (survey.isVerifyEmailEnabled) { + headers.push("Verified Email"); + } + const jsonData = getResponsesJson(survey, responses, questions, userAttributes, hiddenFields); + + const fileName = getResponsesFileName(survey?.name || "", format); + let fileBuffer: Buffer; + + if (format === "xlsx") { + fileBuffer = convertToXlsxBuffer(headers, jsonData); + } else { + const csvFile = await convertToCsv(headers, jsonData); + fileBuffer = Buffer.from(csvFile); + } + + await putFile(fileName, fileBuffer, accessType, environmentId); + + return `${WEBAPP_URL}/storage/${environmentId}/${accessType}/${fileName}`; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const getResponsesByEnvironmentId = reactCache( + async (environmentId: string, limit?: number, offset?: number): Promise => { + validateInputs([environmentId, ZId], [limit, ZOptionalNumber], [offset, ZOptionalNumber]); + + try { + const responses = await prisma.response.findMany({ + where: { + survey: { + environmentId, + }, + }, + select: responseSelection, + orderBy: [ + { + createdAt: "desc", + }, + ], + take: limit, + skip: offset, + }); + + const transformedResponses: TResponse[] = await Promise.all( + responses.map(async (responsePrisma) => { + return { + ...responsePrisma, + contact: getResponseContact(responsePrisma), + tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), + }; + }) + ); + + return transformedResponses; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + } +); + +export const updateResponse = async ( + responseId: string, + responseInput: TResponseUpdateInput +): Promise => { + validateInputs([responseId, ZId], [responseInput, ZResponseUpdateInput]); + try { + // use direct prisma call to avoid cache issues + const currentResponse = await prisma.response.findUnique({ + where: { + id: responseId, + }, + select: responseSelection, + }); + + if (!currentResponse) { + throw new ResourceNotFoundError("Response", responseId); + } + + // merge data object + const data = { + ...currentResponse.data, + ...responseInput.data, + }; + const ttc = responseInput.ttc + ? responseInput.finished + ? calculateTtcTotal(responseInput.ttc) + : responseInput.ttc + : {}; + const language = responseInput.language; + const variables = { + ...currentResponse.variables, + ...responseInput.variables, + }; + + const responsePrisma = await prisma.response.update({ + where: { + id: responseId, + }, + data: { + finished: responseInput.finished, + endingId: responseInput.endingId, + data, + ttc, + language, + variables, + }, + select: responseSelection, + }); + + const response: TResponse = { + ...responsePrisma, + contact: getResponseContact(responsePrisma), + tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), + }; + + return response; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +const findAndDeleteUploadedFilesInResponse = async (response: TResponse, survey: TSurvey): Promise => { + const fileUploadQuestions = new Set( + survey.questions + .filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload) + .map((q) => q.id) + ); + + const fileUrls = Object.entries(response.data) + .filter(([questionId]) => fileUploadQuestions.has(questionId)) + .flatMap(([, questionResponse]) => questionResponse as string[]); + + const deletionPromises = fileUrls.map(async (fileUrl) => { + try { + const { pathname } = new URL(fileUrl); + const [, environmentId, accessType, fileName] = pathname.split("/").filter(Boolean); + + if (!environmentId || !accessType || !fileName) { + throw new Error(`Invalid file path: ${pathname}`); + } + + return deleteFile(environmentId, accessType as "private" | "public", fileName); + } catch (error) { + logger.error(error, `Failed to delete file ${fileUrl}`); + } + }); + + await Promise.all(deletionPromises); +}; + +export const deleteResponse = async (responseId: string): Promise => { + validateInputs([responseId, ZId]); + try { + const responsePrisma = await prisma.response.delete({ + where: { + id: responseId, + }, + select: responseSelection, + }); + + const response: TResponse = { + ...responsePrisma, + contact: getResponseContact(responsePrisma), + + tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), + }; + + if (response.displayId) { + deleteDisplay(response.displayId); + } + const survey = await getSurvey(response.surveyId); + + if (survey) { + await findAndDeleteUploadedFilesInResponse( + { + ...responsePrisma, + contact: getResponseContact(responsePrisma), + tags: responsePrisma.tags.map((tag) => tag.tag), + }, + survey + ); + } + + return response; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const getResponseCountBySurveyId = reactCache( + async (surveyId: string, filterCriteria?: TResponseFilterCriteria): Promise => { + validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]); + + try { + const survey = await getSurvey(surveyId); + if (!survey) return 0; + + const responseCount = await prisma.response.count({ + where: { + surveyId: surveyId, + ...buildWhereClause(survey, filterCriteria), + }, + }); + return responseCount; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + } +); diff --git a/packages/lib/response/tests/__mocks__/data.mock.ts b/apps/web/lib/response/tests/__mocks__/data.mock.ts similarity index 93% rename from packages/lib/response/tests/__mocks__/data.mock.ts rename to apps/web/lib/response/tests/__mocks__/data.mock.ts index 6c833929ea1b..8bbcece69083 100644 --- a/packages/lib/response/tests/__mocks__/data.mock.ts +++ b/apps/web/lib/response/tests/__mocks__/data.mock.ts @@ -1,20 +1,16 @@ +import { mockWelcomeCard } from "@/lib/i18n/i18n.mock"; import { Prisma } from "@prisma/client"; import { isAfter, isBefore, isSameDay } from "date-fns"; -import { mockWelcomeCard } from "i18n/i18n.mock"; import { TDisplay } from "@formbricks/types/displays"; import { TResponse, TResponseFilterCriteria, TResponseUpdateInput } from "@formbricks/types/responses"; import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { TTag } from "@formbricks/types/tags"; -import { responseNoteSelect } from "../../../responseNote/service"; import { responseSelection } from "../../service"; import { constantsForTests } from "../constants"; type ResponseMock = Prisma.ResponseGetPayload<{ include: typeof responseSelection; }>; -type ResponseNoteMock = Prisma.ResponseNoteGetPayload<{ - include: typeof responseNoteSelect; -}>; export const mockEnvironmentId = "ars2tjk8hsi8oqk1uac00mo7"; export const mockContactId = "lhwy39ga2zy8by1ol1bnaiso"; @@ -34,25 +30,6 @@ export const mockMeta = { }, }; -export const mockResponseNote: ResponseNoteMock = { - id: "clnndevho0mqrqp0fm2ozul8p", - createdAt: new Date(), - updatedAt: new Date(), - text: constantsForTests.text, - isEdited: constantsForTests.boolean, - isResolved: constantsForTests.boolean, - responseId: mockResponseId, - userId: mockUserId, - response: { - id: mockResponseId, - surveyId: mockSurveyId, - }, - user: { - id: mockContactId, - name: constantsForTests.fullName, - }, -}; - export const mockContact = { id: mockContactId, userId: mockUserId, @@ -94,7 +71,6 @@ export const mockResponse: ResponseMock = { createdAt: new Date(), finished: constantsForTests.boolean, meta: mockMeta, - notes: [mockResponseNote], tags: mockTags, personId: mockContactId, updatedAt: new Date(), @@ -142,7 +118,6 @@ export const mockResponses: ResponseMock[] = [ }, language: null, tags: getMockTags(["tag1", "tag3"]), - notes: [], endingId: null, displayId: null, }, @@ -169,7 +144,6 @@ export const mockResponses: ResponseMock[] = [ person: null, language: null, tags: getMockTags(["tag1", "tag2"]), - notes: [], }, { id: "clsk7b15p001fk8iu04qpvo2f", @@ -192,7 +166,6 @@ export const mockResponses: ResponseMock[] = [ personId: mockContactId, person: null, tags: getMockTags(["tag2", "tag3"]), - notes: [], language: null, }, { @@ -216,7 +189,6 @@ export const mockResponses: ResponseMock[] = [ personId: mockContactId, person: null, tags: getMockTags(["tag1", "tag4"]), - notes: [], language: null, }, { @@ -240,7 +212,6 @@ export const mockResponses: ResponseMock[] = [ personId: mockContactId, person: null, tags: getMockTags(["tag4", "tag5"]), - notes: [], language: null, }, ]; @@ -392,8 +363,6 @@ export const mockSurveySummaryOutput = { }, summary: [ { - insights: undefined, - insightsEnabled: undefined, question: { headline: { default: "Question Text", de: "Fragetext" }, id: "ars2tjk8hsi8oqk1uac00mo8", @@ -514,6 +483,7 @@ export const mockSurvey: TSurvey = { autoComplete: null, isVerifyEmailEnabled: false, projectOverwrites: null, + recaptcha: null, styling: null, surveyClosedMessage: null, singleUse: { @@ -521,7 +491,6 @@ export const mockSurvey: TSurvey = { isEncrypted: true, }, pin: null, - resultShareKey: null, triggers: [], languages: [], segment: [], diff --git a/packages/lib/response/tests/constants.ts b/apps/web/lib/response/tests/constants.ts similarity index 100% rename from packages/lib/response/tests/constants.ts rename to apps/web/lib/response/tests/constants.ts diff --git a/apps/web/lib/response/tests/response.test.ts b/apps/web/lib/response/tests/response.test.ts new file mode 100644 index 000000000000..a03ad8023f53 --- /dev/null +++ b/apps/web/lib/response/tests/response.test.ts @@ -0,0 +1,420 @@ +import { + getMockUpdateResponseInput, + mockContact, + mockDisplay, + mockEnvironmentId, + mockResponse, + mockResponseData, + mockSingleUseId, + mockSurveyId, + mockSurveySummaryOutput, + mockTags, +} from "./__mocks__/data.mock"; +import { prisma } from "@/lib/__mocks__/database"; +import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary"; +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test } from "vitest"; +import { testInputValidation } from "vitestSetup"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TResponse } from "@formbricks/types/responses"; +import { TTag } from "@formbricks/types/tags"; +import { + mockContactAttributeKey, + mockOrganizationOutput, + mockSurveyOutput, +} from "../../survey/__mock__/survey.mock"; +import { + deleteResponse, + getResponse, + getResponseBySingleUseId, + getResponseCountBySurveyId, + getResponseDownloadUrl, + getResponsesByEnvironmentId, + updateResponse, +} from "../service"; + +const expectedResponseWithoutPerson: TResponse = { + ...mockResponse, + contact: null, + tags: mockTags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), +}; + +beforeEach(() => { + // @ts-expect-error + prisma.response.create.mockImplementation(async (args) => { + if (args.data.contact && args.data.contact.connect) { + return { + ...mockResponse, + contact: mockContact, + }; + } + + return mockResponse; + }); + + // mocking the person findFirst call as it is used in the transformPrismaPerson function + prisma.contact.findFirst.mockResolvedValue(mockContact); + + prisma.response.findUnique.mockResolvedValue(mockResponse); + + // @ts-expect-error + prisma.response.update.mockImplementation(async (args) => { + if (args.data.finished === true) { + return { + ...mockResponse, + finished: true, + data: mockResponseData, + }; + } + + return { + ...mockResponse, + finished: false, + data: mockResponseData, + }; + }); + + prisma.response.findMany.mockResolvedValue([mockResponse]); + prisma.response.delete.mockResolvedValue(mockResponse); + + prisma.display.delete.mockResolvedValue({ ...mockDisplay, status: "seen" }); + + prisma.response.count.mockResolvedValue(1); + + prisma.organization.findFirst.mockResolvedValue(mockOrganizationOutput); + prisma.organization.findUnique.mockResolvedValue(mockOrganizationOutput); + prisma.project.findMany.mockResolvedValue([]); + // @ts-expect-error + prisma.response.aggregate.mockResolvedValue({ _count: { id: 1 } }); +}); + +describe("Tests for getResponsesBySingleUseId", () => { + describe("Happy Path", () => { + test("Retrieves responses linked to a specific single-use ID", async () => { + const responses = await getResponseBySingleUseId(mockSurveyId, mockSingleUseId); + expect(responses).toEqual(expectedResponseWithoutPerson); + }); + }); + + describe("Sad Path", () => { + testInputValidation(getResponseBySingleUseId, "123#", "123#"); + + test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => { + const mockErrorMessage = "Mock error message"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + + prisma.response.findUnique.mockRejectedValue(errToThrow); + + await expect(getResponseBySingleUseId(mockSurveyId, mockSingleUseId)).rejects.toThrow(DatabaseError); + }); + + test("Throws a generic Error for other exceptions", async () => { + const mockErrorMessage = "Mock error message"; + prisma.response.findUnique.mockRejectedValue(new Error(mockErrorMessage)); + + await expect(getResponseBySingleUseId(mockSurveyId, mockSingleUseId)).rejects.toThrow(Error); + }); + }); +}); + +describe("Tests for getResponse service", () => { + describe("Happy Path", () => { + test("Retrieves a specific response by its ID", async () => { + const response = await getResponse(mockResponse.id); + expect(response).toEqual(expectedResponseWithoutPerson); + }); + }); + + describe("Sad Path", () => { + testInputValidation(getResponse, "123#"); + + test("Throws ResourceNotFoundError if no response is found", async () => { + prisma.response.findUnique.mockResolvedValue(null); + const response = await getResponse(mockResponse.id); + expect(response).toBeNull(); + }); + + test("Throws DatabaseError on PrismaClientKnownRequestError", async () => { + const mockErrorMessage = "Mock error message"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + + prisma.response.findUnique.mockRejectedValue(errToThrow); + + await expect(getResponse(mockResponse.id)).rejects.toThrow(DatabaseError); + }); + + test("Throws a generic Error for other unexpected issues", async () => { + const mockErrorMessage = "Mock error message"; + prisma.response.findUnique.mockRejectedValue(new Error(mockErrorMessage)); + + await expect(getResponse(mockResponse.id)).rejects.toThrow(Error); + }); + }); +}); + +describe("Tests for getSurveySummary service", () => { + describe("Happy Path", () => { + test("Returns a summary of the survey responses", async () => { + prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput); + prisma.response.findMany.mockResolvedValue([mockResponse]); + prisma.contactAttributeKey.findMany.mockResolvedValueOnce([mockContactAttributeKey]); + + const summary = await getSurveySummary(mockSurveyId); + expect(summary).toEqual(mockSurveySummaryOutput); + }); + }); + + describe("Sad Path", () => { + testInputValidation(getSurveySummary, 1); + + test("Throws DatabaseError on PrismaClientKnownRequestError", 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.findMany.mockRejectedValue(errToThrow); + prisma.contactAttributeKey.findMany.mockResolvedValueOnce([mockContactAttributeKey]); + + await expect(getSurveySummary(mockSurveyId)).rejects.toThrow(DatabaseError); + }); + + test("Throws a generic Error for unexpected problems", async () => { + const mockErrorMessage = "Mock error message"; + + prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput); + prisma.response.findMany.mockRejectedValue(new Error(mockErrorMessage)); + prisma.contactAttributeKey.findMany.mockResolvedValueOnce([mockContactAttributeKey]); + + await expect(getSurveySummary(mockSurveyId)).rejects.toThrow(Error); + }); + }); +}); + +describe("Tests for getResponseDownloadUrl service", () => { + describe("Happy Path", () => { + test("Returns a download URL for the csv response file", async () => { + prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput); + prisma.response.count.mockResolvedValue(1); + prisma.response.findMany.mockResolvedValue([mockResponse]); + + const url = await getResponseDownloadUrl(mockSurveyId, "csv"); + const fileExtension = url.split(".").pop(); + expect(fileExtension).toEqual("csv"); + }); + + test("Returns a download URL for the xlsx response file", async () => { + prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput); + prisma.response.count.mockResolvedValue(1); + prisma.response.findMany.mockResolvedValue([mockResponse]); + + const url = await getResponseDownloadUrl(mockSurveyId, "xlsx", { finished: true }); + const fileExtension = url.split(".").pop(); + expect(fileExtension).toEqual("xlsx"); + }); + }); + + describe("Sad Path", () => { + testInputValidation(getResponseDownloadUrl, mockSurveyId, 123); + + test("Throws error if response file is of different format than expected", async () => { + prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput); + prisma.response.count.mockResolvedValue(1); + prisma.response.findMany.mockResolvedValue([mockResponse]); + + const url = await getResponseDownloadUrl(mockSurveyId, "csv", { finished: true }); + const fileExtension = url.split(".").pop(); + expect(fileExtension).not.toEqual("xlsx"); + }); + + 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.findMany.mockRejectedValue(errToThrow); + + await expect(getResponseDownloadUrl(mockSurveyId, "csv")).rejects.toThrow(DatabaseError); + }); + + 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.mockResolvedValue(1); + prisma.response.findMany.mockRejectedValue(errToThrow); + + await expect(getResponseDownloadUrl(mockSurveyId, "csv")).rejects.toThrow(DatabaseError); + }); + + test("Throws a generic Error for unexpected problems", async () => { + const mockErrorMessage = "Mock error message"; + + // error from getSurvey + prisma.survey.findUnique.mockRejectedValue(new Error(mockErrorMessage)); + + await expect(getResponseDownloadUrl(mockSurveyId, "xlsx")).rejects.toThrow(Error); + }); + }); +}); + +describe("Tests for getResponsesByEnvironmentId", () => { + describe("Happy Path", () => { + test("Obtains all responses associated with a specific environment ID", async () => { + const responses = await getResponsesByEnvironmentId(mockEnvironmentId); + expect(responses).toEqual([expectedResponseWithoutPerson]); + }); + }); + + describe("Sad Path", () => { + testInputValidation(getResponsesByEnvironmentId, "123#"); + + test("Throws DatabaseError on PrismaClientKnownRequestError", async () => { + const mockErrorMessage = "Mock error message"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + + prisma.response.findMany.mockRejectedValue(errToThrow); + + await expect(getResponsesByEnvironmentId(mockEnvironmentId)).rejects.toThrow(DatabaseError); + }); + + test("Throws a generic Error for any other unhandled exceptions", async () => { + const mockErrorMessage = "Mock error message"; + prisma.response.findMany.mockRejectedValue(new Error(mockErrorMessage)); + + await expect(getResponsesByEnvironmentId(mockEnvironmentId)).rejects.toThrow(Error); + }); + }); +}); + +describe("Tests for updateResponse Service", () => { + describe("Happy Path", () => { + test("Updates a response (finished = true)", async () => { + const response = await updateResponse(mockResponse.id, getMockUpdateResponseInput(true)); + expect(response).toEqual({ + ...expectedResponseWithoutPerson, + data: mockResponseData, + }); + }); + + test("Updates a response (finished = false)", async () => { + const response = await updateResponse(mockResponse.id, getMockUpdateResponseInput(false)); + expect(response).toEqual({ + ...expectedResponseWithoutPerson, + finished: false, + data: mockResponseData, + }); + }); + }); + + describe("Sad Path", () => { + testInputValidation(updateResponse, "123#", {}); + + test("Throws ResourceNotFoundError if no response is found", async () => { + prisma.response.findUnique.mockResolvedValue(null); + await expect(updateResponse(mockResponse.id, getMockUpdateResponseInput())).rejects.toThrow( + ResourceNotFoundError + ); + }); + + test("Throws DatabaseError on PrismaClientKnownRequestError", async () => { + const mockErrorMessage = "Mock error message"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + + prisma.response.update.mockRejectedValue(errToThrow); + + await expect(updateResponse(mockResponse.id, getMockUpdateResponseInput())).rejects.toThrow( + DatabaseError + ); + }); + + test("Throws a generic Error for other unexpected issues", async () => { + const mockErrorMessage = "Mock error message"; + prisma.response.update.mockRejectedValue(new Error(mockErrorMessage)); + + await expect(updateResponse(mockResponse.id, getMockUpdateResponseInput())).rejects.toThrow(Error); + }); + }); +}); + +describe("Tests for deleteResponse service", () => { + describe("Happy Path", () => { + test("Successfully deletes a response based on its ID", async () => { + const response = await deleteResponse(mockResponse.id); + expect(response).toEqual(expectedResponseWithoutPerson); + }); + }); + + describe("Sad Path", () => { + testInputValidation(deleteResponse, "123#"); + + test("Throws DatabaseError on PrismaClientKnownRequestError", async () => { + const mockErrorMessage = "Mock error message"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + + prisma.response.delete.mockRejectedValue(errToThrow); + + await expect(deleteResponse(mockResponse.id)).rejects.toThrow(DatabaseError); + }); + + test("Throws a generic Error for any unhandled exception during deletion", async () => { + const mockErrorMessage = "Mock error message"; + prisma.response.delete.mockRejectedValue(new Error(mockErrorMessage)); + + await expect(deleteResponse(mockResponse.id)).rejects.toThrow(Error); + }); + }); +}); + +describe("Tests for getResponseCountBySurveyId service", () => { + describe("Happy Path", () => { + test("Counts the total number of responses for a given survey ID", async () => { + prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput); + + const count = await getResponseCountBySurveyId(mockSurveyId); + expect(count).toEqual(1); + }); + + test("Returns zero count when there are no responses for a given survey ID", async () => { + prisma.response.count.mockResolvedValue(0); + const count = await getResponseCountBySurveyId(mockSurveyId); + expect(count).toEqual(0); + }); + }); + + describe("Sad Path", () => { + testInputValidation(getResponseCountBySurveyId, "123#"); + + test("Throws a generic Error for other unexpected issues", async () => { + const mockErrorMessage = "Mock error message"; + prisma.response.count.mockRejectedValue(new Error(mockErrorMessage)); + prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput); + + await expect(getResponseCountBySurveyId(mockSurveyId)).rejects.toThrow(Error); + }); + }); +}); diff --git a/apps/web/lib/response/utils.test.ts b/apps/web/lib/response/utils.test.ts new file mode 100644 index 000000000000..cf02238b84e1 --- /dev/null +++ b/apps/web/lib/response/utils.test.ts @@ -0,0 +1,734 @@ +import { Prisma } from "@prisma/client"; +import { describe, expect, test } from "vitest"; +import { TResponse } from "@formbricks/types/responses"; +import { + TSurvey, + TSurveyOpenTextQuestion, + TSurveyQuestion, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; +import { + buildWhereClause, + calculateTtcTotal, + extracMetadataKeys, + extractChoiceIdsFromResponse, + extractSurveyDetails, + generateAllPermutationsOfSubsets, + getResponseContactAttributes, + getResponseHiddenFields, + getResponseMeta, + getResponsesFileName, + getResponsesJson, +} from "./utils"; + +describe("Response Utils", () => { + describe("calculateTtcTotal", () => { + test("should calculate total time correctly", () => { + const ttc = { + question1: 10, + question2: 20, + question3: 30, + }; + const result = calculateTtcTotal(ttc); + expect(result._total).toBe(60); + }); + + test("should handle empty ttc object", () => { + const ttc = {}; + const result = calculateTtcTotal(ttc); + expect(result._total).toBe(0); + }); + }); + + describe("buildWhereClause", () => { + const mockSurvey: Partial = { + id: "survey1", + name: "Test Survey", + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Question 1" }, + required: true, + choices: [ + { id: "1", label: { default: "Option 1" } }, + { id: "other", label: { default: "Other" } }, + ], + shuffleOption: "none", + isDraft: false, + }, + ], + type: "app", + hiddenFields: { enabled: true, fieldIds: [] }, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + createdBy: "user1", + status: "draft", + }; + + test("should build where clause with finished filter", () => { + const filterCriteria = { finished: true }; + const result = buildWhereClause(mockSurvey as TSurvey, filterCriteria); + expect(result.AND).toContainEqual({ finished: true }); + }); + + test("should build where clause with date range", () => { + const filterCriteria = { + createdAt: { + min: new Date("2024-01-01"), + max: new Date("2024-12-31"), + }, + }; + const result = buildWhereClause(mockSurvey as TSurvey, filterCriteria); + expect(result.AND).toContainEqual({ + createdAt: { + gte: new Date("2024-01-01"), + lte: new Date("2024-12-31"), + }, + }); + }); + + test("should build where clause with tags", () => { + const filterCriteria = { + tags: { + applied: ["tag1", "tag2"], + notApplied: ["tag3"], + }, + }; + const result = buildWhereClause(mockSurvey as TSurvey, filterCriteria); + expect(result.AND).toHaveLength(1); + }); + + test("should build where clause with contact attributes", () => { + const filterCriteria = { + contactAttributes: { + email: { op: "equals" as const, value: "test@example.com" }, + }, + }; + const result = buildWhereClause(mockSurvey as TSurvey, filterCriteria); + expect(result.AND).toHaveLength(1); + }); + }); + + describe("buildWhereClause – others & meta filters", () => { + const baseSurvey: Partial = { + id: "s1", + name: "Survey", + questions: [], + type: "app", + hiddenFields: { enabled: false, fieldIds: [] }, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "e1", + createdBy: "u1", + status: "inProgress", + }; + + test("others: equals & notEquals", () => { + const criteria = { + others: { + Language: { op: "equals" as const, value: "en" }, + Region: { op: "notEquals" as const, value: "APAC" }, + }, + }; + const result = buildWhereClause(baseSurvey as TSurvey, criteria); + expect(result.AND).toEqual([ + { + AND: [{ language: "en" }, { region: { not: "APAC" } }], + }, + ]); + }); + + test("meta: equals & notEquals map to userAgent paths", () => { + const criteria = { + meta: { + browser: { op: "equals" as const, value: "Chrome" }, + os: { op: "notEquals" as const, value: "Windows" }, + }, + }; + const result = buildWhereClause(baseSurvey as TSurvey, criteria); + expect(result.AND).toEqual([ + { + AND: [ + { meta: { path: ["userAgent", "browser"], equals: "Chrome" } }, + { meta: { path: ["userAgent", "os"], not: "Windows" } }, + ], + }, + ]); + }); + }); + + describe("buildWhereClause – data‐field filter operations", () => { + const textSurvey: Partial = { + id: "s2", + name: "TextSurvey", + questions: [ + { + id: "qText", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Text Q" }, + required: false, + isDraft: false, + charLimit: {}, + inputType: "text", + }, + { + id: "qNum", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Num Q" }, + required: false, + isDraft: false, + charLimit: {}, + inputType: "number", + }, + ], + type: "app", + hiddenFields: { enabled: false, fieldIds: [] }, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "e2", + createdBy: "u2", + status: "inProgress", + }; + + const ops: Array<[keyof TSurveyQuestionTypeEnum | string, any, any]> = [ + ["submitted", { op: "submitted" }, { path: ["qText"], not: Prisma.DbNull }], + ["filledOut", { op: "filledOut" }, { path: ["qText"], not: [] }], + ["skipped", { op: "skipped" }, "OR"], + ["equals", { op: "equals", value: "foo" }, { path: ["qText"], equals: "foo" }], + ["notEquals", { op: "notEquals", value: "bar" }, "NOT"], + ["lessThan", { op: "lessThan", value: 5 }, { path: ["qNum"], lt: 5 }], + ["lessEqual", { op: "lessEqual", value: 10 }, { path: ["qNum"], lte: 10 }], + ["greaterThan", { op: "greaterThan", value: 1 }, { path: ["qNum"], gt: 1 }], + ["greaterEqual", { op: "greaterEqual", value: 2 }, { path: ["qNum"], gte: 2 }], + [ + "includesAll", + { op: "includesAll", value: ["a", "b"] }, + { path: ["qText"], array_contains: ["a", "b"] }, + ], + ]; + + ops.forEach(([name, filter, expected]) => { + test(name as string, () => { + const result = buildWhereClause(textSurvey as TSurvey, { + data: { + [["submitted", "filledOut", "equals", "includesAll"].includes(name as string) ? "qText" : "qNum"]: + filter, + }, + }); + // for OR/NOT cases we just ensure the operator key exists + if (expected === "OR" || expected === "NOT") { + expect(JSON.stringify(result)).toMatch( + new RegExp(name === "skipped" ? `"OR":\\s*\\[` : `"not":"${filter.value}"`) + ); + } else { + expect(result.AND).toEqual([ + { + AND: [{ data: expected }], + }, + ]); + } + }); + }); + + test("uploaded & notUploaded", () => { + const res1 = buildWhereClause(textSurvey as TSurvey, { data: { qText: { op: "uploaded" } } }); + expect(res1.AND).toContainEqual({ + AND: [{ data: { path: ["qText"], not: "skipped" } }], + }); + + const res2 = buildWhereClause(textSurvey as TSurvey, { data: { qText: { op: "notUploaded" } } }); + expect(JSON.stringify(res2)).toMatch(/"equals":"skipped"/); + expect(JSON.stringify(res2)).toMatch(/"equals":{}/); + }); + + test("clicked, accepted & booked", () => { + ["clicked", "accepted", "booked"].forEach((status) => { + const key = status as "clicked" | "accepted" | "booked"; + const res = buildWhereClause(textSurvey as TSurvey, { data: { qText: { op: key } } }); + expect(res.AND).toEqual([{ AND: [{ data: { path: ["qText"], equals: status } }] }]); + }); + }); + + test("matrix", () => { + const matrixSurvey: Partial = { + id: "s3", + name: "MatrixSurvey", + questions: [ + { + id: "qM", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Matrix" }, + required: false, + rows: [{ default: "R1" }], + columns: [{ default: "C1" }], + shuffleOption: "none", + isDraft: false, + }, + ], + type: "app", + hiddenFields: { enabled: false, fieldIds: [] }, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "e3", + createdBy: "u3", + status: "inProgress", + }; + const res = buildWhereClause(matrixSurvey as TSurvey, { + data: { qM: { op: "matrix", value: { R1: "foo" } } }, + }); + expect(res.AND).toEqual([ + { + AND: [ + { + data: { path: ["qM", "R1"], equals: "foo" }, + }, + ], + }, + ]); + }); + }); + + describe("getResponsesFileName", () => { + test("should generate correct filename", () => { + const surveyName = "Test Survey"; + const extension = "csv"; + const result = getResponsesFileName(surveyName, extension); + expect(result).toContain("export-test_survey-"); + }); + }); + + describe("extracMetadataKeys", () => { + test("should extract metadata keys correctly", () => { + const meta = { + userAgent: { browser: "Chrome", os: "Windows", device: "Desktop" }, + country: "US", + source: "direct", + }; + const result = extracMetadataKeys(meta); + expect(result).toContain("userAgent - browser"); + expect(result).toContain("userAgent - os"); + expect(result).toContain("userAgent - device"); + expect(result).toContain("country"); + expect(result).toContain("source"); + }); + + test("should handle empty metadata", () => { + const result = extracMetadataKeys({}); + expect(result).toEqual([]); + }); + }); + + describe("extractSurveyDetails", () => { + const mockSurvey: Partial = { + id: "survey1", + name: "Test Survey", + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Question 1" }, + required: true, + choices: [ + { id: "1", label: { default: "Option 1" } }, + { id: "2", label: { default: "Option 2" } }, + ], + shuffleOption: "none", + isDraft: false, + }, + { + id: "q2", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Matrix Question" }, + required: true, + rows: [{ default: "Row 1" }, { default: "Row 2" }], + columns: [{ default: "Column 1" }, { default: "Column 2" }], + shuffleOption: "none", + isDraft: false, + }, + ], + type: "app", + hiddenFields: { enabled: true, fieldIds: ["hidden1"] }, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + createdBy: "user1", + status: "draft", + }; + + const mockResponses: Partial[] = [ + { + id: "response1", + surveyId: "survey1", + data: {}, + meta: { userAgent: { browser: "Chrome" } }, + contactAttributes: { email: "test@example.com" }, + finished: true, + createdAt: new Date(), + updatedAt: new Date(), + tags: [], + }, + ]; + + test("should extract survey details correctly", () => { + const result = extractSurveyDetails(mockSurvey as TSurvey, mockResponses as TResponse[]); + expect(result.metaDataFields).toContain("userAgent - browser"); + expect(result.questions).toHaveLength(2); // 1 regular question + 2 matrix rows + expect(result.hiddenFields).toContain("hidden1"); + expect(result.userAttributes).toContain("email"); + }); + }); + + describe("getResponsesJson", () => { + const mockSurvey: Partial = { + id: "survey1", + name: "Test Survey", + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Question 1" }, + required: true, + choices: [ + { id: "1", label: { default: "Option 1" } }, + { id: "2", label: { default: "Option 2" } }, + ], + shuffleOption: "none", + isDraft: false, + }, + ], + type: "app", + hiddenFields: { enabled: true, fieldIds: [] }, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + createdBy: "user1", + status: "draft", + }; + + const mockResponses: Partial[] = [ + { + id: "response1", + surveyId: "survey1", + data: { q1: "answer1" }, + meta: { userAgent: { browser: "Chrome" } }, + contactAttributes: { email: "test@example.com" }, + finished: true, + createdAt: new Date(), + updatedAt: new Date(), + tags: [], + }, + ]; + + test("should generate correct JSON data", () => { + const questionsHeadlines = [["1. Question 1"]]; + const userAttributes = ["email"]; + const hiddenFields: string[] = []; + const result = getResponsesJson( + mockSurvey as TSurvey, + mockResponses as TResponse[], + questionsHeadlines, + userAttributes, + hiddenFields + ); + expect(result[0]["Response ID"]).toBe("response1"); + expect(result[0]["userAgent - browser"]).toBe("Chrome"); + expect(result[0]["1. Question 1"]).toBe("answer1"); + expect(result[0]["email"]).toBe("test@example.com"); + }); + }); + + describe("getResponseContactAttributes", () => { + test("should extract contact attributes correctly", () => { + const responses = [ + { + contactAttributes: { email: "test1@example.com", name: "Test 1" }, + data: {}, + meta: {}, + }, + { + contactAttributes: { email: "test2@example.com", name: "Test 2" }, + data: {}, + meta: {}, + }, + ]; + const result = getResponseContactAttributes( + responses as Pick[] + ); + expect(result.email).toContain("test1@example.com"); + expect(result.email).toContain("test2@example.com"); + expect(result.name).toContain("Test 1"); + expect(result.name).toContain("Test 2"); + }); + + test("should handle empty responses", () => { + const result = getResponseContactAttributes([]); + expect(result).toEqual({}); + }); + }); + + describe("getResponseMeta", () => { + test("should extract meta data correctly", () => { + const responses = [ + { + contactAttributes: {}, + data: {}, + meta: { + userAgent: { browser: "Chrome", os: "Windows" }, + country: "US", + }, + }, + { + contactAttributes: {}, + data: {}, + meta: { + userAgent: { browser: "Firefox", os: "MacOS" }, + country: "UK", + }, + }, + ]; + const result = getResponseMeta(responses as Pick[]); + expect(result.browser).toContain("Chrome"); + expect(result.browser).toContain("Firefox"); + expect(result.os).toContain("Windows"); + expect(result.os).toContain("MacOS"); + }); + + test("should handle empty responses", () => { + const result = getResponseMeta([]); + expect(result).toEqual({}); + }); + }); + + describe("getResponseHiddenFields", () => { + const mockSurvey: Partial = { + id: "survey1", + name: "Test Survey", + questions: [], + type: "app", + hiddenFields: { enabled: true, fieldIds: ["hidden1", "hidden2"] }, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: "env1", + createdBy: "user1", + status: "draft", + }; + + test("should extract hidden fields correctly", () => { + const responses = [ + { + contactAttributes: {}, + data: { hidden1: "value1", hidden2: "value2" }, + meta: {}, + }, + { + contactAttributes: {}, + data: { hidden1: "value3", hidden2: "value4" }, + meta: {}, + }, + ]; + const result = getResponseHiddenFields( + mockSurvey as TSurvey, + responses as Pick[] + ); + expect(result.hidden1).toContain("value1"); + expect(result.hidden1).toContain("value3"); + expect(result.hidden2).toContain("value2"); + expect(result.hidden2).toContain("value4"); + }); + + test("should handle empty responses", () => { + const result = getResponseHiddenFields(mockSurvey as TSurvey, []); + expect(result).toEqual({ + hidden1: [], + hidden2: [], + }); + }); + }); + + describe("generateAllPermutationsOfSubsets", () => { + test("with empty array returns empty", () => { + expect(generateAllPermutationsOfSubsets([])).toEqual([]); + }); + + test("with two elements returns 4 permutations", () => { + const out = generateAllPermutationsOfSubsets(["x", "y"]); + expect(out).toEqual(expect.arrayContaining([["x"], ["y"], ["x", "y"], ["y", "x"]])); + expect(out).toHaveLength(4); + }); + }); +}); + +describe("extractChoiceIdsFromResponse", () => { + const multipleChoiceMultiQuestion: TSurveyQuestion = { + id: "multi-choice-id", + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + headline: { default: "Select multiple options" }, + required: false, + choices: [ + { + id: "choice-1", + label: { default: "Option 1", es: "Opción 1" }, + }, + { + id: "choice-2", + label: { default: "Option 2", es: "Opción 2" }, + }, + { + id: "choice-3", + label: { default: "Option 3", es: "Opción 3" }, + }, + ], + }; + + const multipleChoiceSingleQuestion: TSurveyQuestion = { + id: "single-choice-id", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Select one option" }, + required: false, + choices: [ + { + id: "choice-a", + label: { default: "Choice A", fr: "Choix A" }, + }, + { + id: "choice-b", + label: { default: "Choice B", fr: "Choix B" }, + }, + ], + }; + + const textQuestion: TSurveyOpenTextQuestion = { + id: "text-id", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "What do you think?" }, + required: false, + inputType: "text", + charLimit: { enabled: false, min: 0, max: 0 }, + }; + + describe("multipleChoiceMulti questions", () => { + test("should extract choice IDs from array response with default language", () => { + const responseValue = ["Option 1", "Option 3"]; + const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default"); + + expect(result).toEqual(["choice-1", "choice-3"]); + }); + + test("should extract choice IDs from array response with specific language", () => { + const responseValue = ["Opción 1", "Opción 2"]; + const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "es"); + + expect(result).toEqual(["choice-1", "choice-2"]); + }); + + test("should fall back to checking all language values when exact language match fails", () => { + const responseValue = ["Opción 1", "Option 2"]; + const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default"); + + expect(result).toEqual(["choice-1", "choice-2"]); + }); + + test("should render other option when non-matching choice is selected", () => { + const responseValue = ["Option 1", "Non-existent option", "Option 3"]; + const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default"); + + expect(result).toEqual(["choice-1", "other", "choice-3"]); + }); + + test("should return empty array for empty response", () => { + const responseValue: string[] = []; + const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default"); + + expect(result).toEqual([]); + }); + }); + + describe("multipleChoiceSingle questions", () => { + test("should extract choice ID from string response with default language", () => { + const responseValue = "Choice A"; + const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default"); + + expect(result).toEqual(["choice-a"]); + }); + + test("should extract choice ID from string response with specific language", () => { + const responseValue = "Choix B"; + const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "fr"); + + expect(result).toEqual(["choice-b"]); + }); + + test("should fall back to checking all language values for single choice", () => { + const responseValue = "Choix A"; + const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default"); + + expect(result).toEqual(["choice-a"]); + }); + + test("should return empty array for empty string response", () => { + const responseValue = ""; + const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default"); + + expect(result).toEqual([]); + }); + }); + + describe("edge cases", () => { + test("should return empty array for non-multiple choice questions", () => { + const responseValue = "Some text response"; + const result = extractChoiceIdsFromResponse(responseValue, textQuestion, "default"); + + expect(result).toEqual([]); + }); + + test("should handle missing language parameter by defaulting to 'default'", () => { + const responseValue = "Option 1"; + const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion); + + expect(result).toEqual(["choice-1"]); + }); + + test("should handle numeric or other types by returning empty array", () => { + const responseValue = 123; + const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default"); + + expect(result).toEqual([]); + }); + + test("should handle object responses by returning empty array", () => { + const responseValue = { invalid: "object" }; + const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default"); + + expect(result).toEqual([]); + }); + }); + + describe("language handling", () => { + test("should use provided language parameter", () => { + const responseValue = ["Opción 1"]; + const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "es"); + + expect(result).toEqual(["choice-1"]); + }); + + test("should handle null language parameter by defaulting to 'default'", () => { + const responseValue = ["Option 1"]; + const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, null as any); + + expect(result).toEqual(["choice-1"]); + }); + + test("should handle undefined language parameter by defaulting to 'default'", () => { + const responseValue = ["Option 1"]; + const result = extractChoiceIdsFromResponse( + responseValue, + multipleChoiceMultiQuestion, + undefined as any + ); + + expect(result).toEqual(["choice-1"]); + }); + }); +}); diff --git a/apps/web/lib/response/utils.ts b/apps/web/lib/response/utils.ts new file mode 100644 index 000000000000..7f4a05495a9b --- /dev/null +++ b/apps/web/lib/response/utils.ts @@ -0,0 +1,847 @@ +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { Prisma } from "@prisma/client"; +import { + TResponse, + TResponseDataValue, + TResponseFilterCriteria, + TResponseHiddenFieldsFilter, + TResponseTtc, + TSurveyContactAttributes, + TSurveyMetaFieldFilter, +} from "@formbricks/types/responses"; +import { + TSurvey, + TSurveyMultipleChoiceQuestion, + TSurveyPictureSelectionQuestion, + TSurveyQuestion, + TSurveyRankingQuestion, +} from "@formbricks/types/surveys/types"; +import { processResponseData } from "../responses"; +import { getTodaysDateTimeFormatted } from "../time"; +import { getFormattedDateTimeString } from "../utils/datetime"; +import { sanitizeString } from "../utils/strings"; + +/** + * Extracts choice IDs from response values for multiple choice questions + * @param responseValue - The response value (string for single choice, array for multi choice) + * @param question - The survey question containing choices + * @param language - The language to match against (defaults to "default") + * @returns Array of choice IDs + */ +export const extractChoiceIdsFromResponse = ( + responseValue: TResponseDataValue, + question: TSurveyQuestion, + language: string = "default" +): string[] => { + // Type guard to ensure the question has choices + if ( + question.type !== "multipleChoiceMulti" && + question.type !== "multipleChoiceSingle" && + question.type !== "ranking" && + question.type !== "pictureSelection" + ) { + return []; + } + const isPictureSelection = question.type === "pictureSelection"; + + if (!responseValue) { + return []; + } + + // For picture selection questions, the response value is already choice ID(s) + if (isPictureSelection) { + if (Array.isArray(responseValue)) { + // Multi-selection: array of choice IDs + return responseValue.filter((id): id is string => typeof id === "string"); + } else if (typeof responseValue === "string") { + // Single selection: single choice ID + return [responseValue]; + } + return []; + } + + const defaultLanguage = language ?? "default"; + + // Helper function to find choice by label - eliminates duplication + const findChoiceByLabel = (choiceLabel: string): string | null => { + const targetChoice = question.choices.find((c) => { + // Try exact language match first + if (c.label[defaultLanguage] === choiceLabel) { + return true; + } + // Fall back to checking all language values + return Object.values(c.label).includes(choiceLabel); + }); + return targetChoice?.id || "other"; + }; + + if (Array.isArray(responseValue)) { + // Multiple choice case - response is an array of selected choice labels + return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null); + } else if (typeof responseValue === "string") { + // Single choice case - response is a single choice label + const choiceId = findChoiceByLabel(responseValue); + return choiceId ? [choiceId] : []; + } + + return []; +}; + +export const getChoiceIdByValue = ( + value: string, + question: TSurveyMultipleChoiceQuestion | TSurveyRankingQuestion | TSurveyPictureSelectionQuestion +) => { + if (question.type === "pictureSelection") { + return question.choices.find((choice) => choice.imageUrl === value)?.id ?? "other"; + } + + return question.choices.find((choice) => choice.label.default === value)?.id ?? "other"; +}; + +export const calculateTtcTotal = (ttc: TResponseTtc) => { + const result = { ...ttc }; + result._total = Object.values(result).reduce((acc: number, val: number) => acc + val, 0); + + return result; +}; + +const createFilterTags = (tags: TResponseFilterCriteria["tags"]) => { + if (!tags) return []; + + const filterTags: Record[] = []; + + if (tags?.applied) { + const appliedTags = tags.applied.map((name) => ({ + tags: { + some: { + tag: { + name, + }, + }, + }, + })); + filterTags.push(appliedTags); + } + + if (tags?.notApplied) { + const notAppliedTags = { + tags: { + every: { + tag: { + name: { + notIn: tags.notApplied, + }, + }, + }, + }, + }; + + filterTags.push(notAppliedTags); + } + + return filterTags.flat(); +}; + +export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilterCriteria) => { + const whereClause: Prisma.ResponseWhereInput["AND"] = []; + + // For finished + if (filterCriteria?.finished !== undefined) { + whereClause.push({ + finished: filterCriteria?.finished, + }); + } + + // For Date range + if (filterCriteria?.createdAt) { + const createdAt: { lte?: Date; gte?: Date } = {}; + if (filterCriteria?.createdAt?.max) { + createdAt.lte = filterCriteria?.createdAt?.max; + } + if (filterCriteria?.createdAt?.min) { + createdAt.gte = filterCriteria?.createdAt?.min; + } + + whereClause.push({ + createdAt, + }); + } + + // For Tags + if (filterCriteria?.tags) { + const tagFilters = createFilterTags(filterCriteria.tags); + whereClause.push({ + AND: tagFilters, + }); + } + + // For Person Attributes + if (filterCriteria?.contactAttributes) { + const contactAttributes: Prisma.ResponseWhereInput[] = []; + + Object.entries(filterCriteria.contactAttributes).forEach(([key, val]) => { + switch (val.op) { + case "equals": + contactAttributes.push({ + contactAttributes: { + path: [key], + equals: val.value, + }, + }); + break; + case "notEquals": + contactAttributes.push({ + contactAttributes: { + path: [key], + not: val.value, + }, + }); + break; + } + }); + + whereClause.push({ + AND: contactAttributes, + }); + } + + // for meta + if (filterCriteria?.meta) { + const meta: Prisma.ResponseWhereInput[] = []; + + Object.entries(filterCriteria.meta).forEach(([key, val]) => { + let updatedKey: string[] = []; + if (["browser", "os", "device"].includes(key)) { + updatedKey = ["userAgent", key]; + } else { + updatedKey = [key]; + } + + switch (val.op) { + case "equals": + meta.push({ + meta: { + path: updatedKey, + equals: val.value, + }, + }); + break; + case "notEquals": + meta.push({ + meta: { + path: updatedKey, + not: val.value, + }, + }); + break; + } + }); + + whereClause.push({ + AND: meta, + }); + } + + // For Language + if (filterCriteria?.others) { + const others: Prisma.ResponseWhereInput[] = []; + + Object.entries(filterCriteria.others).forEach(([key, val]) => { + switch (val.op) { + case "equals": + others.push({ + [key.toLocaleLowerCase()]: val.value, + }); + break; + case "notEquals": + others.push({ + [key.toLocaleLowerCase()]: { + not: val.value, + }, + }); + break; + } + }); + whereClause.push({ + AND: others, + }); + } + + // For Questions Data + if (filterCriteria?.data) { + const data: Prisma.ResponseWhereInput[] = []; + + Object.entries(filterCriteria.data).forEach(([key, val]) => { + const question = survey.questions.find((question) => question.id === key); + + switch (val.op) { + case "submitted": + data.push({ + data: { + path: [key], + not: Prisma.DbNull, + }, + }); + break; + case "filledOut": + data.push({ + data: { + path: [key], + not: [], + }, + }); + break; + case "skipped": + data.push({ + OR: [ + { + data: { + path: [key], + equals: Prisma.DbNull, + }, + }, + { + data: { + path: [key], + equals: "", + }, + }, + // For address question + { + data: { + path: [key], + equals: [], + }, + }, + ], + }); + break; + case "equals": + data.push({ + data: { + path: [key], + equals: val.value, + }, + }); + break; + case "notEquals": + data.push({ + OR: [ + { + // for value not equal to val.value + data: { + path: [key], + not: val.value, + }, + }, + { + // for not answered + data: { + path: [key], + equals: Prisma.DbNull, + }, + }, + ], + }); + break; + case "lessThan": + data.push({ + data: { + path: [key], + lt: val.value, + }, + }); + break; + case "lessEqual": + data.push({ + data: { + path: [key], + lte: val.value, + }, + }); + break; + case "greaterThan": + data.push({ + data: { + path: [key], + gt: val.value, + }, + }); + break; + case "greaterEqual": + data.push({ + data: { + path: [key], + gte: val.value, + }, + }); + break; + case "includesAll": + data.push({ + data: { + path: [key], + array_contains: val.value, + }, + }); + break; + case "includesOne": + // * If the question includes an 'other' choice and the user has selected it: + // * - `predefinedLabels`: Collects labels from the question's choices that aren't selected by the user. + // * - `subsets`: Generates all possible non-empty permutations of subsets of these predefined labels. + // * + // * Depending on the question type (multiple or single choice), the filter is constructed: + // * - For "multipleChoiceMulti": Filters out any combinations of choices that match the subsets of predefined labels. + // * - For "multipleChoiceSingle": Filters out any single predefined labels that match the user's selection. + const values: string[] = val.value.map((v) => v.toString()); + const otherChoice = + question && (question.type === "multipleChoiceMulti" || question.type === "multipleChoiceSingle") + ? question.choices.find((choice) => choice.id === "other") + : null; + + if ( + question && + (question.type === "multipleChoiceMulti" || question.type === "multipleChoiceSingle") && + question.choices.map((choice) => choice.id).includes("other") && + otherChoice && + values.includes(otherChoice.label.default) + ) { + const predefinedLabels: string[] = []; + + question.choices.forEach((choice) => { + Object.values(choice.label).forEach((label) => { + if (!values.includes(label)) { + predefinedLabels.push(label); + } + }); + }); + + const subsets = generateAllPermutationsOfSubsets(predefinedLabels); + if (question.type === "multipleChoiceMulti") { + const subsetConditions = subsets.map((subset) => ({ + data: { path: [key], equals: subset }, + })); + data.push({ + NOT: { + OR: subsetConditions, + }, + }); + } else { + data.push( + // for MultipleChoiceSingle + { + AND: predefinedLabels.map((label) => ({ + NOT: { + data: { + path: [key], + equals: label, + }, + }, + })), + } + ); + } + } else { + data.push({ + OR: val.value.map((value: string | number) => ({ + OR: [ + // for MultipleChoiceMulti + { + data: { + path: [key], + array_contains: [value], + }, + }, + // for MultipleChoiceSingle + { + data: { + path: [key], + equals: value, + }, + }, + ], + })), + }); + } + + break; + case "uploaded": + data.push({ + data: { + path: [key], + not: "skipped", + }, + }); + break; + case "notUploaded": + data.push({ + OR: [ + { + // for skipped + data: { + path: [key], + equals: "skipped", + }, + }, + { + // for not answered + data: { + path: [key], + equals: Prisma.DbNull, + }, + }, + ], + }); + break; + case "clicked": + data.push({ + data: { + path: [key], + equals: "clicked", + }, + }); + break; + case "accepted": + data.push({ + data: { + path: [key], + equals: "accepted", + }, + }); + break; + case "booked": + data.push({ + data: { + path: [key], + equals: "booked", + }, + }); + break; + case "matrix": + const rowLabel = Object.keys(val.value)[0]; + data.push({ + data: { + path: [key, rowLabel], + equals: val.value[rowLabel], + }, + }); + break; + } + }); + + whereClause.push({ + AND: data, + }); + } + + // filter by explicit response IDs + if (filterCriteria?.responseIds) { + whereClause.push({ + id: { in: filterCriteria.responseIds }, + }); + } + return { AND: whereClause }; +}; + +export const getResponsesFileName = (surveyName: string, extension: string) => { + const sanitizedSurveyName = sanitizeString(surveyName); + + const formattedDateString = getTodaysDateTimeFormatted("-"); + return `export-${sanitizedSurveyName.split(" ").join("-")}-${formattedDateString}.${extension}`.toLocaleLowerCase(); +}; + +export const extracMetadataKeys = (obj: TResponse["meta"]) => { + let keys: string[] = []; + + Object.entries(obj ?? {}).forEach(([key, value]) => { + if (typeof value === "object" && value !== null) { + Object.entries(value).forEach(([subKey]) => { + keys.push(key + " - " + subKey); + }); + } else { + keys.push(key); + } + }); + + return keys; +}; + +export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) => { + const metaDataFields = responses.length > 0 ? extracMetadataKeys(responses[0].meta) : []; + const questions = survey.questions.map((question, idx) => { + const headline = getLocalizedValue(question.headline, "default") ?? question.id; + if (question.type === "matrix") { + return question.rows.map((row) => { + return `${idx + 1}. ${headline} - ${getLocalizedValue(row, "default")}`; + }); + } else if ( + question.type === "multipleChoiceMulti" || + question.type === "multipleChoiceSingle" || + question.type === "ranking" + ) { + return [`${idx + 1}. ${headline}`, `${idx + 1}. ${headline} - Option ID`]; + } else { + return [`${idx + 1}. ${headline}`]; + } + }); + + const hiddenFields = survey.hiddenFields?.fieldIds || []; + const userAttributes = + survey.type === "app" + ? Array.from(new Set(responses.map((response) => Object.keys(response.contactAttributes ?? {})).flat())) + : []; + const variables = survey.variables?.map((variable) => variable.name) || []; + + return { metaDataFields, questions, hiddenFields, variables, userAttributes }; +}; + +export const getResponsesJson = ( + survey: TSurvey, + responses: TResponse[], + questionsHeadlines: string[][], + userAttributes: string[], + hiddenFields: string[] +): Record[] => { + const jsonData: Record[] = []; + + responses.forEach((response, idx) => { + // basic response details + jsonData.push({ + "No.": idx + 1, + "Response ID": response.id, + Timestamp: getFormattedDateTimeString(response.createdAt), + Finished: response.finished ? "Yes" : "No", + "Survey ID": response.surveyId, + "Formbricks ID (internal)": response.contact?.id || "", + "User ID": response.contact?.userId || "", + Tags: response.tags.map((tag) => tag.name).join(", "), + }); + + // meta details + Object.entries(response.meta ?? {}).forEach(([key, value]) => { + if (typeof value === "object" && value !== null) { + Object.entries(value).forEach(([subKey, subValue]) => { + jsonData[idx][key + " - " + subKey] = subValue; + }); + } else { + jsonData[idx][key] = value; + } + }); + + // survey response data + questionsHeadlines.forEach((questionHeadline) => { + const questionIndex = parseInt(questionHeadline[0]) - 1; + const question = survey?.questions[questionIndex]; + const answer = response.data[question.id]; + + if (question.type === "matrix") { + // For matrix questions, we need to handle each row separately + questionHeadline.forEach((headline, index) => { + if (answer) { + const row = question.rows[index]; + if (row && row.default && answer[row.default] !== undefined) { + jsonData[idx][headline] = answer[row.default]; + } else { + jsonData[idx][headline] = ""; + } + } + }); + } else if ( + question.type === "multipleChoiceMulti" || + question.type === "multipleChoiceSingle" || + question.type === "ranking" + ) { + // Set the main response value + jsonData[idx][questionHeadline[0]] = processResponseData(answer); + + // Set the option IDs using the reusable function + if (questionHeadline[1]) { + const choiceIds = extractChoiceIdsFromResponse(answer, question, response.language || "default"); + jsonData[idx][questionHeadline[1]] = choiceIds.join(", "); + } + } else { + jsonData[idx][questionHeadline[0]] = processResponseData(answer); + } + }); + + survey.variables?.forEach((variable) => { + const answer = response.variables[variable.id]; + jsonData[idx][variable.name] = answer; + }); + + // user attributes + userAttributes.forEach((attribute) => { + jsonData[idx][attribute] = response.contactAttributes?.[attribute] || ""; + }); + + // hidden fields + hiddenFields.forEach((field) => { + const value = response.data[field]; + if (Array.isArray(value)) { + jsonData[idx][field] = value.join("; "); + } else { + jsonData[idx][field] = processResponseData(value); + } + }); + + if (survey.isVerifyEmailEnabled) { + const verifiedEmail = response.data["verifiedEmail"]; + jsonData[idx]["Verified Email"] = processResponseData(verifiedEmail); + } + }); + + return jsonData; +}; + +export const getResponseContactAttributes = ( + responses: Pick[] +): TSurveyContactAttributes => { + try { + let attributes: TSurveyContactAttributes = {}; + + responses.forEach((response) => { + Object.keys(response.contactAttributes ?? {}).forEach((key) => { + if (response.contactAttributes && attributes[key]) { + attributes[key].push(response.contactAttributes[key].toString()); + } else if (response.contactAttributes) { + attributes[key] = [response.contactAttributes[key].toString()]; + } + }); + }); + + Object.keys(attributes).forEach((key) => { + attributes[key] = Array.from(new Set(attributes[key])); + }); + + return attributes; + } catch (error) { + throw error; + } +}; + +export const getResponseMeta = ( + responses: Pick[] +): TSurveyMetaFieldFilter => { + try { + const meta: { [key: string]: Set } = {}; + + responses.forEach((response) => { + Object.entries(response.meta).forEach(([key, value]) => { + // skip url + if (key === "url") return; + + // Handling nested objects (like userAgent) + if (typeof value === "object" && value !== null) { + Object.entries(value).forEach(([nestedKey, nestedValue]) => { + if (typeof nestedValue === "string" && nestedValue) { + if (!meta[nestedKey]) { + meta[nestedKey] = new Set(); + } + meta[nestedKey].add(nestedValue); + } + }); + } else if (typeof value === "string" && value) { + if (!meta[key]) { + meta[key] = new Set(); + } + meta[key].add(value); + } + }); + }); + + // Convert Set to Array + const result = Object.fromEntries( + Object.entries(meta).map(([key, valueSet]) => [key, Array.from(valueSet)]) + ); + + return result; + } catch (error) { + throw error; + } +}; + +export const getResponseHiddenFields = ( + survey: TSurvey, + responses: Pick[] +): TResponseHiddenFieldsFilter => { + try { + const hiddenFields: { [key: string]: Set } = {}; + + const surveyHiddenFields = survey?.hiddenFields.fieldIds; + const hasHiddenFields = surveyHiddenFields && surveyHiddenFields.length > 0; + + if (hasHiddenFields) { + // adding hidden fields to meta + survey?.hiddenFields.fieldIds?.forEach((fieldId) => { + hiddenFields[fieldId] = new Set(); + }); + + responses.forEach((response) => { + // Handling data fields(Hidden fields) + surveyHiddenFields?.forEach((fieldId) => { + const hiddenFieldValue = response.data[fieldId]; + if (hiddenFieldValue) { + if (typeof hiddenFieldValue === "string") { + hiddenFields[fieldId].add(hiddenFieldValue); + } + } + }); + }); + } + + // Convert Set to Array + const result = Object.fromEntries( + Object.entries(hiddenFields).map(([key, valueSet]) => [key, Array.from(valueSet)]) + ); + + return result; + } catch (error) { + throw error; + } +}; + +export const generateAllPermutationsOfSubsets = (array: string[]): string[][] => { + const subsets: string[][] = []; + + // Helper function to generate permutations of an array + const generatePermutations = (arr: string[]): string[][] => { + const permutations: string[][] = []; + + // Recursive function to generate permutations + const permute = (current: string[], remaining: string[]): void => { + if (remaining.length === 0) { + permutations.push(current.slice()); // Make a copy of the current permutation + return; + } + + for (let i = 0; i < remaining.length; i++) { + current.push(remaining[i]); + permute(current, remaining.slice(0, i).concat(remaining.slice(i + 1))); + current.pop(); + } + }; + + permute([], arr); + return permutations; + }; + + // Recursive function to generate subsets + const findSubsets = (currentIndex: number, currentSubset: string[]): void => { + if (currentIndex === array.length) { + if (currentSubset.length > 0) { + // Skip empty subset if not needed + const allPermutations = generatePermutations(currentSubset); + subsets.push(...allPermutations); // Spread operator to add all permutations individually + } + return; + } + + // Include the current element + findSubsets(currentIndex + 1, currentSubset.concat(array[currentIndex])); + + // Exclude the current element + findSubsets(currentIndex + 1, currentSubset); + }; + + findSubsets(0, []); + return subsets; +}; diff --git a/apps/web/lib/responses.test.ts b/apps/web/lib/responses.test.ts new file mode 100644 index 000000000000..04f60a2c9847 --- /dev/null +++ b/apps/web/lib/responses.test.ts @@ -0,0 +1,500 @@ +import { describe, expect, test, vi } from "vitest"; +import { TSurveyQuestionType, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { convertResponseValue, getQuestionResponseMapping, processResponseData } from "./responses"; + +// Mock the recall and i18n utils +vi.mock("@/lib/utils/recall", () => ({ + parseRecallInfo: vi.fn((text) => text), +})); + +vi.mock("./i18n/utils", () => ({ + getLocalizedValue: vi.fn((obj, lang) => obj[lang] || obj.default), + getLanguageCode: vi.fn((surveyLanguages, languageCode) => { + if (!surveyLanguages?.length || !languageCode) return null; // Changed from "default" to null + const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode); + return language?.default ? "default" : language?.language.code || "default"; + }), +})); + +describe("Response Processing", () => { + describe("processResponseData", () => { + test("should handle string input", () => { + expect(processResponseData("test")).toBe("test"); + }); + + test("should handle number input", () => { + expect(processResponseData(42)).toBe("42"); + }); + + test("should handle array input", () => { + expect(processResponseData(["a", "b", "c"])).toBe("a; b; c"); + }); + + test("should filter out empty values from array", () => { + const input = ["a", "", "c"]; + expect(processResponseData(input)).toBe("a; c"); + }); + + test("should handle object input", () => { + const input = { key1: "value1", key2: "value2" }; + expect(processResponseData(input)).toBe("key1: value1\nkey2: value2"); + }); + + test("should filter out empty values from object", () => { + const input = { key1: "value1", key2: "", key3: "value3" }; + expect(processResponseData(input)).toBe("key1: value1\nkey3: value3"); + }); + + test("should return empty string for unsupported types", () => { + expect(processResponseData(undefined as any)).toBe(""); + }); + + test("should filter out null values from array", () => { + const input = ["a", null, "c"] as any; + expect(processResponseData(input)).toBe("a; c"); + }); + + test("should filter out undefined values from array", () => { + const input = ["a", undefined, "c"] as any; + expect(processResponseData(input)).toBe("a; c"); + }); + }); + + describe("convertResponseValue", () => { + const mockOpenTextQuestion = { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText as const, + headline: { default: "Test Question" }, + required: true, + inputType: "text" as const, + longAnswer: false, + charLimit: { enabled: false }, + }; + + const mockRankingQuestion = { + id: "q1", + type: TSurveyQuestionTypeEnum.Ranking as const, + headline: { default: "Test Question" }, + required: true, + choices: [ + { id: "1", label: { default: "Choice 1" } }, + { id: "2", label: { default: "Choice 2" } }, + ], + shuffleOption: "none" as const, + }; + + const mockFileUploadQuestion = { + id: "q1", + type: TSurveyQuestionTypeEnum.FileUpload as const, + headline: { default: "Test Question" }, + required: true, + allowMultipleFiles: true, + }; + + const mockPictureSelectionQuestion = { + id: "q1", + type: TSurveyQuestionTypeEnum.PictureSelection as const, + headline: { default: "Test Question" }, + required: true, + allowMulti: false, + choices: [ + { id: "1", imageUrl: "image1.jpg", label: { default: "Choice 1" } }, + { id: "2", imageUrl: "image2.jpg", label: { default: "Choice 2" } }, + ], + }; + + test("should handle ranking type with string input", () => { + expect(convertResponseValue("answer", mockRankingQuestion)).toEqual(["answer"]); + }); + + test("should handle ranking type with array input", () => { + expect(convertResponseValue(["answer1", "answer2"], mockRankingQuestion)).toEqual([ + "answer1", + "answer2", + ]); + }); + + test("should handle fileUpload type with string input", () => { + expect(convertResponseValue("file.jpg", mockFileUploadQuestion)).toEqual(["file.jpg"]); + }); + + test("should handle fileUpload type with array input", () => { + expect(convertResponseValue(["file1.jpg", "file2.jpg"], mockFileUploadQuestion)).toEqual([ + "file1.jpg", + "file2.jpg", + ]); + }); + + test("should handle pictureSelection type with string input", () => { + expect(convertResponseValue("1", mockPictureSelectionQuestion)).toEqual(["image1.jpg"]); + }); + + test("should handle pictureSelection type with array input", () => { + expect(convertResponseValue(["1", "2"], mockPictureSelectionQuestion)).toEqual([ + "image1.jpg", + "image2.jpg", + ]); + }); + + test("should handle pictureSelection type with invalid choice", () => { + expect(convertResponseValue("invalid", mockPictureSelectionQuestion)).toEqual([]); + }); + + test("should handle pictureSelection type with number input", () => { + expect(convertResponseValue(42, mockPictureSelectionQuestion)).toEqual([]); + }); + + test("should handle pictureSelection type with object input", () => { + expect(convertResponseValue({ key: "value" }, mockPictureSelectionQuestion)).toEqual([]); + }); + + test("should handle pictureSelection type with null input", () => { + expect(convertResponseValue(null as any, mockPictureSelectionQuestion)).toEqual([]); + }); + + test("should handle pictureSelection type with undefined input", () => { + expect(convertResponseValue(undefined as any, mockPictureSelectionQuestion)).toEqual([]); + }); + + test("should handle default case with string input", () => { + expect(convertResponseValue("answer", mockOpenTextQuestion)).toBe("answer"); + }); + + test("should handle default case with number input", () => { + expect(convertResponseValue(42, mockOpenTextQuestion)).toBe("42"); + }); + + test("should handle default case with array input", () => { + expect(convertResponseValue(["a", "b", "c"], mockOpenTextQuestion)).toBe("a; b; c"); + }); + + test("should handle default case with object input", () => { + const input = { key1: "value1", key2: "value2" }; + expect(convertResponseValue(input, mockOpenTextQuestion)).toBe("key1: value1\nkey2: value2"); + }); + }); + + describe("getQuestionResponseMapping", () => { + const mockSurvey = { + id: "survey1", + type: "link" as const, + status: "inProgress" as const, + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + environmentId: "env1", + createdBy: null, + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText as const, + headline: { default: "Question 1" }, + required: true, + inputType: "text" as const, + longAnswer: false, + charLimit: { enabled: false }, + }, + { + id: "q2", + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti as const, + headline: { default: "Question 2" }, + required: true, + choices: [ + { id: "1", label: { default: "Option 1" } }, + { id: "2", label: { default: "Option 2" } }, + ], + shuffleOption: "none" as const, + }, + ], + hiddenFields: { + enabled: false, + fieldIds: [], + }, + displayOption: "displayOnce" as const, + delay: 0, + languages: [ + { + language: { + id: "lang1", + code: "default", + createdAt: new Date(), + updatedAt: new Date(), + alias: null, + projectId: "proj1", + }, + default: true, + enabled: true, + }, + ], + variables: [], + endings: [], + displayLimit: null, + autoClose: null, + autoComplete: null, + recontactDays: null, + runOnDate: null, + closeOnDate: null, + welcomeCard: { + enabled: false, + timeToFinish: false, + showResponseCount: false, + }, + showLanguageSwitch: false, + isBackButtonHidden: false, + isVerifyEmailEnabled: false, + isSingleResponsePerEmailEnabled: false, + displayPercentage: 100, + styling: null, + projectOverwrites: null, + verifyEmail: null, + inlineTriggers: [], + pin: null, + triggers: [], + followUps: [], + segment: null, + recaptcha: null, + surveyClosedMessage: null, + singleUse: { + enabled: false, + isEncrypted: false, + }, + }; + + const mockResponse = { + id: "response1", + surveyId: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + finished: true, + data: { + q1: "Answer 1", + q2: ["Option 1", "Option 2"], + }, + language: "default", + meta: { + url: undefined, + country: undefined, + action: undefined, + source: undefined, + userAgent: undefined, + }, + tags: [], + person: null, + personAttributes: {}, + ttc: {}, + variables: {}, + contact: null, + contactAttributes: {}, + singleUseId: null, + }; + + test("should map questions to responses correctly", () => { + const mapping = getQuestionResponseMapping(mockSurvey, mockResponse); + expect(mapping).toHaveLength(2); + expect(mapping[0]).toEqual({ + question: "Question 1", + response: "Answer 1", + type: TSurveyQuestionTypeEnum.OpenText, + }); + expect(mapping[1]).toEqual({ + question: "Question 2", + response: "Option 1; Option 2", + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + }); + }); + + test("should handle missing response data", () => { + const response = { + id: "response1", + surveyId: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + finished: true, + data: {}, + language: "default", + meta: { + url: undefined, + country: undefined, + action: undefined, + source: undefined, + userAgent: undefined, + }, + tags: [], + person: null, + personAttributes: {}, + ttc: {}, + variables: {}, + contact: null, + contactAttributes: {}, + singleUseId: null, + }; + const mapping = getQuestionResponseMapping(mockSurvey, response); + expect(mapping).toHaveLength(2); + expect(mapping[0].response).toBe(""); + expect(mapping[1].response).toBe(""); + }); + + test("should handle different language", () => { + const survey = { + ...mockSurvey, + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText as const, + headline: { default: "Question 1", en: "Question 1 EN" }, + required: true, + inputType: "text" as const, + longAnswer: false, + charLimit: { enabled: false }, + }, + ], + languages: [ + { + language: { + id: "lang1", + code: "default", + createdAt: new Date(), + updatedAt: new Date(), + alias: null, + projectId: "proj1", + }, + default: true, + enabled: true, + }, + { + language: { + id: "lang2", + code: "en", + createdAt: new Date(), + updatedAt: new Date(), + alias: null, + projectId: "proj1", + }, + default: false, + enabled: true, + }, + ], + }; + const response = { + id: "response1", + surveyId: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + finished: true, + data: { q1: "Answer 1" }, + language: "en", + meta: { + url: undefined, + country: undefined, + action: undefined, + source: undefined, + userAgent: undefined, + }, + tags: [], + person: null, + personAttributes: {}, + ttc: {}, + variables: {}, + contact: null, + contactAttributes: {}, + singleUseId: null, + }; + const mapping = getQuestionResponseMapping(survey, response); + expect(mapping[0].question).toBe("Question 1 EN"); + }); + + test("should handle null response language", () => { + const response = { + id: "response1", + surveyId: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + finished: true, + data: { q1: "Answer 1" }, + language: null, + meta: { + url: undefined, + country: undefined, + action: undefined, + source: undefined, + userAgent: undefined, + }, + tags: [], + person: null, + personAttributes: {}, + ttc: {}, + variables: {}, + contact: null, + contactAttributes: {}, + singleUseId: null, + }; + const mapping = getQuestionResponseMapping(mockSurvey, response); + expect(mapping).toHaveLength(2); + expect(mapping[0].question).toBe("Question 1"); + }); + + test("should handle undefined response language", () => { + const response = { + id: "response1", + surveyId: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + finished: true, + data: { q1: "Answer 1" }, + language: null, + meta: { + url: undefined, + country: undefined, + action: undefined, + source: undefined, + userAgent: undefined, + }, + tags: [], + person: null, + personAttributes: {}, + ttc: {}, + variables: {}, + contact: null, + contactAttributes: {}, + singleUseId: null, + }; + const mapping = getQuestionResponseMapping(mockSurvey, response); + expect(mapping).toHaveLength(2); + expect(mapping[0].question).toBe("Question 1"); + }); + + test("should handle empty survey languages", () => { + const survey = { + ...mockSurvey, + languages: [], // Empty languages array + }; + const response = { + id: "response1", + surveyId: "survey1", + createdAt: new Date(), + updatedAt: new Date(), + finished: true, + data: { q1: "Answer 1" }, + language: "en", + meta: { + url: undefined, + country: undefined, + action: undefined, + source: undefined, + userAgent: undefined, + }, + tags: [], + person: null, + personAttributes: {}, + ttc: {}, + variables: {}, + contact: null, + contactAttributes: {}, + singleUseId: null, + }; + const mapping = getQuestionResponseMapping(survey, response); + expect(mapping).toHaveLength(2); + expect(mapping[0].question).toBe("Question 1"); // Should fallback to default + }); + }); +}); diff --git a/packages/lib/responses.ts b/apps/web/lib/responses.ts similarity index 86% rename from packages/lib/responses.ts rename to apps/web/lib/responses.ts index 0e4bdeddeee0..e8760e13778e 100644 --- a/packages/lib/responses.ts +++ b/apps/web/lib/responses.ts @@ -1,7 +1,7 @@ +import { parseRecallInfo } from "@/lib/utils/recall"; import { TResponse } from "@formbricks/types/responses"; import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types"; -import { getLocalizedValue } from "./i18n/utils"; -import { parseRecallInfo } from "./utils/recall"; +import { getLanguageCode, getLocalizedValue } from "./i18n/utils"; // function to convert response value of type string | number | string[] or Record to string | string[] export const convertResponseValue = ( @@ -39,11 +39,16 @@ export const getQuestionResponseMapping = ( response: string | string[]; type: TSurveyQuestionType; }[] = []; + const responseLanguageCode = getLanguageCode(survey.languages, response.language); + for (const question of survey.questions) { const answer = response.data[question.id]; questionResponseMapping.push({ - question: parseRecallInfo(getLocalizedValue(question.headline, "default"), response.data), + question: parseRecallInfo( + getLocalizedValue(question.headline, responseLanguageCode ?? "default"), + response.data + ), response: convertResponseValue(answer, question), type: question.type, }); @@ -66,7 +71,7 @@ export const processResponseData = ( if (Array.isArray(responseData)) { responseData = responseData .filter((item) => item !== null && item !== undefined && item !== "") - .join(", "); + .join("; "); return responseData; } else { const formattedString = Object.entries(responseData) diff --git a/packages/lib/slack/service.ts b/apps/web/lib/slack/service.ts similarity index 100% rename from packages/lib/slack/service.ts rename to apps/web/lib/slack/service.ts diff --git a/apps/web/lib/storage/service.test.ts b/apps/web/lib/storage/service.test.ts new file mode 100644 index 000000000000..ed207abe7736 --- /dev/null +++ b/apps/web/lib/storage/service.test.ts @@ -0,0 +1,312 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { readFile } from "fs/promises"; +import { lookup } from "mime-types"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock AWS SDK +const mockSend = vi.fn(); +const mockS3Client = { + send: mockSend, +}; + +vi.mock("fs/promises", () => ({ + readFile: vi.fn(), + access: vi.fn(), + mkdir: vi.fn(), + rmdir: vi.fn(), + unlink: vi.fn(), + writeFile: vi.fn(), +})); + +vi.mock("mime-types", () => ({ + lookup: vi.fn(), +})); + +vi.mock("@aws-sdk/client-s3", () => ({ + S3Client: vi.fn(() => mockS3Client), + HeadBucketCommand: vi.fn(), + PutObjectCommand: vi.fn(), + DeleteObjectCommand: vi.fn(), + GetObjectCommand: vi.fn(), +})); + +vi.mock("@aws-sdk/s3-presigned-post", () => ({ + createPresignedPost: vi.fn(() => + Promise.resolve({ + url: "https://test-bucket.s3.test-region.amazonaws.com", + fields: { key: "test-key", policy: "test-policy" }, + }) + ), +})); + +// Mock environment variables +vi.mock("../constants", () => ({ + S3_ACCESS_KEY: "test-access-key", + S3_SECRET_KEY: "test-secret-key", + S3_REGION: "test-region", + S3_BUCKET_NAME: "test-bucket", + S3_ENDPOINT_URL: "http://test-endpoint", + S3_FORCE_PATH_STYLE: true, + isS3Configured: () => true, + IS_FORMBRICKS_CLOUD: false, + MAX_SIZES: { + standard: 5 * 1024 * 1024, + big: 10 * 1024 * 1024, + }, + WEBAPP_URL: "http://test-webapp", + ENCRYPTION_KEY: "test-encryption-key-32-chars-long!!", + UPLOADS_DIR: "/tmp/uploads", +})); + +// Mock getPublicDomain +vi.mock("../getPublicUrl", () => ({ + getPublicDomain: () => "https://public-domain.com", +})); + +// Mock crypto functions +vi.mock("crypto", () => ({ + randomUUID: () => "test-uuid", +})); + +// Mock local signed url generation +vi.mock("../crypto", () => ({ + generateLocalSignedUrl: () => ({ + signature: "test-signature", + timestamp: 123456789, + uuid: "test-uuid", + }), +})); + +// Mock env +vi.mock("../env", () => ({ + env: { + S3_BUCKET_NAME: "test-bucket", + }, +})); + +describe("Storage Service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("getS3Client", () => { + test("should create and return S3 client instance", async () => { + const { getS3Client } = await import("./service"); + const client = getS3Client(); + expect(client).toBe(mockS3Client); + expect(S3Client).toHaveBeenCalledWith({ + credentials: { + accessKeyId: "test-access-key", + secretAccessKey: "test-secret-key", + }, + region: "test-region", + endpoint: "http://test-endpoint", + forcePathStyle: true, + }); + }); + + test("should return existing client instance on subsequent calls", async () => { + vi.resetModules(); + const { getS3Client } = await import("./service"); + const client1 = getS3Client(); + const client2 = getS3Client(); + expect(client1).toBe(client2); + expect(S3Client).toHaveBeenCalledTimes(1); + }); + }); + + describe("testS3BucketAccess", () => { + let testS3BucketAccess: any; + + beforeEach(async () => { + const serviceModule = await import("./service"); + testS3BucketAccess = serviceModule.testS3BucketAccess; + }); + + test("should return true when bucket access is successful", async () => { + mockSend.mockResolvedValueOnce({}); + const result = await testS3BucketAccess(); + expect(result).toBe(true); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + test("should throw error when bucket access fails", async () => { + const error = new Error("Access denied"); + mockSend.mockRejectedValueOnce(error); + await expect(testS3BucketAccess()).rejects.toThrow( + "S3 Bucket Access Test Failed: Error: Access denied" + ); + }); + }); + + describe("putFile", () => { + let putFile: any; + + beforeEach(async () => { + const serviceModule = await import("./service"); + putFile = serviceModule.putFile; + }); + + test("should successfully upload file to S3", async () => { + const fileName = "test.jpg"; + const fileBuffer = Buffer.from("test"); + const accessType = "private"; + const environmentId = "env123"; + + mockSend.mockResolvedValueOnce({}); + + const result = await putFile(fileName, fileBuffer, accessType, environmentId); + expect(result).toEqual({ success: true, message: "File uploaded" }); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + test("should throw error when S3 upload fails", async () => { + const fileName = "test.jpg"; + const fileBuffer = Buffer.from("test"); + const accessType = "private"; + const environmentId = "env123"; + + const error = new Error("Upload failed"); + mockSend.mockRejectedValueOnce(error); + + await expect(putFile(fileName, fileBuffer, accessType, environmentId)).rejects.toThrow("Upload failed"); + }); + }); + + describe("getUploadSignedUrl", () => { + let getUploadSignedUrl: any; + + beforeEach(async () => { + const serviceModule = await import("./service"); + getUploadSignedUrl = serviceModule.getUploadSignedUrl; + }); + + test("should use PUBLIC_URL for public files with S3", async () => { + const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "public"); + + expect(result.fileUrl).toContain("https://public-domain.com"); + expect(result.fileUrl).toMatch( + /https:\/\/public-domain\.com\/storage\/env123\/public\/test--fid--test-uuid\.jpg/ + ); + }); + + test("should use WEBAPP_URL for private files with S3", async () => { + const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "private"); + + expect(result.fileUrl).toContain("http://test-webapp"); + expect(result.fileUrl).toMatch( + /http:\/\/test-webapp\/storage\/env123\/private\/test--fid--test-uuid\.jpg/ + ); + }); + + test("should contain signed URL and presigned fields for S3", async () => { + const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "public"); + + expect(result.signedUrl).toBe("https://test-bucket.s3.test-region.amazonaws.com"); + expect(result.presignedFields).toEqual({ key: "test-key", policy: "test-policy" }); + }); + + test("use local storage for private files when S3 is not configured", async () => { + vi.resetModules(); + + vi.doMock("../constants", () => ({ + S3_ACCESS_KEY: "test-access-key", + S3_SECRET_KEY: "test-secret-key", + S3_REGION: "test-region", + S3_BUCKET_NAME: "test-bucket", + S3_ENDPOINT_URL: "http://test-endpoint", + S3_FORCE_PATH_STYLE: true, + isS3Configured: () => false, + IS_FORMBRICKS_CLOUD: false, + MAX_SIZES: { + standard: 5 * 1024 * 1024, + big: 10 * 1024 * 1024, + }, + WEBAPP_URL: "http://test-webapp", + ENCRYPTION_KEY: "test-encryption-key-32-chars-long!!", + UPLOADS_DIR: "/tmp/uploads", + })); + + vi.mock("../getPublicUrl", () => ({ + getPublicDomain: () => "https://public-domain.com", + })); + + const freshModule = await import("./service"); + const freshGetUploadSignedUrl = freshModule.getUploadSignedUrl as typeof getUploadSignedUrl; + + const result = await freshGetUploadSignedUrl("test.jpg", "env123", "image/jpeg", "private"); + + expect(result.fileUrl).toContain("http://test-webapp"); + expect(result.fileUrl).toMatch( + /http:\/\/test-webapp\/storage\/env123\/private\/test--fid--test-uuid\.jpg/ + ); + expect(result.fileUrl).not.toContain("test-bucket"); + expect(result.fileUrl).not.toContain("test-endpoint"); + }); + }); + + describe("getLocalFile", () => { + let getLocalFile: any; + + beforeEach(async () => { + const serviceModule = await import("./service"); + getLocalFile = serviceModule.getLocalFile; + }); + + test("should return file buffer and metadata", async () => { + vi.mocked(readFile).mockResolvedValue(Buffer.from("test")); + vi.mocked(lookup).mockReturnValue("image/jpeg"); + + const result = await getLocalFile("/tmp/uploads/test/test.jpg"); + expect(result.fileBuffer).toBeInstanceOf(Buffer); + expect(result.metaData).toEqual({ contentType: "image/jpeg" }); + }); + + test("should throw error when file does not exist", async () => { + vi.mocked(readFile).mockRejectedValue(new Error("File not found")); + await expect(getLocalFile("/tmp/uploads/test/test.jpg")).rejects.toThrow("File not found"); + }); + + test("should throw error when file path attempts traversal outside uploads dir", async () => { + const traversalOutside = path.join("/tmp/uploads", "../outside.txt"); + await expect(getLocalFile(traversalOutside)).rejects.toThrow( + "Invalid file path: Path must be within uploads folder" + ); + }); + + test("should reject path traversal using '../secret' with security error", async () => { + await expect(getLocalFile("../secret")).rejects.toThrow( + "Invalid file path: Path must be within uploads folder" + ); + }); + + test("should reject Windows-style traversal '..\\\\secret' with security error", async () => { + await expect(getLocalFile("..\\secret")).rejects.toThrow( + "Invalid file path: Path must be within uploads folder" + ); + }); + + test("should reject nested traversal 'subdir/../../etc/passwd' with security error", async () => { + await expect(getLocalFile("subdir/../../etc/passwd")).rejects.toThrow( + "Invalid file path: Path must be within uploads folder" + ); + }); + + test("should throw EISDIR when provided path is a directory inside uploads", async () => { + // Simulate Node throwing EISDIR when attempting to read a directory + const eisdirError: any = new Error("EISDIR: illegal operation on a directory, read"); + eisdirError.code = "EISDIR"; + vi.mocked(readFile).mockRejectedValueOnce(eisdirError); + + await expect(getLocalFile("/tmp/uploads/some-dir")).rejects.toMatchObject({ + code: "EISDIR", + message: expect.stringContaining("EISDIR"), + }); + }); + }); +}); diff --git a/apps/web/lib/storage/service.ts b/apps/web/lib/storage/service.ts new file mode 100644 index 000000000000..784f76ba6fce --- /dev/null +++ b/apps/web/lib/storage/service.ts @@ -0,0 +1,435 @@ +import { + DeleteObjectCommand, + DeleteObjectsCommand, + GetObjectCommand, + HeadBucketCommand, + ListObjectsCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { PresignedPostOptions, createPresignedPost } from "@aws-sdk/s3-presigned-post"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { randomUUID } from "crypto"; +import { access, mkdir, readFile, rmdir, unlink, writeFile } from "fs/promises"; +import { lookup } from "mime-types"; +import type { WithImplicitCoercion } from "node:buffer"; +import path, { join } from "path"; +import { logger } from "@formbricks/logger"; +import { TAccessType } from "@formbricks/types/storage"; +import { + IS_FORMBRICKS_CLOUD, + MAX_SIZES, + S3_ACCESS_KEY, + S3_BUCKET_NAME, + S3_ENDPOINT_URL, + S3_FORCE_PATH_STYLE, + S3_REGION, + S3_SECRET_KEY, + UPLOADS_DIR, + WEBAPP_URL, + isS3Configured, +} from "../constants"; +import { generateLocalSignedUrl } from "../crypto"; +import { env } from "../env"; +import { getPublicDomain } from "../getPublicUrl"; + +// S3Client Singleton +let s3ClientInstance: S3Client | null = null; + +export const getS3Client = () => { + if (!s3ClientInstance) { + const credentials = + S3_ACCESS_KEY && S3_SECRET_KEY + ? { accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY } + : undefined; + + s3ClientInstance = new S3Client({ + credentials, + region: S3_REGION, + ...(S3_ENDPOINT_URL && { endpoint: S3_ENDPOINT_URL }), + forcePathStyle: S3_FORCE_PATH_STYLE, + }); + } + + return s3ClientInstance; +}; + +export const testS3BucketAccess = async () => { + const s3Client = getS3Client(); + + try { + // Attempt to retrieve metadata about the bucket + const headBucketCommand = new HeadBucketCommand({ + Bucket: S3_BUCKET_NAME, + }); + + await s3Client.send(headBucketCommand); + + return true; + } catch (error) { + logger.error(error, "Failed to access S3 bucket"); + throw new Error(`S3 Bucket Access Test Failed: ${error}`); + } +}; + +// Helper function to validate file paths are within the uploads directory +const validateAndResolvePath = (filePath: string): string => { + // Resolve and normalize the path to prevent directory traversal attacks + const resolvedPath = path.resolve(filePath); + const uploadsPath = path.resolve(UPLOADS_DIR); + + // Ensure the resolved path is within the uploads directory + if (!resolvedPath.startsWith(uploadsPath)) { + throw new Error("Invalid file path: Path must be within uploads folder"); + } + + return resolvedPath; +}; + +const ensureDirectoryExists = async (dirPath: string) => { + const safePath = validateAndResolvePath(dirPath); + + try { + await access(safePath); + } catch (error: any) { + if (error.code === "ENOENT") { + await mkdir(safePath, { recursive: true }); + } else { + throw error; + } + } +}; + +type TGetFileResponse = { + fileBuffer: Buffer; + metaData: { + contentType: string; + }; +}; + +// discriminated union +type TGetSignedUrlResponse = + | { signedUrl: string; fileUrl: string; presignedFields: Object } + | { + signedUrl: string; + updatedFileName: string; + fileUrl: string; + signingData: { + signature: string; + timestamp: number; + uuid: string; + }; + }; + +const getS3SignedUrl = async (fileKey: string): Promise => { + const getObjectCommand = new GetObjectCommand({ + Bucket: S3_BUCKET_NAME, + Key: fileKey, + }); + + try { + const s3Client = getS3Client(); + return await getSignedUrl(s3Client, getObjectCommand, { expiresIn: 30 * 60 }); + } catch (err) { + throw err; + } +}; + +export const getS3File = async (fileKey: string): Promise => { + const signedUrl = await getS3SignedUrl(fileKey); + return signedUrl; +}; + +export const getLocalFile = async (filePath: string): Promise => { + try { + const safeFilePath = validateAndResolvePath(filePath); + const file = await readFile(safeFilePath); + let contentType = ""; + + try { + contentType = lookup(filePath) || ""; + } catch (err) { + throw err; + } + + return { + fileBuffer: file, + metaData: { + contentType: contentType ?? "", + }, + }; + } catch (err) { + throw err; + } +}; + +// a single service for generating a signed url based on user's environment variables +export const getUploadSignedUrl = async ( + fileName: string, + environmentId: string, + fileType: string, + accessType: TAccessType, + isBiggerFileUploadAllowed: boolean = false +): Promise => { + // add a unique id to the file name + + const fileExtension = fileName.split(".").pop(); + const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join("."); + + if (!fileExtension) { + throw new Error("File extension not found"); + } + + const updatedFileName = `${fileNameWithoutExtension}--fid--${randomUUID()}.${fileExtension}`; + + // Use PUBLIC_URL for public files, WEBAPP_URL for private files + const publicDomain = getPublicDomain(); + const baseUrl = accessType === "public" ? getPublicDomain() : WEBAPP_URL; + + // handle the local storage case first + if (!isS3Configured()) { + try { + const { signature, timestamp, uuid } = generateLocalSignedUrl(updatedFileName, environmentId, fileType); + + return { + signedUrl: + accessType === "private" + ? new URL(`${publicDomain}/api/v1/client/${environmentId}/storage/local`).href + : new URL(`${WEBAPP_URL}/api/v1/management/storage/local`).href, + signingData: { + signature, + timestamp, + uuid, + }, + updatedFileName, + fileUrl: new URL(`${baseUrl}/storage/${environmentId}/${accessType}/${updatedFileName}`).href, + }; + } catch (err) { + throw err; + } + } + + try { + const { presignedFields, signedUrl } = await getS3UploadSignedUrl( + updatedFileName, + fileType, + accessType, + environmentId, + isBiggerFileUploadAllowed + ); + + return { + signedUrl, + presignedFields, + fileUrl: new URL(`${baseUrl}/storage/${environmentId}/${accessType}/${updatedFileName}`).href, + }; + } catch (err) { + throw err; + } +}; + +export const getS3UploadSignedUrl = async ( + fileName: string, + contentType: string, + accessType: string, + environmentId: string, + isBiggerFileUploadAllowed: boolean = false +) => { + const maxSize = IS_FORMBRICKS_CLOUD + ? isBiggerFileUploadAllowed + ? MAX_SIZES.big + : MAX_SIZES.standard + : Infinity; + + const postConditions: PresignedPostOptions["Conditions"] = IS_FORMBRICKS_CLOUD + ? [["content-length-range", 0, maxSize]] + : undefined; + + try { + const s3Client = getS3Client(); + const { fields, url } = await createPresignedPost(s3Client, { + Expires: 10 * 60, // 10 minutes + Bucket: env.S3_BUCKET_NAME!, + Key: `${environmentId}/${accessType}/${fileName}`, + Fields: { + "Content-Type": contentType, + "Content-Encoding": "base64", + }, + Conditions: postConditions, + }); + + return { + signedUrl: url, + presignedFields: fields, + }; + } catch (err) { + throw err; + } +}; + +export const putFileToLocalStorage = async ( + fileName: string, + fileBuffer: Buffer, + accessType: string, + environmentId: string, + rootDir: string, + isBiggerFileUploadAllowed: boolean = false +) => { + try { + await ensureDirectoryExists(`${rootDir}/${environmentId}/${accessType}`); + + const uploadPath = `${rootDir}/${environmentId}/${accessType}/${fileName}`; + const safeUploadPath = validateAndResolvePath(uploadPath); + + const buffer = Buffer.from(fileBuffer as unknown as WithImplicitCoercion); + const bufferBytes = buffer.byteLength; + + const maxSize = IS_FORMBRICKS_CLOUD + ? isBiggerFileUploadAllowed + ? MAX_SIZES.big + : MAX_SIZES.standard + : Infinity; + + if (bufferBytes > maxSize) { + const err = new Error(`File size exceeds the ${maxSize / (1024 * 1024)} MB limit`); + err.name = "FileTooLargeError"; + + throw err; + } + + await writeFile(safeUploadPath, buffer as unknown as any); + } catch (err) { + throw err; + } +}; + +// a single service to put file in the storage(local or S3), based on the S3 configuration +export const putFile = async ( + fileName: string, + fileBuffer: Buffer, + accessType: TAccessType, + environmentId: string +) => { + try { + if (!isS3Configured()) { + await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR); + return { success: true, message: "File uploaded" }; + } else { + const input = { + Body: fileBuffer, + Bucket: S3_BUCKET_NAME, + Key: `${environmentId}/${accessType}/${fileName}`, + }; + + const command = new PutObjectCommand(input); + const s3Client = getS3Client(); + await s3Client.send(command); + return { success: true, message: "File uploaded" }; + } + } catch (err) { + throw err; + } +}; + +export const deleteFile = async ( + environmentId: string, + accessType: TAccessType, + fileName: string +): Promise<{ success: boolean; message: string; code?: number }> => { + if (!isS3Configured()) { + try { + await deleteLocalFile(path.join(UPLOADS_DIR, environmentId, accessType, fileName)); + return { success: true, message: "File deleted" }; + } catch (err: any) { + if (err.code !== "ENOENT") { + return { success: false, message: err.message ?? "Something went wrong" }; + } + + return { success: false, message: "File not found", code: 404 }; + } + } + + try { + await deleteS3File(`${environmentId}/${accessType}/${fileName}`); + return { success: true, message: "File deleted" }; + } catch (err: any) { + if (err.name === "NoSuchKey") { + return { success: false, message: "File not found", code: 404 }; + } else { + return { success: false, message: err.message ?? "Something went wrong" }; + } + } +}; + +export const deleteLocalFile = async (filePath: string) => { + try { + const safeFilePath = validateAndResolvePath(filePath); + await unlink(safeFilePath); + } catch (err: any) { + throw err; + } +}; + +export const deleteS3File = async (fileKey: string) => { + const deleteObjectCommand = new DeleteObjectCommand({ + Bucket: S3_BUCKET_NAME, + Key: fileKey, + }); + + try { + const s3Client = getS3Client(); + await s3Client.send(deleteObjectCommand); + } catch (err) { + throw err; + } +}; + +export const deleteS3FilesByEnvironmentId = async (environmentId: string) => { + try { + // List all objects in the bucket with the prefix of environmentId + const s3Client = getS3Client(); + const listObjectsOutput = await s3Client.send( + new ListObjectsCommand({ + Bucket: S3_BUCKET_NAME, + Prefix: environmentId, + }) + ); + + if (listObjectsOutput.Contents) { + const objectsToDelete = listObjectsOutput.Contents.map((obj) => { + return { Key: obj.Key }; + }); + + if (!objectsToDelete.length) { + // no objects to delete + return null; + } + + // Delete the objects + await s3Client.send( + new DeleteObjectsCommand({ + Bucket: S3_BUCKET_NAME, + Delete: { + Objects: objectsToDelete, + }, + }) + ); + } else { + // no objects to delete + return null; + } + } catch (err) { + throw err; + } +}; + +export const deleteLocalFilesByEnvironmentId = async (environmentId: string) => { + const dirPath = join(UPLOADS_DIR, environmentId); + + try { + await ensureDirectoryExists(dirPath); + await rmdir(dirPath, { recursive: true }); + } catch (err) { + throw err; + } +}; diff --git a/apps/web/lib/storage/utils.test.ts b/apps/web/lib/storage/utils.test.ts new file mode 100644 index 000000000000..e41fe79a52fa --- /dev/null +++ b/apps/web/lib/storage/utils.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; +import { getFileNameWithIdFromUrl, getOriginalFileNameFromUrl } from "./utils"; + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +describe("Storage Utils", () => { + describe("getOriginalFileNameFromUrl", () => { + test("should handle URL without file ID", () => { + const url = "/storage/test-file.pdf"; + expect(getOriginalFileNameFromUrl(url)).toBe("test-file.pdf"); + }); + + test("should handle invalid URL", () => { + const url = "invalid-url"; + expect(getOriginalFileNameFromUrl(url)).toBeUndefined(); + expect(logger.error).toHaveBeenCalled(); + }); + }); + + describe("getFileNameWithIdFromUrl", () => { + test("should get full filename with ID from storage URL", () => { + const url = "/storage/test-file.pdf--fid--123"; + expect(getFileNameWithIdFromUrl(url)).toBe("test-file.pdf--fid--123"); + }); + + test("should get full filename with ID from external URL", () => { + const url = "https://example.com/path/test-file.pdf--fid--123"; + expect(getFileNameWithIdFromUrl(url)).toBe("test-file.pdf--fid--123"); + }); + + test("should handle invalid URL", () => { + const url = "invalid-url"; + expect(getFileNameWithIdFromUrl(url)).toBeUndefined(); + expect(logger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/lib/storage/utils.ts b/apps/web/lib/storage/utils.ts similarity index 100% rename from packages/lib/storage/utils.ts rename to apps/web/lib/storage/utils.ts diff --git a/apps/web/lib/styling/constants.ts b/apps/web/lib/styling/constants.ts new file mode 100644 index 000000000000..0d43306047d4 --- /dev/null +++ b/apps/web/lib/styling/constants.ts @@ -0,0 +1,46 @@ +// https://github.com/airbnb/javascript/#naming--uppercase +import { TProjectStyling } from "@formbricks/types/project"; + +export const COLOR_DEFAULTS = { + brandColor: "#64748b", + questionColor: "#2b2524", + inputColor: "#ffffff", + inputBorderColor: "#cbd5e1", + cardBackgroundColor: "#ffffff", + cardBorderColor: "#f8fafc", + highlightBorderColor: "#64748b", +} as const; + +export const defaultStyling: TProjectStyling = { + allowStyleOverwrite: true, + brandColor: { + light: COLOR_DEFAULTS.brandColor, + }, + questionColor: { + light: COLOR_DEFAULTS.questionColor, + }, + inputColor: { + light: COLOR_DEFAULTS.inputColor, + }, + inputBorderColor: { + light: COLOR_DEFAULTS.inputBorderColor, + }, + cardBackgroundColor: { + light: COLOR_DEFAULTS.cardBackgroundColor, + }, + cardBorderColor: { + light: COLOR_DEFAULTS.cardBorderColor, + }, + isLogoHidden: false, + highlightBorderColor: undefined, + isDarkModeEnabled: false, + background: { + bg: "#fff", + bgType: "color", + }, + roundness: 8, + cardArrangement: { + linkSurveys: "straight", + appSurveys: "straight", + }, +}; diff --git a/packages/lib/survey/tests/__mock__/survey.mock.ts b/apps/web/lib/survey/__mock__/survey.mock.ts similarity index 98% rename from packages/lib/survey/tests/__mock__/survey.mock.ts rename to apps/web/lib/survey/__mock__/survey.mock.ts index 825ef6c54068..f4d681ae2c3b 100644 --- a/packages/lib/survey/tests/__mock__/survey.mock.ts +++ b/apps/web/lib/survey/__mock__/survey.mock.ts @@ -13,7 +13,7 @@ import { TSurveyWelcomeCard, } from "@formbricks/types/surveys/types"; import { TUser } from "@formbricks/types/user"; -import { selectSurvey } from "../../service"; +import { selectSurvey } from "../service"; const selectContact = { id: true, @@ -130,7 +130,7 @@ export const mockUser: TUser = { objective: "improve_user_retention", notificationSettings: { alert: {}, - weeklySummary: {}, + unsubscribedOrganizationIds: [], }, role: "other", @@ -143,7 +143,6 @@ export const mockPrismaPerson: Prisma.ContactGetPayload<{ include: typeof selectContact; }> = { id: mockId, - userId: mockId, attributes: [ { value: "de", @@ -202,7 +201,7 @@ const baseSurveyProperties = { autoComplete: 7, runOnDate: null, closeOnDate: currentDate, - redirectUrl: "http://github.com/formbricks/formbricks", + redirectUrl: "https://github.com/formbricks/formbricks", recontactDays: 3, displayLimit: 3, welcomeCard: mockWelcomeCard, @@ -254,12 +253,12 @@ export const mockSyncSurveyOutput: SurveyMock = { projectOverwrites: null, singleUse: null, styling: null, + recaptcha: null, displayPercentage: null, createdBy: null, pin: null, segment: null, segmentId: null, - resultShareKey: null, inlineTriggers: null, languages: mockSurveyLanguages, ...baseSurveyProperties, @@ -276,6 +275,7 @@ export const mockSurveyOutput: SurveyMock = { displayOption: "respondMultiple", triggers: [{ actionClass: mockActionClass }], projectOverwrites: null, + recaptcha: null, singleUse: null, styling: null, displayPercentage: null, @@ -283,7 +283,6 @@ export const mockSurveyOutput: SurveyMock = { pin: null, segment: null, segmentId: null, - resultShareKey: null, inlineTriggers: null, languages: mockSurveyLanguages, followUps: [], @@ -312,7 +311,7 @@ export const updateSurveyInput: TSurvey = { displayPercentage: null, createdBy: null, pin: null, - resultShareKey: null, + recaptcha: null, segment: null, languages: [], showLanguageSwitch: null, diff --git a/apps/web/lib/survey/service.test.ts b/apps/web/lib/survey/service.test.ts new file mode 100644 index 000000000000..b6377ad653d7 --- /dev/null +++ b/apps/web/lib/survey/service.test.ts @@ -0,0 +1,957 @@ +import { prisma } from "@/lib/__mocks__/database"; +import { getActionClasses } from "@/lib/actionClass/service"; +import { + getOrganizationByEnvironmentId, + subscribeOrganizationMembersToSurveyResponses, +} from "@/lib/organization/service"; +import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer"; +import { evaluateLogic } from "@/lib/surveyLogic/utils"; +import { ActionClass, Prisma, Survey } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { testInputValidation } from "vitestSetup"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up"; +import { TActionClass } from "@formbricks/types/action-classes"; +import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TSegment } from "@formbricks/types/segment"; +import { TSurvey, TSurveyCreateInput, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { + mockActionClass, + mockId, + mockOrganizationOutput, + mockSurveyOutput, + mockSurveyWithLogic, + mockTransformedSurveyOutput, + updateSurveyInput, +} from "./__mock__/survey.mock"; +import { + createSurvey, + getSurvey, + getSurveyCount, + getSurveys, + getSurveysByActionClassId, + getSurveysBySegmentId, + handleTriggerUpdates, + loadNewSegmentInSurvey, + updateSurvey, +} from "./service"; + +// Mock organization service +vi.mock("@/lib/organization/service", () => ({ + getOrganizationByEnvironmentId: vi.fn().mockResolvedValue({ + id: "org123", + }), + subscribeOrganizationMembersToSurveyResponses: vi.fn(), +})); + +// Mock posthogServer +vi.mock("@/lib/posthogServer", () => ({ + capturePosthogEnvironmentEvent: vi.fn(), +})); + +// Mock actionClass service +vi.mock("@/lib/actionClass/service", () => ({ + getActionClasses: vi.fn(), +})); + +beforeEach(() => { + prisma.survey.count.mockResolvedValue(1); +}); + +describe("evaluateLogic with mockSurveyWithLogic", () => { + test("should return true when q1 answer is blue", () => { + const data = { q1: "blue" }; + const variablesData = {}; + + const result = evaluateLogic( + mockSurveyWithLogic, + data, + variablesData, + mockSurveyWithLogic.questions[0].logic![0].conditions, + "default" + ); + expect(result).toBe(true); + }); + + test("should return false when q1 answer is not blue", () => { + const data = { q1: "red" }; + const variablesData = {}; + + const result = evaluateLogic( + mockSurveyWithLogic, + data, + variablesData, + mockSurveyWithLogic.questions[0].logic![0].conditions, + "default" + ); + expect(result).toBe(false); + }); + + test("should return true when q1 is blue and q2 is pizza", () => { + const data = { q1: "blue", q2: "pizza" }; + const variablesData = {}; + + const result = evaluateLogic( + mockSurveyWithLogic, + data, + variablesData, + mockSurveyWithLogic.questions[1].logic![0].conditions, + "default" + ); + expect(result).toBe(true); + }); + + test("should return false when q1 is blue but q2 is not pizza", () => { + const data = { q1: "blue", q2: "burger" }; + const variablesData = {}; + + const result = evaluateLogic( + mockSurveyWithLogic, + data, + variablesData, + mockSurveyWithLogic.questions[1].logic![0].conditions, + "default" + ); + expect(result).toBe(false); + }); + + test("should return true when q2 is pizza or q3 is Inception", () => { + const data = { q2: "pizza", q3: "Inception" }; + const variablesData = {}; + + const result = evaluateLogic( + mockSurveyWithLogic, + data, + variablesData, + mockSurveyWithLogic.questions[2].logic![0].conditions, + "default" + ); + expect(result).toBe(true); + }); + + test("should return true when var1 is equal to single select question value", () => { + const data = { q4: "lmao" }; + const variablesData = { siog1dabtpo3l0a3xoxw2922: "lmao" }; + + const result = evaluateLogic( + mockSurveyWithLogic, + data, + variablesData, + mockSurveyWithLogic.questions[3].logic![0].conditions, + "default" + ); + expect(result).toBe(true); + }); + + test("should return false when var1 is not equal to single select question value", () => { + const data = { q4: "lol" }; + const variablesData = { siog1dabtpo3l0a3xoxw2922: "damn" }; + + const result = evaluateLogic( + mockSurveyWithLogic, + data, + variablesData, + mockSurveyWithLogic.questions[3].logic![0].conditions, + "default" + ); + expect(result).toBe(false); + }); + + test("should return true when var2 is greater than 30 and less than open text number value", () => { + const data = { q5: "40" }; + const variablesData = { km1srr55owtn2r7lkoh5ny1u: 35 }; + + const result = evaluateLogic( + mockSurveyWithLogic, + data, + variablesData, + mockSurveyWithLogic.questions[4].logic![0].conditions, + "default" + ); + expect(result).toBe(true); + }); + + test("should return false when var2 is not greater than 30 or greater than open text number value", () => { + const data = { q5: "40" }; + const variablesData = { km1srr55owtn2r7lkoh5ny1u: 25 }; + + const result = evaluateLogic( + mockSurveyWithLogic, + data, + variablesData, + mockSurveyWithLogic.questions[4].logic![0].conditions, + "default" + ); + expect(result).toBe(false); + }); + + test("should return for complex condition", () => { + const data = { q6: ["lmao", "XD"], q1: "green", q2: "pizza", q3: "inspection", name: "pizza" }; + const variablesData = { siog1dabtpo3l0a3xoxw2922: "tokyo" }; + + const result = evaluateLogic( + mockSurveyWithLogic, + data, + variablesData, + mockSurveyWithLogic.questions[5].logic![0].conditions, + "default" + ); + expect(result).toBe(true); + }); +}); + +describe("Tests for getSurvey", () => { + describe("Happy Path", () => { + test("Returns a survey", async () => { + prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput); + const survey = await getSurvey(mockId); + expect(survey).toEqual(mockTransformedSurveyOutput); + }); + + test("Returns null if survey is not found", async () => { + prisma.survey.findUnique.mockResolvedValueOnce(null); + const survey = await getSurvey(mockId); + expect(survey).toBeNull(); + }); + }); + + describe("Sad Path", () => { + testInputValidation(getSurvey, "123#"); + + test("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => { + const mockErrorMessage = "Mock error message"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + prisma.survey.findUnique.mockRejectedValue(errToThrow); + await expect(getSurvey(mockId)).rejects.toThrow(DatabaseError); + }); + + test("should throw an error if there is an unknown error", async () => { + const mockErrorMessage = "Mock error message"; + prisma.survey.findUnique.mockRejectedValue(new Error(mockErrorMessage)); + await expect(getSurvey(mockId)).rejects.toThrow(Error); + }); + }); +}); + +describe("Tests for getSurveysByActionClassId", () => { + describe("Happy Path", () => { + test("Returns an array of surveys for a given actionClassId", async () => { + prisma.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]); + const surveys = await getSurveysByActionClassId(mockId); + expect(surveys).toEqual([mockTransformedSurveyOutput]); + }); + + test("Returns an empty array if no surveys are found", async () => { + prisma.survey.findMany.mockResolvedValueOnce([]); + const surveys = await getSurveysByActionClassId(mockId); + expect(surveys).toEqual([]); + }); + }); + + describe("Sad Path", () => { + testInputValidation(getSurveysByActionClassId, "123#"); + + test("should throw an error if there is an unknown error", async () => { + const mockErrorMessage = "Unknown error occurred"; + prisma.survey.findMany.mockRejectedValue(new Error(mockErrorMessage)); + await expect(getSurveysByActionClassId(mockId)).rejects.toThrow(Error); + }); + }); +}); + +describe("Tests for getSurveys", () => { + describe("Happy Path", () => { + test("Returns an array of surveys for a given environmentId, limit(optional) and offset(optional)", async () => { + prisma.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]); + const surveys = await getSurveys(mockId); + expect(surveys).toEqual([mockTransformedSurveyOutput]); + }); + + test("Returns an empty array if no surveys are found", async () => { + prisma.survey.findMany.mockResolvedValueOnce([]); + + const surveys = await getSurveys(mockId); + expect(surveys).toEqual([]); + }); + }); + + describe("Sad Path", () => { + testInputValidation(getSurveysByActionClassId, "123#"); + + test("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => { + const mockErrorMessage = "Mock error message"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + + prisma.survey.findMany.mockRejectedValue(errToThrow); + await expect(getSurveys(mockId)).rejects.toThrow(DatabaseError); + }); + + test("should throw an error if there is an unknown error", async () => { + const mockErrorMessage = "Unknown error occurred"; + prisma.survey.findMany.mockRejectedValue(new Error(mockErrorMessage)); + await expect(getSurveys(mockId)).rejects.toThrow(Error); + }); + }); +}); + +describe("Tests for updateSurvey", () => { + beforeEach(() => { + vi.mocked(getActionClasses).mockResolvedValueOnce([mockActionClass] as TActionClass[]); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput); + }); + + describe("Happy Path", () => { + test("Updates a survey successfully", async () => { + prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput); + prisma.survey.update.mockResolvedValueOnce(mockSurveyOutput); + const updatedSurvey = await updateSurvey(updateSurveyInput); + expect(updatedSurvey).toEqual(mockTransformedSurveyOutput); + }); + }); + + describe("Sad Path", () => { + testInputValidation(updateSurvey, "123#"); + + test("Throws ResourceNotFoundError if the survey does not exist", async () => { + prisma.survey.findUnique.mockRejectedValueOnce( + new ResourceNotFoundError("Survey", updateSurveyInput.id) + ); + await expect(updateSurvey(updateSurveyInput)).rejects.toThrow(ResourceNotFoundError); + }); + + test("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => { + const mockErrorMessage = "Mock error message"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput); + prisma.survey.update.mockRejectedValue(errToThrow); + await expect(updateSurvey(updateSurveyInput)).rejects.toThrow(DatabaseError); + }); + + test("should throw an error if there is an unknown error", async () => { + const mockErrorMessage = "Unknown error occurred"; + prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput); + prisma.survey.update.mockRejectedValue(new Error(mockErrorMessage)); + await expect(updateSurvey(updateSurveyInput)).rejects.toThrow(Error); + }); + }); +}); + +describe("Tests for getSurveyCount service", () => { + describe("Happy Path", () => { + test("Counts the total number of surveys for a given environment ID", async () => { + const count = await getSurveyCount(mockId); + expect(count).toEqual(1); + }); + + test("Returns zero count when there are no surveys for a given environment ID", async () => { + prisma.survey.count.mockResolvedValue(0); + const count = await getSurveyCount(mockId); + expect(count).toEqual(0); + }); + }); + + describe("Sad Path", () => { + testInputValidation(getSurveyCount, "123#"); + + test("Throws a generic Error for other unexpected issues", async () => { + const mockErrorMessage = "Mock error message"; + prisma.survey.count.mockRejectedValue(new Error(mockErrorMessage)); + + await expect(getSurveyCount(mockId)).rejects.toThrow(Error); + }); + }); +}); + +describe("Tests for handleTriggerUpdates", () => { + const mockEnvironmentId = "env-123"; + const mockActionClassId1 = "action-123"; + const mockActionClassId2 = "action-456"; + + const mockActionClasses: ActionClass[] = [ + { + id: mockActionClassId1, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: mockEnvironmentId, + name: "Test Action 1", + description: "Test action description 1", + type: "code", + key: "test-action-1", + noCodeConfig: null, + }, + { + id: mockActionClassId2, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: mockEnvironmentId, + name: "Test Action 2", + description: "Test action description 2", + type: "code", + key: "test-action-2", + noCodeConfig: null, + }, + ]; + + test("adds new triggers correctly", () => { + const updatedTriggers = [ + { + actionClass: { + id: mockActionClassId1, + name: "Test Action 1", + environmentId: mockEnvironmentId, + type: "code", + key: "test-action-1", + }, + }, + ] as TSurvey["triggers"]; + const currentTriggers = []; + + const result = handleTriggerUpdates(updatedTriggers, currentTriggers, mockActionClasses); + + expect(result).toHaveProperty("create"); + expect(result.create).toEqual([{ actionClassId: mockActionClassId1 }]); + }); + + test("removes deleted triggers correctly", () => { + const updatedTriggers = []; + const currentTriggers = [ + { + actionClass: { + id: mockActionClassId1, + name: "Test Action 1", + environmentId: mockEnvironmentId, + type: "code", + key: "test-action-1", + }, + }, + ] as TSurvey["triggers"]; + + const result = handleTriggerUpdates(updatedTriggers, currentTriggers, mockActionClasses); + + expect(result).toHaveProperty("deleteMany"); + expect(result.deleteMany).toEqual({ actionClassId: { in: [mockActionClassId1] } }); + }); + + test("handles both adding and removing triggers", () => { + const updatedTriggers = [ + { + actionClass: { + id: mockActionClassId2, + name: "Test Action 2", + environmentId: mockEnvironmentId, + type: "code", + key: "test-action-2", + }, + }, + ] as TSurvey["triggers"]; + + const currentTriggers = [ + { + actionClass: { + id: mockActionClassId1, + name: "Test Action 1", + environmentId: mockEnvironmentId, + type: "code", + key: "test-action-1", + }, + }, + ] as TSurvey["triggers"]; + + const result = handleTriggerUpdates(updatedTriggers, currentTriggers, mockActionClasses); + + expect(result).toHaveProperty("create"); + expect(result).toHaveProperty("deleteMany"); + expect(result.create).toEqual([{ actionClassId: mockActionClassId2 }]); + expect(result.deleteMany).toEqual({ actionClassId: { in: [mockActionClassId1] } }); + }); + + test("returns empty object when no triggers provided", () => { + // @ts-expect-error -- This is a test case to check the empty input + const result = handleTriggerUpdates(undefined, [], mockActionClasses); + expect(result).toEqual({}); + }); + + test("throws InvalidInputError for invalid trigger IDs", () => { + const updatedTriggers = [ + { + actionClass: { + id: "invalid-action-id", + name: "Invalid Action", + environmentId: mockEnvironmentId, + type: "code", + key: "invalid-action", + }, + }, + ] as TSurvey["triggers"]; + + const currentTriggers = []; + + expect(() => handleTriggerUpdates(updatedTriggers, currentTriggers, mockActionClasses)).toThrow( + InvalidInputError + ); + }); + + test("throws InvalidInputError for duplicate trigger IDs", () => { + const updatedTriggers = [ + { + actionClass: { + id: mockActionClassId1, + name: "Test Action 1", + environmentId: mockEnvironmentId, + type: "code", + key: "test-action-1", + }, + }, + { + actionClass: { + id: mockActionClassId1, // Duplicated ID + name: "Test Action 1", + environmentId: mockEnvironmentId, + type: "code", + key: "test-action-1", + }, + }, + ] as TSurvey["triggers"]; + const currentTriggers = []; + + expect(() => handleTriggerUpdates(updatedTriggers, currentTriggers, mockActionClasses)).toThrow( + InvalidInputError + ); + }); +}); + +describe("Tests for createSurvey", () => { + const mockEnvironmentId = "env123"; + const mockUserId = "user123"; + + const mockCreateSurveyInput = { + name: "Test Survey", + type: "app" as const, + createdBy: mockUserId, + status: "inProgress" as const, + welcomeCard: { + enabled: true, + headline: { default: "Welcome" }, + html: { default: "

Welcome to our survey

" }, + }, + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + inputType: "text", + headline: { default: "What is your favorite color?" }, + required: true, + charLimit: { + enabled: false, + }, + }, + { + id: "q2", + type: TSurveyQuestionTypeEnum.OpenText, + inputType: "text", + headline: { default: "What is your favorite food?" }, + required: true, + charLimit: { + enabled: false, + }, + }, + { + id: "q3", + type: TSurveyQuestionTypeEnum.OpenText, + inputType: "text", + headline: { default: "What is your favorite movie?" }, + required: true, + charLimit: { + enabled: false, + }, + }, + { + id: "q4", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "Select a number:" }, + choices: [ + { id: "mvedaklp0gxxycprpyhhwen7", label: { default: "lol" } }, + { id: "i7ws8uqyj66q5x086vbqtm8n", label: { default: "lmao" } }, + { id: "cy8hbbr9e2q6ywbfjbzwdsqn", label: { default: "XD" } }, + { id: "sojc5wwxc5gxrnuib30w7t6s", label: { default: "hehe" } }, + ], + required: true, + }, + { + id: "q5", + type: TSurveyQuestionTypeEnum.OpenText, + inputType: "number", + headline: { default: "Select your age group:" }, + required: true, + charLimit: { + enabled: false, + }, + }, + { + id: "q6", + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + headline: { default: "Select your age group:" }, + required: true, + choices: [ + { id: "mvedaklp0gxxycprpyhhwen7", label: { default: "lol" } }, + { id: "i7ws8uqyj66q5x086vbqtm8n", label: { default: "lmao" } }, + { id: "cy8hbbr9e2q6ywbfjbzwdsqn", label: { default: "XD" } }, + { id: "sojc5wwxc5gxrnuib30w7t6s", label: { default: "hehe" } }, + ], + }, + ], + variables: [], + hiddenFields: { enabled: false, fieldIds: [] }, + endings: [], + displayOption: "respondMultiple" as const, + languages: [], + } as TSurveyCreateInput; + + const mockActionClasses = [ + { + id: "action-123", + createdAt: new Date(), + updatedAt: new Date(), + environmentId: mockEnvironmentId, + name: "Test Action", + description: "Test action description", + type: "code", + key: "test-action", + noCodeConfig: null, + }, + ]; + + beforeEach(() => { + vi.mocked(getActionClasses).mockResolvedValue(mockActionClasses as TActionClass[]); + }); + + describe("Happy Path", () => { + test("creates a survey successfully", async () => { + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput); + prisma.survey.create.mockResolvedValueOnce({ + ...mockSurveyOutput, + }); + + const result = await createSurvey(mockEnvironmentId, mockCreateSurveyInput); + + expect(prisma.survey.create).toHaveBeenCalled(); + expect(result.name).toEqual(mockSurveyOutput.name); + expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled(); + expect(capturePosthogEnvironmentEvent).toHaveBeenCalled(); + }); + + test("creates a private segment for app surveys", async () => { + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput); + prisma.survey.create.mockResolvedValueOnce({ + ...mockSurveyOutput, + type: "app", + }); + + prisma.segment.create.mockResolvedValueOnce({ + id: "segment-123", + environmentId: mockEnvironmentId, + title: mockSurveyOutput.id, + isPrivate: true, + filters: [], + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as TSegment); + + await createSurvey(mockEnvironmentId, { + ...mockCreateSurveyInput, + type: "app", + }); + + expect(prisma.segment.create).toHaveBeenCalled(); + expect(prisma.survey.update).toHaveBeenCalled(); + }); + + test("creates survey with follow-ups", async () => { + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput); + const followUp = { + id: "followup1", + name: "Follow up 1", + trigger: { type: "response", properties: null }, + action: { + type: "send-email", + properties: { + to: "abc@example.com", + attachResponseData: true, + body: "Hello", + from: "hello@exmaple.com", + replyTo: ["hello@example.com"], + subject: "Follow up", + }, + }, + surveyId: mockSurveyOutput.id, + createdAt: new Date(), + updatedAt: new Date(), + } as TSurveyFollowUp; + + const surveyWithFollowUps = { + ...mockCreateSurveyInput, + followUps: [followUp], + }; + + prisma.survey.create.mockResolvedValueOnce({ + ...mockSurveyOutput, + }); + + await createSurvey(mockEnvironmentId, surveyWithFollowUps); + + expect(prisma.survey.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + followUps: { + create: [ + expect.objectContaining({ + name: "Follow up 1", + }), + ], + }, + }), + }) + ); + }); + }); + + describe("Sad Path", () => { + testInputValidation(createSurvey, "123#", mockCreateSurveyInput); + + test("throws ResourceNotFoundError if organization not found", async () => { + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null); + await expect(createSurvey(mockEnvironmentId, mockCreateSurveyInput)).rejects.toThrow( + ResourceNotFoundError + ); + }); + + test("throws DatabaseError if there is a Prisma error", async () => { + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput); + const mockError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "1.0.0", + }); + prisma.survey.create.mockRejectedValueOnce(mockError); + + await expect(createSurvey(mockEnvironmentId, mockCreateSurveyInput)).rejects.toThrow(DatabaseError); + }); + }); +}); + +describe("Tests for loadNewSegmentInSurvey", () => { + const mockSurveyId = mockId; + const mockNewSegmentId = "segment456"; + const mockCurrentSegmentId = "segment-123"; + const mockEnvironmentId = "env-123"; + + describe("Happy Path", () => { + test("loads new segment successfully", async () => { + // Set up mocks for existing survey + prisma.survey.findUnique.mockResolvedValueOnce({ + ...mockSurveyOutput, + }); + // Mock segment exists + prisma.segment.findUnique.mockResolvedValueOnce({ + id: mockNewSegmentId, + environmentId: mockEnvironmentId, + filters: [], + title: "Test Segment", + isPrivate: false, + createdAt: new Date(), + updatedAt: new Date(), + description: "Test Segment Description", + }); + // Mock survey update + prisma.survey.update.mockResolvedValueOnce({ + ...mockSurveyOutput, + segmentId: mockNewSegmentId, + }); + const result = await loadNewSegmentInSurvey(mockSurveyId, mockNewSegmentId); + expect(prisma.survey.update).toHaveBeenCalledWith({ + where: { id: mockSurveyId }, + data: { + segment: { + connect: { + id: mockNewSegmentId, + }, + }, + }, + select: expect.anything(), + }); + expect(result).toEqual( + expect.objectContaining({ + segmentId: mockNewSegmentId, + }) + ); + }); + + test("deletes private segment when changing to a new segment", async () => { + const mockSegment = { + id: mockCurrentSegmentId, + environmentId: mockEnvironmentId, + title: mockId, // Private segments have title = surveyId + isPrivate: true, + filters: [], + surveys: [mockSurveyId], + createdAt: new Date(), + updatedAt: new Date(), + description: "Test Segment Description", + }; + + // Set up mocks for existing survey with private segment + prisma.survey.findUnique.mockResolvedValueOnce({ + ...mockSurveyOutput, + segment: mockSegment, + } as Survey); + + // Mock segment exists + prisma.segment.findUnique.mockResolvedValueOnce({ + ...mockSegment, + id: mockNewSegmentId, + environmentId: mockEnvironmentId, + }); + + // Mock survey update + prisma.survey.update.mockResolvedValueOnce({ + ...mockSurveyOutput, + segment: { + id: mockNewSegmentId, + environmentId: mockEnvironmentId, + title: "Test Segment", + isPrivate: false, + filters: [], + surveys: [{ id: mockSurveyId }], + }, + } as Survey); + + // Mock segment delete + prisma.segment.delete.mockResolvedValueOnce({ + id: mockCurrentSegmentId, + environmentId: mockEnvironmentId, + surveys: [{ id: mockSurveyId }], + } as unknown as TSegment); + + await loadNewSegmentInSurvey(mockSurveyId, mockNewSegmentId); + + // Verify the private segment was deleted + expect(prisma.segment.delete).toHaveBeenCalledWith({ + where: { id: mockCurrentSegmentId }, + select: expect.anything(), + }); + }); + }); + + describe("Sad Path", () => { + testInputValidation(loadNewSegmentInSurvey, "123#", "123#"); + + test("throws ResourceNotFoundError when survey not found", async () => { + prisma.survey.findUnique.mockResolvedValueOnce(null); + + await expect(loadNewSegmentInSurvey(mockSurveyId, mockNewSegmentId)).rejects.toThrow( + ResourceNotFoundError + ); + }); + + test("throws ResourceNotFoundError when segment not found", async () => { + // Set up mock for existing survey + prisma.survey.findUnique.mockResolvedValueOnce({ + ...mockSurveyOutput, + }); + + // Segment not found + prisma.segment.findUnique.mockResolvedValueOnce(null); + + await expect(loadNewSegmentInSurvey(mockSurveyId, mockNewSegmentId)).rejects.toThrow( + ResourceNotFoundError + ); + }); + + test("throws DatabaseError on Prisma error", async () => { + // Set up mock for existing survey + prisma.survey.findUnique.mockResolvedValueOnce({ + ...mockSurveyOutput, + }); + + // // Mock segment exists + prisma.segment.findUnique.mockResolvedValueOnce({ + id: mockNewSegmentId, + environmentId: mockEnvironmentId, + filters: [], + title: "Test Segment", + isPrivate: false, + createdAt: new Date(), + updatedAt: new Date(), + description: "Test Segment Description", + }); + + // Mock Prisma error on update + const mockError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "1.0.0", + }); + + prisma.survey.update.mockRejectedValueOnce(mockError); + + await expect(loadNewSegmentInSurvey(mockSurveyId, mockNewSegmentId)).rejects.toThrow(DatabaseError); + }); + }); +}); + +describe("Tests for getSurveysBySegmentId", () => { + const mockSegmentId = "segment-123"; + + describe("Happy Path", () => { + test("returns surveys associated with a segment", async () => { + prisma.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]); + + const result = await getSurveysBySegmentId(mockSegmentId); + + expect(prisma.survey.findMany).toHaveBeenCalledWith({ + where: { segmentId: mockSegmentId }, + select: expect.anything(), + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual( + expect.objectContaining({ + id: mockSurveyOutput.id, + }) + ); + }); + + test("returns empty array when no surveys found", async () => { + prisma.survey.findMany.mockResolvedValueOnce([]); + + const result = await getSurveysBySegmentId(mockSegmentId); + + expect(result).toEqual([]); + }); + }); + + describe("Sad Path", () => { + test("throws DatabaseError on Prisma error", async () => { + const mockError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "1.0.0", + }); + prisma.survey.findMany.mockRejectedValueOnce(mockError); + + await expect(getSurveysBySegmentId(mockSegmentId)).rejects.toThrow(DatabaseError); + }); + + test("throws error on unexpected error", async () => { + prisma.survey.findMany.mockRejectedValueOnce(new Error("Unexpected error")); + + await expect(getSurveysBySegmentId(mockSegmentId)).rejects.toThrow(Error); + }); + }); +}); diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts new file mode 100644 index 000000000000..580540193768 --- /dev/null +++ b/apps/web/lib/survey/service.ts @@ -0,0 +1,808 @@ +import "server-only"; +import { + getOrganizationByEnvironmentId, + subscribeOrganizationMembersToSurveyResponses, +} from "@/lib/organization/service"; +import { ActionClass, Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { ZId, ZOptionalNumber } from "@formbricks/types/common"; +import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TSegment, ZSegmentFilters } from "@formbricks/types/segment"; +import { TSurvey, TSurveyCreateInput, ZSurvey, ZSurveyCreateInput } from "@formbricks/types/surveys/types"; +import { getActionClasses } from "../actionClass/service"; +import { ITEMS_PER_PAGE } from "../constants"; +import { capturePosthogEnvironmentEvent } from "../posthogServer"; +import { validateInputs } from "../utils/validate"; +import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils"; + +interface TriggerUpdate { + create?: Array<{ actionClassId: string }>; + deleteMany?: { + actionClassId: { + in: string[]; + }; + }; +} + +export const selectSurvey = { + id: true, + createdAt: true, + updatedAt: true, + name: true, + type: true, + environmentId: true, + createdBy: true, + status: true, + welcomeCard: true, + questions: true, + endings: true, + hiddenFields: true, + variables: true, + displayOption: true, + recontactDays: true, + displayLimit: true, + autoClose: true, + runOnDate: true, + closeOnDate: true, + delay: true, + displayPercentage: true, + autoComplete: true, + isVerifyEmailEnabled: true, + isSingleResponsePerEmailEnabled: true, + isBackButtonHidden: true, + redirectUrl: true, + projectOverwrites: true, + styling: true, + surveyClosedMessage: true, + singleUse: true, + pin: true, + showLanguageSwitch: true, + recaptcha: true, + languages: { + select: { + default: true, + enabled: true, + language: { + select: { + id: true, + code: true, + alias: true, + createdAt: true, + updatedAt: true, + projectId: true, + }, + }, + }, + }, + triggers: { + select: { + actionClass: { + select: { + id: true, + createdAt: true, + updatedAt: true, + environmentId: true, + name: true, + description: true, + type: true, + key: true, + noCodeConfig: true, + }, + }, + }, + }, + segment: { + include: { + surveys: { + select: { + id: true, + }, + }, + }, + }, + followUps: true, +} satisfies Prisma.SurveySelect; + +export const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: ActionClass[]) => { + if (!triggers) return; + + // check if all the triggers are valid + triggers.forEach((trigger) => { + if (!actionClasses.find((actionClass) => actionClass.id === trigger.actionClass.id)) { + throw new InvalidInputError("Invalid trigger id"); + } + }); + + // check if all the triggers are unique + const triggerIds = triggers.map((trigger) => trigger.actionClass.id); + + if (new Set(triggerIds).size !== triggerIds.length) { + throw new InvalidInputError("Duplicate trigger id"); + } +}; + +export const handleTriggerUpdates = ( + updatedTriggers: TSurvey["triggers"], + currentTriggers: TSurvey["triggers"], + actionClasses: ActionClass[] +) => { + if (!updatedTriggers) return {}; + checkTriggersValidity(updatedTriggers, actionClasses); + + const currentTriggerIds = currentTriggers.map((trigger) => trigger.actionClass.id); + const updatedTriggerIds = updatedTriggers.map((trigger) => trigger.actionClass.id); + + // added triggers are triggers that are not in the current triggers and are there in the new triggers + const addedTriggers = updatedTriggers.filter( + (trigger) => !currentTriggerIds.includes(trigger.actionClass.id) + ); + + // deleted triggers are triggers that are not in the new triggers and are there in the current triggers + const deletedTriggers = currentTriggers.filter( + (trigger) => !updatedTriggerIds.includes(trigger.actionClass.id) + ); + + // Construct the triggers update object + const triggersUpdate: TriggerUpdate = {}; + + if (addedTriggers.length > 0) { + triggersUpdate.create = addedTriggers.map((trigger) => ({ + actionClassId: trigger.actionClass.id, + })); + } + + if (deletedTriggers.length > 0) { + // disconnect the public triggers from the survey + triggersUpdate.deleteMany = { + actionClassId: { + in: deletedTriggers.map((trigger) => trigger.actionClass.id), + }, + }; + } + + return triggersUpdate; +}; + +export const getSurvey = reactCache(async (surveyId: string): Promise => { + validateInputs([surveyId, ZId]); + + let surveyPrisma; + try { + surveyPrisma = await prisma.survey.findUnique({ + where: { + id: surveyId, + }, + select: selectSurvey, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error getting survey"); + throw new DatabaseError(error.message); + } + throw error; + } + + if (!surveyPrisma) { + return null; + } + + return transformPrismaSurvey(surveyPrisma); +}); + +export const getSurveysByActionClassId = reactCache( + async (actionClassId: string, page?: number): Promise => { + validateInputs([actionClassId, ZId], [page, ZOptionalNumber]); + + let surveysPrisma; + try { + surveysPrisma = await prisma.survey.findMany({ + where: { + triggers: { + some: { + actionClass: { + id: actionClassId, + }, + }, + }, + }, + select: selectSurvey, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error getting surveys by action class id"); + throw new DatabaseError(error.message); + } + + throw error; + } + + const surveys: TSurvey[] = []; + + for (const surveyPrisma of surveysPrisma) { + const transformedSurvey = transformPrismaSurvey(surveyPrisma); + surveys.push(transformedSurvey); + } + + return surveys; + } +); + +export const getSurveys = reactCache( + async (environmentId: string, limit?: number, offset?: number): Promise => { + validateInputs([environmentId, ZId], [limit, ZOptionalNumber], [offset, ZOptionalNumber]); + + try { + const surveysPrisma = await prisma.survey.findMany({ + where: { + environmentId, + }, + select: selectSurvey, + orderBy: { + updatedAt: "desc", + }, + take: limit, + skip: offset, + }); + + return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey(surveyPrisma)); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error getting surveys"); + throw new DatabaseError(error.message); + } + throw error; + } + } +); + +export const getSurveyCount = reactCache(async (environmentId: string): Promise => { + validateInputs([environmentId, ZId]); + try { + const surveyCount = await prisma.survey.count({ + where: { + environmentId: environmentId, + }, + }); + + return surveyCount; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error getting survey count"); + throw new DatabaseError(error.message); + } + + throw error; + } +}); + +export const updateSurvey = async (updatedSurvey: TSurvey): Promise => { + validateInputs([updatedSurvey, ZSurvey]); + + try { + const surveyId = updatedSurvey.id; + let data: any = {}; + + const actionClasses = await getActionClasses(updatedSurvey.environmentId); + const currentSurvey = await getSurvey(surveyId); + + if (!currentSurvey) { + throw new ResourceNotFoundError("Survey", surveyId); + } + + const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } = + updatedSurvey; + + checkForInvalidImagesInQuestions(questions); + + if (languages) { + // Process languages update logic here + // Extract currentLanguageIds and updatedLanguageIds + const currentLanguageIds = currentSurvey.languages + ? currentSurvey.languages.map((l) => l.language.id) + : []; + const updatedLanguageIds = + languages.length > 1 ? updatedSurvey.languages.map((l) => l.language.id) : []; + const enabledLanguageIds = languages.map((language) => { + if (language.enabled) return language.language.id; + }); + + // Determine languages to add and remove + const languagesToAdd = updatedLanguageIds.filter((id) => !currentLanguageIds.includes(id)); + const languagesToRemove = currentLanguageIds.filter((id) => !updatedLanguageIds.includes(id)); + + const defaultLanguageId = updatedSurvey.languages.find((l) => l.default)?.language.id; + + // Prepare data for Prisma update + data.languages = {}; + + // Update existing languages for default value changes + data.languages.updateMany = currentSurvey.languages.map((surveyLanguage) => ({ + where: { languageId: surveyLanguage.language.id }, + data: { + default: surveyLanguage.language.id === defaultLanguageId, + enabled: enabledLanguageIds.includes(surveyLanguage.language.id), + }, + })); + + // Add new languages + if (languagesToAdd.length > 0) { + data.languages.create = languagesToAdd.map((languageId) => ({ + languageId: languageId, + default: languageId === defaultLanguageId, + enabled: enabledLanguageIds.includes(languageId), + })); + } + + // Remove languages no longer associated with the survey + if (languagesToRemove.length > 0) { + data.languages.deleteMany = languagesToRemove.map((languageId) => ({ + languageId: languageId, + enabled: enabledLanguageIds.includes(languageId), + })); + } + } + + if (triggers) { + data.triggers = handleTriggerUpdates(triggers, currentSurvey.triggers, actionClasses); + } + + // if the survey body has type other than "app" but has a private segment, we delete that segment, and if it has a public segment, we disconnect from to the survey + if (segment) { + if (type === "app") { + // parse the segment filters: + const parsedFilters = ZSegmentFilters.safeParse(segment.filters); + if (!parsedFilters.success) { + throw new InvalidInputError("Invalid user segment filters"); + } + + try { + // update the segment: + let updatedInput: Prisma.SegmentUpdateInput = { + ...segment, + surveys: undefined, + }; + + if (segment.surveys) { + updatedInput = { + ...segment, + surveys: { + connect: segment.surveys.map((surveyId) => ({ id: surveyId })), + }, + }; + } + + await prisma.segment.update({ + where: { id: segment.id }, + data: updatedInput, + select: { + surveys: { select: { id: true } }, + environmentId: true, + id: true, + }, + }); + } catch (error) { + logger.error(error, "Error updating survey"); + throw new Error("Error updating survey"); + } + } else { + if (segment.isPrivate) { + // disconnect the private segment first and then delete: + await prisma.segment.update({ + where: { id: segment.id }, + data: { + surveys: { + disconnect: { + id: surveyId, + }, + }, + }, + }); + + // delete the private segment: + await prisma.segment.delete({ + where: { + id: segment.id, + }, + }); + } else { + await prisma.survey.update({ + where: { + id: surveyId, + }, + data: { + segment: { + disconnect: true, + }, + }, + }); + } + } + } else if (type === "app") { + if (!currentSurvey.segment) { + await prisma.survey.update({ + where: { + id: surveyId, + }, + data: { + segment: { + connectOrCreate: { + where: { + environmentId_title: { + environmentId, + title: surveyId, + }, + }, + create: { + title: surveyId, + isPrivate: true, + filters: [], + environment: { + connect: { + id: environmentId, + }, + }, + }, + }, + }, + }, + }); + } + } + + if (followUps) { + // Separate follow-ups into categories based on deletion flag + const deletedFollowUps = followUps.filter((followUp) => followUp.deleted); + const nonDeletedFollowUps = followUps.filter((followUp) => !followUp.deleted); + + // Get set of existing follow-up IDs from currentSurvey + const existingFollowUpIds = new Set(currentSurvey.followUps.map((f) => f.id)); + + // Separate non-deleted follow-ups into new and existing + const existingFollowUps = nonDeletedFollowUps.filter((followUp) => + existingFollowUpIds.has(followUp.id) + ); + const newFollowUps = nonDeletedFollowUps.filter((followUp) => !existingFollowUpIds.has(followUp.id)); + + data.followUps = { + // Update existing follow-ups + updateMany: existingFollowUps.map((followUp) => ({ + where: { + id: followUp.id, + }, + data: { + name: followUp.name, + trigger: followUp.trigger, + action: followUp.action, + }, + })), + // Create new follow-ups + createMany: + newFollowUps.length > 0 + ? { + data: newFollowUps.map((followUp) => ({ + name: followUp.name, + trigger: followUp.trigger, + action: followUp.action, + })), + } + : undefined, + // Delete follow-ups marked as deleted, regardless of whether they exist in DB + deleteMany: + deletedFollowUps.length > 0 + ? deletedFollowUps.map((followUp) => ({ + id: followUp.id, + })) + : undefined, + }; + } + + data.questions = questions.map((question) => { + const { isDraft, ...rest } = question; + return rest; + }); + + const organization = await getOrganizationByEnvironmentId(environmentId); + if (!organization) { + throw new ResourceNotFoundError("Organization", null); + } + + surveyData.updatedAt = new Date(); + + data = { + ...surveyData, + ...data, + type, + }; + + // Remove scheduled status when runOnDate is not set + if (data.status === "scheduled" && data.runOnDate === null) { + data.status = "inProgress"; + } + // Set scheduled status when runOnDate is set and in the future on completed surveys + if ( + (data.status === "completed" || data.status === "paused" || data.status === "inProgress") && + data.runOnDate && + data.runOnDate > new Date() + ) { + data.status = "scheduled"; + } + + delete data.createdBy; + const prismaSurvey = await prisma.survey.update({ + where: { id: surveyId }, + data, + select: selectSurvey, + }); + + let surveySegment: TSegment | null = null; + if (prismaSurvey.segment) { + surveySegment = { + ...prismaSurvey.segment, + surveys: prismaSurvey.segment.surveys.map((survey) => survey.id), + }; + } + + // TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration + // @ts-expect-error + const modifiedSurvey: TSurvey = { + ...prismaSurvey, // Properties from prismaSurvey + displayPercentage: Number(prismaSurvey.displayPercentage) || null, + segment: surveySegment, + }; + + return modifiedSurvey; + } catch (error) { + logger.error(error, "Error updating survey"); + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const createSurvey = async ( + environmentId: string, + surveyBody: TSurveyCreateInput +): Promise => { + const [parsedEnvironmentId, parsedSurveyBody] = validateInputs( + [environmentId, ZId], + [surveyBody, ZSurveyCreateInput] + ); + + try { + const { createdBy, ...restSurveyBody } = parsedSurveyBody; + + // empty languages array + if (!restSurveyBody.languages?.length) { + delete restSurveyBody.languages; + } + + const actionClasses = await getActionClasses(parsedEnvironmentId); + + // @ts-expect-error + let data: Omit = { + ...restSurveyBody, + // TODO: Create with attributeFilters + triggers: restSurveyBody.triggers + ? handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses) + : undefined, + attributeFilters: undefined, + }; + + if (createdBy) { + data.creator = { + connect: { + id: createdBy, + }, + }; + } + + const organization = await getOrganizationByEnvironmentId(parsedEnvironmentId); + if (!organization) { + throw new ResourceNotFoundError("Organization", null); + } + + // Survey follow-ups + if (restSurveyBody.followUps?.length) { + data.followUps = { + create: restSurveyBody.followUps.map((followUp) => ({ + name: followUp.name, + trigger: followUp.trigger, + action: followUp.action, + })), + }; + } else { + delete data.followUps; + } + + if (data.questions) { + checkForInvalidImagesInQuestions(data.questions); + } + + const survey = await prisma.survey.create({ + data: { + ...data, + environment: { + connect: { + id: parsedEnvironmentId, + }, + }, + }, + select: selectSurvey, + }); + + // if the survey created is an "app" survey, we also create a private segment for it. + if (survey.type === "app") { + // const newSegment = await createSegment({ + // environmentId: parsedEnvironmentId, + // surveyId: survey.id, + // filters: [], + // title: survey.id, + // isPrivate: true, + // }); + + const newSegment = await prisma.segment.create({ + data: { + title: survey.id, + filters: [], + isPrivate: true, + environment: { + connect: { + id: parsedEnvironmentId, + }, + }, + }, + }); + + await prisma.survey.update({ + where: { + id: survey.id, + }, + data: { + segment: { + connect: { + id: newSegment.id, + }, + }, + }, + }); + } + + // TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration + // @ts-expect-error + const transformedSurvey: TSurvey = { + ...survey, + ...(survey.segment && { + segment: { + ...survey.segment, + surveys: survey.segment.surveys.map((survey) => survey.id), + }, + }), + }; + + if (createdBy) { + await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id); + } + + await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", { + surveyId: survey.id, + surveyType: survey.type, + }); + + return transformedSurvey; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + logger.error(error, "Error creating survey"); + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: string): Promise => { + validateInputs([surveyId, ZId], [newSegmentId, ZId]); + try { + const currentSurvey = await getSurvey(surveyId); + if (!currentSurvey) { + throw new ResourceNotFoundError("survey", surveyId); + } + + const currentSurveySegment = currentSurvey.segment; + + const newSegment = await prisma.segment.findUnique({ + where: { + id: newSegmentId, + }, + }); + + if (!newSegment) { + throw new ResourceNotFoundError("segment", newSegmentId); + } + + const prismaSurvey = await prisma.survey.update({ + where: { + id: surveyId, + }, + select: selectSurvey, + data: { + segment: { + connect: { + id: newSegmentId, + }, + }, + }, + }); + + if ( + currentSurveySegment && + currentSurveySegment.isPrivate && + currentSurveySegment.title === currentSurvey.id + ) { + await prisma.segment.delete({ + where: { + id: currentSurveySegment.id, + }, + select: { + environmentId: true, + surveys: { + select: { + id: true, + }, + }, + }, + }); + } + + let surveySegment: TSegment | null = null; + if (prismaSurvey.segment) { + surveySegment = { + ...prismaSurvey.segment, + surveys: prismaSurvey.segment.surveys.map((survey) => survey.id), + }; + } + + // TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration + // @ts-expect-error + const modifiedSurvey: TSurvey = { + ...prismaSurvey, // Properties from prismaSurvey + segment: surveySegment, + }; + + return modifiedSurvey; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const getSurveysBySegmentId = reactCache(async (segmentId: string): Promise => { + try { + const surveysPrisma = await prisma.survey.findMany({ + where: { segmentId }, + select: selectSurvey, + }); + + const surveys: TSurvey[] = []; + + for (const surveyPrisma of surveysPrisma) { + const transformedSurvey = transformPrismaSurvey(surveyPrisma); + surveys.push(transformedSurvey); + } + + return surveys; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}); diff --git a/apps/web/lib/survey/utils.test.ts b/apps/web/lib/survey/utils.test.ts new file mode 100644 index 000000000000..18dee96bce23 --- /dev/null +++ b/apps/web/lib/survey/utils.test.ts @@ -0,0 +1,254 @@ +import * as fileValidation from "@/lib/fileValidation"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { InvalidInputError } from "@formbricks/types/errors"; +import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; +import { TSegment } from "@formbricks/types/segment"; +import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { anySurveyHasFilters, checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils"; + +describe("transformPrismaSurvey", () => { + test("transforms prisma survey without segment", () => { + const surveyPrisma = { + id: "survey1", + name: "Test Survey", + displayPercentage: "30", + segment: null, + }; + + const result = transformPrismaSurvey(surveyPrisma); + + expect(result).toEqual({ + id: "survey1", + name: "Test Survey", + displayPercentage: 30, + segment: null, + }); + }); + + test("transforms prisma survey with segment", () => { + const surveyPrisma = { + id: "survey1", + name: "Test Survey", + displayPercentage: "50", + segment: { + id: "segment1", + name: "Test Segment", + filters: [{ id: "filter1", type: "user" }], + surveys: [{ id: "survey1" }, { id: "survey2" }], + }, + }; + + const result = transformPrismaSurvey(surveyPrisma); + + expect(result).toEqual({ + id: "survey1", + name: "Test Survey", + displayPercentage: 50, + segment: { + id: "segment1", + name: "Test Segment", + filters: [{ id: "filter1", type: "user" }], + surveys: ["survey1", "survey2"], + }, + }); + }); + + test("transforms prisma survey with non-numeric displayPercentage", () => { + const surveyPrisma = { + id: "survey1", + name: "Test Survey", + displayPercentage: "invalid", + }; + + const result = transformPrismaSurvey(surveyPrisma); + + expect(result).toEqual({ + id: "survey1", + name: "Test Survey", + displayPercentage: null, + segment: null, + }); + }); + + test("transforms prisma survey with undefined displayPercentage", () => { + const surveyPrisma = { + id: "survey1", + name: "Test Survey", + }; + + const result = transformPrismaSurvey(surveyPrisma); + + expect(result).toEqual({ + id: "survey1", + name: "Test Survey", + displayPercentage: null, + segment: null, + }); + }); +}); + +describe("anySurveyHasFilters", () => { + test("returns false when no surveys have segments", () => { + const surveys = [ + { id: "survey1", name: "Survey 1" }, + { id: "survey2", name: "Survey 2" }, + ] as TSurvey[]; + + expect(anySurveyHasFilters(surveys)).toBe(false); + }); + + test("returns false when surveys have segments but no filters", () => { + const surveys = [ + { + id: "survey1", + name: "Survey 1", + segment: { + id: "segment1", + title: "Segment 1", + filters: [], + createdAt: new Date(), + description: "Segment description", + environmentId: "env1", + isPrivate: true, + surveys: ["survey1"], + updatedAt: new Date(), + } as TSegment, + }, + { id: "survey2", name: "Survey 2" }, + ] as TSurvey[]; + + expect(anySurveyHasFilters(surveys)).toBe(false); + }); + + test("returns true when at least one survey has segment with filters", () => { + const surveys = [ + { id: "survey1", name: "Survey 1" }, + { + id: "survey2", + name: "Survey 2", + segment: { + id: "segment2", + filters: [ + { + id: "filter1", + connector: null, + resource: { + root: { type: "attribute", contactAttributeKey: "attr-1" }, + id: "attr-filter-1", + qualifier: { operator: "contains" }, + value: "attr", + }, + }, + ], + createdAt: new Date(), + description: "Segment description", + environmentId: "env1", + isPrivate: true, + surveys: ["survey2"], + updatedAt: new Date(), + title: "Segment title", + } as TSegment, + }, + ] as TSurvey[]; + + expect(anySurveyHasFilters(surveys)).toBe(true); + }); +}); + +describe("checkForInvalidImagesInQuestions", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("does not throw error when no images are present", () => { + const questions = [ + { id: "q1", type: TSurveyQuestionTypeEnum.OpenText }, + { id: "q2", type: TSurveyQuestionTypeEnum.MultipleChoiceSingle }, + ] as TSurveyQuestion[]; + + expect(() => checkForInvalidImagesInQuestions(questions)).not.toThrow(); + }); + + test("does not throw error when all images are valid", () => { + vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true); + + const questions = [ + { id: "q1", type: TSurveyQuestionTypeEnum.OpenText, imageUrl: "valid-image.jpg" }, + { id: "q2", type: TSurveyQuestionTypeEnum.MultipleChoiceSingle }, + ] as TSurveyQuestion[]; + + expect(() => checkForInvalidImagesInQuestions(questions)).not.toThrow(); + expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("valid-image.jpg"); + }); + + test("throws error when question image is invalid", () => { + vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(false); + + const questions = [ + { id: "q1", type: TSurveyQuestionTypeEnum.OpenText, imageUrl: "invalid-image.txt" }, + ] as TSurveyQuestion[]; + + expect(() => checkForInvalidImagesInQuestions(questions)).toThrow( + new InvalidInputError("Invalid image file in question 1") + ); + expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("invalid-image.txt"); + }); + + test("throws error when picture selection question has no choices", () => { + const questions = [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.PictureSelection, + }, + ] as TSurveyQuestion[]; + + expect(() => checkForInvalidImagesInQuestions(questions)).toThrow( + new InvalidInputError("Choices missing for question 1") + ); + }); + + test("throws error when picture selection choice has invalid image", () => { + vi.spyOn(fileValidation, "isValidImageFile").mockImplementation((url) => url === "valid-image.jpg"); + + const questions = [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.PictureSelection, + choices: [ + { id: "c1", imageUrl: "valid-image.jpg" }, + { id: "c2", imageUrl: "invalid-image.txt" }, + ], + }, + ] as TSurveyQuestion[]; + + expect(() => checkForInvalidImagesInQuestions(questions)).toThrow( + new InvalidInputError("Invalid image file for choice 2 in question 1") + ); + + expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "valid-image.jpg"); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "invalid-image.txt"); + }); + + test("validates all choices in picture selection questions", () => { + vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true); + + const questions = [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.PictureSelection, + choices: [ + { id: "c1", imageUrl: "image1.jpg" }, + { id: "c2", imageUrl: "image2.jpg" }, + { id: "c3", imageUrl: "image3.jpg" }, + ], + }, + ] as TSurveyQuestion[]; + + expect(() => checkForInvalidImagesInQuestions(questions)).not.toThrow(); + expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "image1.jpg"); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "image2.jpg"); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "image3.jpg"); + }); +}); diff --git a/apps/web/lib/survey/utils.ts b/apps/web/lib/survey/utils.ts new file mode 100644 index 000000000000..d556eaf71b61 --- /dev/null +++ b/apps/web/lib/survey/utils.ts @@ -0,0 +1,58 @@ +import "server-only"; +import { isValidImageFile } from "@/lib/fileValidation"; +import { InvalidInputError } from "@formbricks/types/errors"; +import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; +import { TSegment } from "@formbricks/types/segment"; +import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; + +export const transformPrismaSurvey = ( + surveyPrisma: any +): T => { + let segment: TSegment | null = null; + + if (surveyPrisma.segment) { + segment = { + ...surveyPrisma.segment, + surveys: surveyPrisma.segment.surveys.map((survey) => survey.id), + }; + } + + const transformedSurvey = { + ...surveyPrisma, + displayPercentage: Number(surveyPrisma.displayPercentage) || null, + segment, + } as T; + + return transformedSurvey; +}; + +export const anySurveyHasFilters = (surveys: TSurvey[]): boolean => { + return surveys.some((survey) => { + if ("segment" in survey && survey.segment) { + return survey.segment.filters && survey.segment.filters.length > 0; + } + return false; + }); +}; + +export const checkForInvalidImagesInQuestions = (questions: TSurveyQuestion[]) => { + questions.forEach((question, qIndex) => { + if (question.imageUrl && !isValidImageFile(question.imageUrl)) { + throw new InvalidInputError(`Invalid image file in question ${String(qIndex + 1)}`); + } + + if (question.type === TSurveyQuestionTypeEnum.PictureSelection) { + if (!Array.isArray(question.choices)) { + throw new InvalidInputError(`Choices missing for question ${String(qIndex + 1)}`); + } + + question.choices.forEach((choice, cIndex) => { + if (!isValidImageFile(choice.imageUrl)) { + throw new InvalidInputError( + `Invalid image file for choice ${String(cIndex + 1)} in question ${String(qIndex + 1)}` + ); + } + }); + } + }); +}; diff --git a/apps/web/lib/surveyLogic/utils.test.ts b/apps/web/lib/surveyLogic/utils.test.ts new file mode 100644 index 000000000000..745695aea400 --- /dev/null +++ b/apps/web/lib/surveyLogic/utils.test.ts @@ -0,0 +1,1169 @@ +import { describe, expect, test, vi } from "vitest"; +import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; +import { TResponseData, TResponseVariables } from "@formbricks/types/responses"; +import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { + TConditionGroup, + TSingleCondition, + TSurveyLogic, + TSurveyLogicAction, +} from "@formbricks/types/surveys/types"; +import { + addConditionBelow, + createGroupFromResource, + deleteEmptyGroups, + duplicateCondition, + duplicateLogicItem, + evaluateLogic, + getUpdatedActionBody, + performActions, + removeCondition, + toggleGroupConnector, + updateCondition, +} from "./utils"; + +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: (label: { default: string }) => label.default, +})); +vi.mock("@paralleldrive/cuid2", () => ({ + createId: () => "fixed-id", +})); + +describe("surveyLogic", () => { + const mockSurvey: TJsEnvironmentStateSurvey = { + id: "cm9gptbhg0000192zceq9ayuc", + name: "Start from scratch‌‌‍‍‌‍‍‌‌‌‌‍‍‍‌‌‌‌‌‌‌‌‍‌‍‌‌", + type: "link", + status: "inProgress", + welcomeCard: { + html: { + default: "Thanks for providing your feedback - let's go!‌‌‍‍‌‍‍‍‌‌‌‍‍‌‌‌‍‌‌‌‌‌‍‌‍‌‌", + }, + enabled: false, + headline: { + default: "Welcome!‌‌‍‍‌‍‍‍‌‌‌‍‍‌‌‌‌‌‌‌‌‌‍‌‍‌‌", + }, + buttonLabel: { + default: "Next‌‌‍‍‌‍‍‍‌‌‌‍‍‌‌‍‌‌‌‌‌‌‍‌‍‌‌", + }, + timeToFinish: false, + showResponseCount: false, + }, + questions: [ + { + id: "vjniuob08ggl8dewl0hwed41", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { + default: "What would you like to know?‌‌‍‍‌‍‍‍‌‌‌‍‍‌‍‍‌‌‌‌‌‌‍‌‍‌‌", + }, + required: true, + charLimit: {}, + inputType: "email", + longAnswer: false, + buttonLabel: { + default: "Next‌‌‍‍‌‍‍‍‌‌‌‍‍‍‌‌‌‌‌‌‌‌‍‌‍‌‌", + }, + placeholder: { + default: "example@email.com", + }, + }, + ], + endings: [ + { + id: "gt1yoaeb5a3istszxqbl08mk", + type: "endScreen", + headline: { + default: "Thank you!‌‌‍‍‌‍‍‍‌‌‌‍‍‌‌‍‍‌‌‌‌‌‍‌‍‌‌", + }, + subheader: { + default: "We appreciate your feedback.‌‌‍‍‌‍‍‍‌‌‌‍‍‌‍‌‌‌‌‌‌‌‍‌‍‌‌", + }, + buttonLink: "https://formbricks.com", + buttonLabel: { + default: "Create your own Survey‌‌‍‍‌‍‍‍‌‌‌‍‍‌‍‌‍‌‌‌‌‌‍‌‍‌‌", + }, + }, + ], + hiddenFields: { + enabled: true, + fieldIds: [], + }, + variables: [ + { + id: "v", + name: "num", + type: "number", + value: 0, + }, + ], + displayOption: "displayOnce", + recontactDays: null, + displayLimit: null, + autoClose: null, + delay: 0, + displayPercentage: null, + isBackButtonHidden: false, + projectOverwrites: null, + styling: null, + showLanguageSwitch: null, + languages: [], + triggers: [], + segment: null, + }; + + const simpleGroup = (): TConditionGroup => ({ + id: "g1", + connector: "and", + conditions: [ + { + id: "c1", + leftOperand: { type: "hiddenField", value: "f1" }, + operator: "equals", + rightOperand: { type: "static", value: "v1" }, + }, + { + id: "c2", + leftOperand: { type: "hiddenField", value: "f2" }, + operator: "equals", + rightOperand: { type: "static", value: "v2" }, + }, + ], + }); + + test("duplicateLogicItem duplicates IDs recursively", () => { + const logic: TSurveyLogic = { + id: "L1", + conditions: simpleGroup(), + actions: [{ id: "A1", objective: "requireAnswer", target: "q1" }], + }; + const dup = duplicateLogicItem(logic); + expect(dup.id).toBe("fixed-id"); + expect(dup.conditions.id).toBe("fixed-id"); + expect(dup.actions[0].id).toBe("fixed-id"); + }); + + test("addConditionBelow inserts after matched id", () => { + const group = simpleGroup(); + const newCond: TSingleCondition = { + id: "new", + leftOperand: { type: "hiddenField", value: "x" }, + operator: "equals", + rightOperand: { type: "static", value: "y" }, + }; + addConditionBelow(group, "c1", newCond); + expect(group.conditions[1]).toEqual(newCond); + }); + + test("toggleGroupConnector flips connector", () => { + const g = simpleGroup(); + toggleGroupConnector(g, "g1"); + expect(g.connector).toBe("or"); + toggleGroupConnector(g, "g1"); + expect(g.connector).toBe("and"); + }); + + test("removeCondition deletes the condition and cleans empty groups", () => { + const group: TConditionGroup = { + id: "root", + connector: "and", + conditions: [ + { + id: "c", + leftOperand: { type: "hiddenField", value: "f" }, + operator: "equals", + rightOperand: { type: "static", value: "" }, + }, + ], + }; + removeCondition(group, "c"); + expect(group.conditions).toHaveLength(0); + }); + + test("duplicateCondition clones a condition in place", () => { + const group = simpleGroup(); + duplicateCondition(group, "c1"); + expect(group.conditions[1].id).toBe("fixed-id"); + }); + + test("deleteEmptyGroups removes nested empty groups", () => { + const nested: TConditionGroup = { id: "n", connector: "and", conditions: [] }; + const root: TConditionGroup = { id: "r", connector: "and", conditions: [nested] }; + deleteEmptyGroups(root); + expect(root.conditions).toHaveLength(0); + }); + + test("createGroupFromResource wraps item in new group", () => { + const group = simpleGroup(); + createGroupFromResource(group, "c1"); + const g = group.conditions[0] as TConditionGroup; + expect(g.conditions[0].id).toBe("c1"); + expect(g.connector).toBe("and"); + }); + + test("updateCondition merges in partial changes", () => { + const group = simpleGroup(); + updateCondition(group, "c1", { operator: "contains", rightOperand: { type: "static", value: "z" } }); + const updated = group.conditions.find((c) => c.id === "c1") as TSingleCondition; + expect(updated?.operator).toBe("contains"); + expect(updated?.rightOperand?.value).toBe("z"); + }); + + test("getUpdatedActionBody returns new action bodies correctly", () => { + const base: TSurveyLogicAction = { id: "A", objective: "requireAnswer", target: "q" }; + const calc = getUpdatedActionBody(base, "calculate"); + expect(calc.objective).toBe("calculate"); + const req = getUpdatedActionBody(calc, "requireAnswer"); + expect(req.objective).toBe("requireAnswer"); + const jump = getUpdatedActionBody(req, "jumpToQuestion"); + expect(jump.objective).toBe("jumpToQuestion"); + }); + + test("evaluateLogic handles AND/OR groups and single conditions", () => { + const data: TResponseData = { f1: "v1", f2: "x" }; + const vars: TResponseVariables = {}; + const group: TConditionGroup = { + id: "g", + connector: "and", + conditions: [ + { + id: "c1", + leftOperand: { type: "hiddenField", value: "f1" }, + operator: "equals", + rightOperand: { type: "static", value: "v1" }, + }, + { + id: "c2", + leftOperand: { type: "hiddenField", value: "f2" }, + operator: "equals", + rightOperand: { type: "static", value: "v2" }, + }, + ], + }; + expect(evaluateLogic(mockSurvey, data, vars, group, "en")).toBe(false); + group.connector = "or"; + expect(evaluateLogic(mockSurvey, data, vars, group, "en")).toBe(true); + }); + + test("performActions calculates, requires, and jumps correctly", () => { + const data: TResponseData = { q: "5" }; + const initialVars: TResponseVariables = {}; + const actions: TSurveyLogicAction[] = [ + { + id: "a1", + objective: "calculate", + variableId: "v", + operator: "add", + value: { type: "static", value: 3 }, + }, + { id: "a2", objective: "requireAnswer", target: "q2" }, + { id: "a3", objective: "jumpToQuestion", target: "q3" }, + ]; + const result = performActions(mockSurvey, actions, data, initialVars); + expect(result.calculations.v).toBe(3); + expect(result.requiredQuestionIds).toContain("q2"); + expect(result.jumpTarget).toBe("q3"); + }); + + test("evaluateLogic handles all operators and error cases", () => { + const baseCond = (operator: string, right: any = undefined) => ({ + id: "c", + leftOperand: { type: "hiddenField", value: "f" }, + operator, + ...(right !== undefined ? { rightOperand: { type: "static", value: right } } : {}), + }); + const vars: TResponseVariables = {}; + const group = (cond: any) => ({ id: "g", connector: "and" as const, conditions: [cond] }); + expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("equals", "foo")), "en")).toBe(true); + expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("doesNotEqual", "bar")), "en")).toBe( + true + ); + expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("contains", "o")), "en")).toBe(true); + expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("doesNotContain", "z")), "en")).toBe( + true + ); + expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("startsWith", "f")), "en")).toBe( + true + ); + expect( + evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("doesNotStartWith", "z")), "en") + ).toBe(true); + expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("endsWith", "o")), "en")).toBe(true); + expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("doesNotEndWith", "z")), "en")).toBe( + true + ); + expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("isSubmitted")), "en")).toBe(true); + expect(evaluateLogic(mockSurvey, { f: "" }, vars, group(baseCond("isSkipped")), "en")).toBe(true); + expect( + evaluateLogic( + mockSurvey, + { fnum: 5 }, + vars, + group({ ...baseCond("isGreaterThan", 2), leftOperand: { type: "hiddenField", value: "fnum" } }), + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + mockSurvey, + { fnum: 1 }, + vars, + group({ ...baseCond("isLessThan", 2), leftOperand: { type: "hiddenField", value: "fnum" } }), + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + mockSurvey, + { fnum: 2 }, + vars, + group({ + ...baseCond("isGreaterThanOrEqual", 2), + leftOperand: { type: "hiddenField", value: "fnum" }, + }), + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + mockSurvey, + { fnum: 2 }, + vars, + group({ ...baseCond("isLessThanOrEqual", 2), leftOperand: { type: "hiddenField", value: "fnum" } }), + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + mockSurvey, + { f: "foo" }, + vars, + group({ ...baseCond("equalsOneOf", ["foo", "bar"]) }), + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + mockSurvey, + { farr: ["foo", "bar"] }, + vars, + group({ ...baseCond("includesAllOf", ["foo"]), leftOperand: { type: "hiddenField", value: "farr" } }), + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + mockSurvey, + { farr: ["foo", "bar"] }, + vars, + group({ ...baseCond("includesOneOf", ["foo"]), leftOperand: { type: "hiddenField", value: "farr" } }), + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + mockSurvey, + { farr: ["foo", "bar"] }, + vars, + group({ + ...baseCond("doesNotIncludeAllOf", ["baz"]), + leftOperand: { type: "hiddenField", value: "farr" }, + }), + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + mockSurvey, + { farr: ["foo", "bar"] }, + vars, + group({ + ...baseCond("doesNotIncludeOneOf", ["baz"]), + leftOperand: { type: "hiddenField", value: "farr" }, + }), + "en" + ) + ).toBe(true); + expect(evaluateLogic(mockSurvey, { f: "accepted" }, vars, group(baseCond("isAccepted")), "en")).toBe( + true + ); + expect(evaluateLogic(mockSurvey, { f: "clicked" }, vars, group(baseCond("isClicked")), "en")).toBe(true); + expect( + evaluateLogic( + mockSurvey, + { f: "2024-01-02" }, + vars, + group({ ...baseCond("isAfter", "2024-01-01") }), + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + mockSurvey, + { f: "2024-01-01" }, + vars, + group({ ...baseCond("isBefore", "2024-01-02") }), + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + mockSurvey, + { fbooked: "booked" }, + vars, + group({ ...baseCond("isBooked"), leftOperand: { type: "hiddenField", value: "fbooked" } }), + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + mockSurvey, + { fobj: { a: "", b: "x" } }, + vars, + group({ ...baseCond("isPartiallySubmitted"), leftOperand: { type: "hiddenField", value: "fobj" } }), + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + mockSurvey, + { fobj: { a: "y", b: "x" } }, + vars, + group({ ...baseCond("isCompletelySubmitted"), leftOperand: { type: "hiddenField", value: "fobj" } }), + "en" + ) + ).toBe(true); + expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("isSet")), "en")).toBe(true); + expect(evaluateLogic(mockSurvey, { f: "" }, vars, group(baseCond("isEmpty")), "en")).toBe(true); + expect( + evaluateLogic(mockSurvey, { f: "foo" }, vars, group({ ...baseCond("isAnyOf", ["foo", "bar"]) }), "en") + ).toBe(true); + // default/fallback + expect( + evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("notARealOperator", "bar")), "en") + ).toBe(false); + // error handling + expect( + evaluateLogic( + mockSurvey, + {}, + vars, + group({ ...baseCond("equals", "foo"), leftOperand: { type: "question", value: "notfound" } }), + "en" + ) + ).toBe(false); + }); + + test("performActions handles divide by zero, assign, concat, and missing variable", () => { + const survey: TJsEnvironmentStateSurvey = { + ...mockSurvey, + variables: [{ id: "v", name: "num", type: "number", value: 0 }], + }; + const data: TResponseData = { q: 2 }; + const actions: TSurveyLogicAction[] = [ + { + id: "a1", + objective: "calculate", + variableId: "v", + operator: "divide", + value: { type: "static", value: 0 }, + }, + { + id: "a2", + objective: "calculate", + variableId: "v", + operator: "assign", + value: { type: "static", value: 42 }, + }, + { + id: "a3", + objective: "calculate", + variableId: "v", + operator: "concat", + value: { type: "static", value: "bar" }, + }, + { + id: "a4", + objective: "calculate", + variableId: "notfound", + operator: "add", + value: { type: "static", value: 1 }, + }, + ]; + const result = performActions(survey, actions, data, {}); + expect(result.calculations.v).toBe("42bar"); + expect(result.calculations.notfound).toBeUndefined(); + }); + + test("getUpdatedActionBody returns same action if objective matches", () => { + const base: TSurveyLogicAction = { id: "A", objective: "requireAnswer", target: "q" }; + expect(getUpdatedActionBody(base, "requireAnswer")).toBe(base); + }); + + test("group/condition manipulation functions handle missing resourceId", () => { + const group = simpleGroup(); + addConditionBelow(group, "notfound", { + id: "x", + leftOperand: { type: "hiddenField", value: "a" }, + operator: "equals", + rightOperand: { type: "static", value: "b" }, + }); + expect(group.conditions.length).toBe(2); + toggleGroupConnector(group, "notfound"); + expect(group.connector).toBe("and"); + removeCondition(group, "notfound"); + expect(group.conditions.length).toBe(2); + duplicateCondition(group, "notfound"); + expect(group.conditions.length).toBe(2); + createGroupFromResource(group, "notfound"); + expect(group.conditions.length).toBe(2); + updateCondition(group, "notfound", { operator: "equals" }); + expect(group.conditions.length).toBe(2); + }); + + // Additional tests for complete coverage + + test("addConditionBelow with nested group correctly adds condition", () => { + const nestedGroup: TConditionGroup = { + id: "nestedGroup", + connector: "and", + conditions: [ + { + id: "nestedC1", + leftOperand: { type: "hiddenField", value: "nf1" }, + operator: "equals", + rightOperand: { type: "static", value: "nv1" }, + }, + ], + }; + + const group: TConditionGroup = { + id: "parentGroup", + connector: "and", + conditions: [nestedGroup], + }; + + const newCond: TSingleCondition = { + id: "new", + leftOperand: { type: "hiddenField", value: "x" }, + operator: "equals", + rightOperand: { type: "static", value: "y" }, + }; + + addConditionBelow(group, "nestedGroup", newCond); + expect(group.conditions[1]).toEqual(newCond); + + addConditionBelow(group, "nestedC1", newCond); + expect((group.conditions[0] as TConditionGroup).conditions[1]).toEqual(newCond); + }); + + test("getLeftOperandValue handles different question types", () => { + const surveyWithQuestions: TJsEnvironmentStateSurvey = { + ...mockSurvey, + questions: [ + ...mockSurvey.questions, + { + id: "numQuestion", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Number question" }, + required: true, + inputType: "number", + charLimit: { enabled: false }, + }, + { + id: "mcSingle", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + headline: { default: "MC Single" }, + required: true, + choices: [ + { id: "choice1", label: { default: "Choice 1" } }, + { id: "choice2", label: { default: "Choice 2" } }, + { id: "other", label: { default: "Other" } }, + ], + buttonLabel: { default: "Next" }, + }, + { + id: "mcMulti", + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + headline: { default: "MC Multi" }, + required: true, + choices: [ + { id: "choice1", label: { default: "Choice 1" } }, + { id: "choice2", label: { default: "Choice 2" } }, + ], + buttonLabel: { default: "Next" }, + }, + { + id: "matrixQ", + type: TSurveyQuestionTypeEnum.Matrix, + headline: { default: "Matrix Question" }, + required: true, + rows: [{ default: "Row 1" }, { default: "Row 2" }], + columns: [{ default: "Column 1" }, { default: "Column 2" }], + buttonLabel: { default: "Next" }, + shuffleOption: "none", + }, + { + id: "pictureQ", + type: TSurveyQuestionTypeEnum.PictureSelection, + allowMulti: false, + headline: { default: "Picture Selection" }, + required: true, + choices: [ + { id: "pic1", imageUrl: "url1" }, + { id: "pic2", imageUrl: "url2" }, + ], + buttonLabel: { default: "Next" }, + }, + { + id: "dateQ", + type: TSurveyQuestionTypeEnum.Date, + format: "M-d-y", + headline: { default: "Date Question" }, + required: true, + buttonLabel: { default: "Next" }, + }, + { + id: "fileQ", + type: TSurveyQuestionTypeEnum.FileUpload, + allowMultipleFiles: false, + headline: { default: "File Upload" }, + required: true, + buttonLabel: { default: "Next" }, + }, + ], + variables: [ + { id: "numVar", name: "numberVar", type: "number", value: 5 }, + { id: "textVar", name: "textVar", type: "text", value: "hello" }, + ], + }; + + const data: TResponseData = { + numQuestion: 42, + mcSingle: "Choice 1", + mcMulti: ["Choice 1", "Choice 2"], + matrixQ: { "Row 1": "Column 1" }, + pictureQ: ["pic1"], + dateQ: "2024-01-15", + fileQ: "file.pdf", + unknownChoice: "Unknown option", + multiWithUnknown: ["Choice 1", "Unknown option"], + }; + + const vars: TResponseVariables = { + numVar: 10, + textVar: "world", + }; + + // Test number question + const numberCondition: TSingleCondition = { + id: "numCond", + leftOperand: { type: "question", value: "numQuestion" }, + operator: "equals", + rightOperand: { type: "static", value: 42 }, + }; + expect( + evaluateLogic( + surveyWithQuestions, + data, + vars, + { id: "g", connector: "and", conditions: [numberCondition] }, + "en" + ) + ).toBe(true); + + // Test MC single with recognized choice + const mcSingleCondition: TSingleCondition = { + id: "mcCond", + leftOperand: { type: "question", value: "mcSingle" }, + operator: "equals", + rightOperand: { type: "static", value: "choice1" }, + }; + expect( + evaluateLogic( + surveyWithQuestions, + data, + vars, + { id: "g", connector: "and", conditions: [mcSingleCondition] }, + "default" + ) + ).toBe(true); + + // Test MC multi + const mcMultiCondition: TSingleCondition = { + id: "mcMultiCond", + leftOperand: { type: "question", value: "mcMulti" }, + operator: "includesOneOf", + rightOperand: { type: "static", value: ["choice1"] }, + }; + expect( + evaluateLogic( + surveyWithQuestions, + data, + vars, + { id: "g", connector: "and", conditions: [mcMultiCondition] }, + "en" + ) + ).toBe(true); + + // Test matrix question + const matrixCondition: TSingleCondition = { + id: "matrixCond", + leftOperand: { type: "question", value: "matrixQ", meta: { row: "0" } }, + operator: "equals", + rightOperand: { type: "static", value: "0" }, + }; + expect( + evaluateLogic( + surveyWithQuestions, + data, + vars, + { id: "g", connector: "and", conditions: [matrixCondition] }, + "en" + ) + ).toBe(true); + + // Test with variable type + const varCondition: TSingleCondition = { + id: "varCond", + leftOperand: { type: "variable", value: "numVar" }, + operator: "equals", + rightOperand: { type: "static", value: 10 }, + }; + expect( + evaluateLogic( + surveyWithQuestions, + data, + vars, + { id: "g", connector: "and", conditions: [varCondition] }, + "en" + ) + ).toBe(true); + + // Test with missing question + const missingQuestionCondition: TSingleCondition = { + id: "missingCond", + leftOperand: { type: "question", value: "nonExistent" }, + operator: "equals", + rightOperand: { type: "static", value: "foo" }, + }; + expect( + evaluateLogic( + surveyWithQuestions, + data, + vars, + { id: "g", connector: "and", conditions: [missingQuestionCondition] }, + "en" + ) + ).toBe(false); + + // Test with unknown value type in leftOperand + const unknownTypeCondition: TSingleCondition = { + id: "unknownCond", + leftOperand: { type: "unknown" as any, value: "x" }, + operator: "equals", + rightOperand: { type: "static", value: "x" }, + }; + expect( + evaluateLogic( + surveyWithQuestions, + data, + vars, + { id: "g", connector: "and", conditions: [unknownTypeCondition] }, + "en" + ) + ).toBe(false); + + // Test MC single with "other" option + const otherCondition: TSingleCondition = { + id: "otherCond", + leftOperand: { type: "question", value: "mcSingle" }, + operator: "equals", + rightOperand: { type: "static", value: "Unknown option" }, + }; + expect( + evaluateLogic( + surveyWithQuestions, + data, + vars, + { id: "g", connector: "and", conditions: [otherCondition] }, + "en" + ) + ).toBe(false); + + // Test matrix with invalid row index + const invalidMatrixCondition: TSingleCondition = { + id: "invalidMatrixCond", + leftOperand: { type: "question", value: "matrixQ", meta: { row: "999" } }, + operator: "equals", + rightOperand: { type: "static", value: "0" }, + }; + expect( + evaluateLogic( + surveyWithQuestions, + data, + vars, + { id: "g", connector: "and", conditions: [invalidMatrixCondition] }, + "en" + ) + ).toBe(false); + }); + + test("getRightOperandValue handles different data types and sources", () => { + const surveyWithVars: TJsEnvironmentStateSurvey = { + ...mockSurvey, + questions: [ + ...mockSurvey.questions, + { + id: "question1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Question 1" }, + required: true, + inputType: "text", + charLimit: { enabled: false }, + }, + ], + variables: [ + { id: "numVar", name: "numberVar", type: "number", value: 5 }, + { id: "textVar", name: "textVar", type: "text", value: "hello" }, + ], + }; + + const vars: TResponseVariables = { + numVar: 10, + textVar: "world", + }; + + // Test with different rightOperand types + const staticCondition: TSingleCondition = { + id: "staticCond", + leftOperand: { type: "hiddenField", value: "f" }, + operator: "equals", + rightOperand: { type: "static", value: "test" }, + }; + + const questionCondition: TSingleCondition = { + id: "questionCond", + leftOperand: { type: "hiddenField", value: "f" }, + operator: "equals", + rightOperand: { type: "question", value: "question1" }, + }; + + const variableCondition: TSingleCondition = { + id: "varCond", + leftOperand: { type: "hiddenField", value: "f" }, + operator: "equals", + rightOperand: { type: "variable", value: "textVar" }, + }; + + const hiddenFieldCondition: TSingleCondition = { + id: "hiddenFieldCond", + leftOperand: { type: "hiddenField", value: "f" }, + operator: "equals", + rightOperand: { type: "hiddenField", value: "hiddenField1" }, + }; + + const unknownTypeCondition: TSingleCondition = { + id: "unknownCond", + leftOperand: { type: "hiddenField", value: "f" }, + operator: "equals", + rightOperand: { type: "unknown" as any, value: "x" }, + }; + + expect( + evaluateLogic( + surveyWithVars, + { f: "test" }, + vars, + { id: "g", connector: "and", conditions: [staticCondition] }, + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + surveyWithVars, + { f: "response1", question1: "response1" }, + vars, + { id: "g", connector: "and", conditions: [questionCondition] }, + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + surveyWithVars, + { f: "world" }, + vars, + { id: "g", connector: "and", conditions: [variableCondition] }, + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + surveyWithVars, + { f: "hidden1", hiddenField1: "hidden1" }, + vars, + { id: "g", connector: "and", conditions: [hiddenFieldCondition] }, + "en" + ) + ).toBe(true); + expect( + evaluateLogic( + surveyWithVars, + { f: "x" }, + vars, + { id: "g", connector: "and", conditions: [unknownTypeCondition] }, + "en" + ) + ).toBe(false); + }); + + test("performCalculation handles different variable types and operations", () => { + const surveyWithVars: TJsEnvironmentStateSurvey = { + ...mockSurvey, + variables: [ + { id: "numVar", name: "numberVar", type: "number", value: 5 }, + { id: "textVar", name: "textVar", type: "text", value: "hello" }, + ], + }; + + const data: TResponseData = { + questionNum: 20, + questionText: "world", + hiddenNum: 30, + }; + + // Test with variable value from another variable + const varValueAction: TSurveyLogicAction = { + id: "a1", + objective: "calculate", + variableId: "numVar", + operator: "add", + value: { type: "variable", value: "numVar" }, + }; + + // Test with question value + const questionValueAction: TSurveyLogicAction = { + id: "a2", + objective: "calculate", + variableId: "numVar", + operator: "add", + value: { type: "question", value: "questionNum" }, + }; + + // Test with hidden field value + const hiddenFieldValueAction: TSurveyLogicAction = { + id: "a3", + objective: "calculate", + variableId: "numVar", + operator: "add", + value: { type: "hiddenField", value: "hiddenNum" }, + }; + + // Test with text variable for concat + const textVarAction: TSurveyLogicAction = { + id: "a4", + objective: "calculate", + variableId: "textVar", + operator: "concat", + value: { type: "question", value: "questionText" }, + }; + + // Test with missing variable + const missingVarAction: TSurveyLogicAction = { + id: "a5", + objective: "calculate", + variableId: "nonExistentVar", + operator: "add", + value: { type: "static", value: 10 }, + }; + + // Test with invalid value type (null) + const invalidValueAction: TSurveyLogicAction = { + id: "a6", + objective: "calculate", + variableId: "numVar", + operator: "add", + value: { type: "question", value: "nonExistentQuestion" }, + }; + + // Test with other math operations + const multiplyAction: TSurveyLogicAction = { + id: "a7", + objective: "calculate", + variableId: "numVar", + operator: "multiply", + value: { type: "static", value: 2 }, + }; + + const subtractAction: TSurveyLogicAction = { + id: "a8", + objective: "calculate", + variableId: "numVar", + operator: "subtract", + value: { type: "static", value: 3 }, + }; + + let result = performActions(surveyWithVars, [varValueAction], data, { numVar: 5 }); + expect(result.calculations.numVar).toBe(10); // 5 + 5 + + result = performActions(surveyWithVars, [questionValueAction], data, { numVar: 5 }); + expect(result.calculations.numVar).toBe(25); // 5 + 20 + + result = performActions(surveyWithVars, [hiddenFieldValueAction], data, { numVar: 5 }); + expect(result.calculations.numVar).toBe(35); // 5 + 30 + + result = performActions(surveyWithVars, [textVarAction], data, { textVar: "hello" }); + expect(result.calculations.textVar).toBe("helloworld"); + + result = performActions(surveyWithVars, [missingVarAction], data, {}); + expect(result.calculations.nonExistentVar).toBeUndefined(); + + result = performActions(surveyWithVars, [invalidValueAction], data, { numVar: 5 }); + expect(result.calculations.numVar).toBe(5); // Unchanged + + result = performActions(surveyWithVars, [multiplyAction], data, { numVar: 5 }); + expect(result.calculations.numVar).toBe(10); // 5 * 2 + + result = performActions(surveyWithVars, [subtractAction], data, { numVar: 5 }); + expect(result.calculations.numVar).toBe(2); // 5 - 3 + }); + + test("evaluateLogic handles more complex nested condition groups", () => { + const nestedGroup: TConditionGroup = { + id: "nestedGroup", + connector: "or", + conditions: [ + { + id: "c1", + leftOperand: { type: "hiddenField", value: "f1" }, + operator: "equals", + rightOperand: { type: "static", value: "v1" }, + }, + { + id: "c2", + leftOperand: { type: "hiddenField", value: "f2" }, + operator: "equals", + rightOperand: { type: "static", value: "v2" }, + }, + ], + }; + + const deeplyNestedGroup: TConditionGroup = { + id: "deepGroup", + connector: "and", + conditions: [ + { + id: "d1", + leftOperand: { type: "hiddenField", value: "f3" }, + operator: "equals", + rightOperand: { type: "static", value: "v3" }, + }, + nestedGroup, + ], + }; + + const rootGroup: TConditionGroup = { + id: "rootGroup", + connector: "and", + conditions: [ + { + id: "r1", + leftOperand: { type: "hiddenField", value: "f4" }, + operator: "equals", + rightOperand: { type: "static", value: "v4" }, + }, + deeplyNestedGroup, + ], + }; + + // All conditions met + expect(evaluateLogic(mockSurvey, { f1: "v1", f2: "v2", f3: "v3", f4: "v4" }, {}, rootGroup, "en")).toBe( + true + ); + + // One condition in OR fails but group still passes + expect( + evaluateLogic(mockSurvey, { f1: "v1", f2: "wrong", f3: "v3", f4: "v4" }, {}, rootGroup, "en") + ).toBe(true); + + // Both conditions in OR fail, causing AND to fail + expect( + evaluateLogic(mockSurvey, { f1: "wrong", f2: "wrong", f3: "v3", f4: "v4" }, {}, rootGroup, "en") + ).toBe(false); + + // Top level condition fails + expect( + evaluateLogic(mockSurvey, { f1: "v1", f2: "v2", f3: "v3", f4: "wrong" }, {}, rootGroup, "en") + ).toBe(false); + }); + + test("missing connector in group defaults to 'and'", () => { + const group: TConditionGroup = { + id: "g1", + conditions: [ + { + id: "c1", + leftOperand: { type: "hiddenField", value: "f1" }, + operator: "equals", + rightOperand: { type: "static", value: "v1" }, + }, + { + id: "c2", + leftOperand: { type: "hiddenField", value: "f2" }, + operator: "equals", + rightOperand: { type: "static", value: "v2" }, + }, + ], + } as any; // Intentionally missing connector + + createGroupFromResource(group, "c1"); + expect(group.connector).toBe("and"); + }); + + test("getLeftOperandValue handles number input type with non-number value", () => { + const surveyWithNumberInput: TJsEnvironmentStateSurvey = { + ...mockSurvey, + questions: [ + { + id: "numQuestion", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Number question" }, + required: true, + inputType: "number", + placeholder: { default: "Enter a number" }, + buttonLabel: { default: "Next" }, + longAnswer: false, + charLimit: {}, + }, + ], + }; + + const condition: TSingleCondition = { + id: "numCond", + leftOperand: { type: "question", value: "numQuestion" }, + operator: "equals", + rightOperand: { type: "static", value: 0 }, + }; + + // Test with non-numeric string + expect( + evaluateLogic( + surveyWithNumberInput, + { numQuestion: "not-a-number" }, + {}, + { id: "g", connector: "and", conditions: [condition] }, + "en" + ) + ).toBe(false); + + // Test with empty string + expect( + evaluateLogic( + surveyWithNumberInput, + { numQuestion: "" }, + {}, + { id: "g", connector: "and", conditions: [condition] }, + "en" + ) + ).toBe(false); + }); +}); diff --git a/apps/web/lib/surveyLogic/utils.ts b/apps/web/lib/surveyLogic/utils.ts new file mode 100644 index 000000000000..ca900c4ac063 --- /dev/null +++ b/apps/web/lib/surveyLogic/utils.ts @@ -0,0 +1,706 @@ +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { createId } from "@paralleldrive/cuid2"; +import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; +import { TResponseData, TResponseVariables } from "@formbricks/types/responses"; +import { + TActionCalculate, + TActionObjective, + TConditionGroup, + TSingleCondition, + TSurveyLogic, + TSurveyLogicAction, + TSurveyQuestion, + TSurveyQuestionTypeEnum, + TSurveyVariable, +} from "@formbricks/types/surveys/types"; + +type TCondition = TSingleCondition | TConditionGroup; + +export const isConditionGroup = (condition: TCondition): condition is TConditionGroup => { + return (condition as TConditionGroup).connector !== undefined; +}; + +export const duplicateLogicItem = (logicItem: TSurveyLogic): TSurveyLogic => { + const duplicateConditionGroup = (group: TConditionGroup): TConditionGroup => { + return { + ...group, + id: createId(), + conditions: group.conditions.map((condition) => { + if (isConditionGroup(condition)) { + return duplicateConditionGroup(condition); + } else { + return duplicateCondition(condition); + } + }), + }; + }; + + const duplicateCondition = (condition: TSingleCondition): TSingleCondition => { + return { + ...condition, + id: createId(), + }; + }; + + const duplicateAction = (action: TSurveyLogicAction): TSurveyLogicAction => { + return { + ...action, + id: createId(), + }; + }; + + return { + ...logicItem, + id: createId(), + conditions: duplicateConditionGroup(logicItem.conditions), + actions: logicItem.actions.map(duplicateAction), + }; +}; + +export const addConditionBelow = ( + group: TConditionGroup, + resourceId: string, + condition: TSingleCondition +) => { + for (let i = 0; i < group.conditions.length; i++) { + const item = group.conditions[i]; + + if (isConditionGroup(item)) { + if (item.id === resourceId) { + group.conditions.splice(i + 1, 0, condition); + break; + } else { + addConditionBelow(item, resourceId, condition); + } + } else { + if (item.id === resourceId) { + group.conditions.splice(i + 1, 0, condition); + break; + } + } + } +}; + +export const toggleGroupConnector = (group: TConditionGroup, resourceId: string) => { + if (group.id === resourceId) { + group.connector = group.connector === "and" ? "or" : "and"; + return; + } + + for (const condition of group.conditions) { + if (condition.connector) { + toggleGroupConnector(condition, resourceId); + } + } +}; + +export const removeCondition = (group: TConditionGroup, resourceId: string) => { + for (let i = 0; i < group.conditions.length; i++) { + const item = group.conditions[i]; + + if (item.id === resourceId) { + group.conditions.splice(i, 1); + return; + } + + if (isConditionGroup(item)) { + removeCondition(item, resourceId); + } + } + + deleteEmptyGroups(group); +}; + +export const duplicateCondition = (group: TConditionGroup, resourceId: string) => { + for (let i = 0; i < group.conditions.length; i++) { + const item = group.conditions[i]; + + if (item.id === resourceId) { + const newItem: TCondition = { + ...item, + id: createId(), + }; + group.conditions.splice(i + 1, 0, newItem); + return; + } + + if (item.connector) { + duplicateCondition(item, resourceId); + } + } +}; + +export const deleteEmptyGroups = (group: TConditionGroup) => { + for (let i = 0; i < group.conditions.length; i++) { + const resource = group.conditions[i]; + + if (isConditionGroup(resource) && resource.conditions.length === 0) { + group.conditions.splice(i, 1); + } else if (isConditionGroup(resource)) { + deleteEmptyGroups(resource); + } + } +}; + +export const createGroupFromResource = (group: TConditionGroup, resourceId: string) => { + for (let i = 0; i < group.conditions.length; i++) { + const item = group.conditions[i]; + + if (item.id === resourceId) { + const newGroup: TConditionGroup = { + id: createId(), + connector: "and", + conditions: [item], + }; + group.conditions[i] = newGroup; + group.connector = group.connector ?? "and"; + return; + } + + if (isConditionGroup(item)) { + createGroupFromResource(item, resourceId); + } + } +}; + +export const updateCondition = ( + group: TConditionGroup, + resourceId: string, + condition: Partial +) => { + for (let i = 0; i < group.conditions.length; i++) { + const item = group.conditions[i]; + + if (item.id === resourceId && !("connector" in item)) { + group.conditions[i] = { ...item, ...condition } as TSingleCondition; + return; + } + + if (isConditionGroup(item)) { + updateCondition(item, resourceId, condition); + } + } +}; + +export const getUpdatedActionBody = ( + action: TSurveyLogicAction, + objective: TActionObjective +): TSurveyLogicAction => { + if (objective === action.objective) return action; + switch (objective) { + case "calculate": + return { + id: action.id, + objective: "calculate", + variableId: "", + operator: "assign", + value: { type: "static", value: "" }, + }; + case "requireAnswer": + return { + id: action.id, + objective: "requireAnswer", + target: "", + }; + case "jumpToQuestion": + return { + id: action.id, + objective: "jumpToQuestion", + target: "", + }; + } +}; + +export const evaluateLogic = ( + localSurvey: TJsEnvironmentStateSurvey, + data: TResponseData, + variablesData: TResponseVariables, + conditions: TConditionGroup, + selectedLanguage: string +): boolean => { + const evaluateConditionGroup = (group: TConditionGroup): boolean => { + const results = group.conditions.map((condition) => { + if (isConditionGroup(condition)) { + return evaluateConditionGroup(condition); + } else { + return evaluateSingleCondition(localSurvey, data, variablesData, condition, selectedLanguage); + } + }); + + return group.connector === "or" ? results.some((r) => r) : results.every((r) => r); + }; + + return evaluateConditionGroup(conditions); +}; + +const evaluateSingleCondition = ( + localSurvey: TJsEnvironmentStateSurvey, + data: TResponseData, + variablesData: TResponseVariables, + condition: TSingleCondition, + selectedLanguage: string +): boolean => { + try { + let leftValue = getLeftOperandValue( + localSurvey, + data, + variablesData, + condition.leftOperand, + selectedLanguage + ); + let rightValue = condition.rightOperand + ? getRightOperandValue(localSurvey, data, variablesData, condition.rightOperand) + : undefined; + + let leftField: TSurveyQuestion | TSurveyVariable | string; + + if (condition.leftOperand?.type === "question") { + leftField = localSurvey.questions.find((q) => q.id === condition.leftOperand?.value) as TSurveyQuestion; + } else if (condition.leftOperand?.type === "variable") { + leftField = localSurvey.variables.find((v) => v.id === condition.leftOperand?.value) as TSurveyVariable; + } else if (condition.leftOperand?.type === "hiddenField") { + leftField = condition.leftOperand.value as string; + } else { + leftField = ""; + } + + let rightField: TSurveyQuestion | TSurveyVariable | string; + + if (condition.rightOperand?.type === "question") { + rightField = localSurvey.questions.find( + (q) => q.id === condition.rightOperand?.value + ) as TSurveyQuestion; + } else if (condition.rightOperand?.type === "variable") { + rightField = localSurvey.variables.find( + (v) => v.id === condition.rightOperand?.value + ) as TSurveyVariable; + } else if (condition.rightOperand?.type === "hiddenField") { + rightField = condition.rightOperand.value as string; + } else { + rightField = ""; + } + + if ( + condition.leftOperand.type === "variable" && + (leftField as TSurveyVariable).type === "number" && + condition.rightOperand?.type === "hiddenField" + ) { + rightValue = Number(rightValue as string); + } + + switch (condition.operator) { + case "equals": + if (condition.leftOperand.type === "question") { + if ( + (leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date && + typeof leftValue === "string" && + typeof rightValue === "string" + ) { + // when left value is of date question and right value is string + return new Date(leftValue).getTime() === new Date(rightValue).getTime(); + } + } + + // when left value is of openText, hiddenField, variable and right value is of multichoice + if (condition.rightOperand?.type === "question") { + if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) { + if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) { + return rightValue.includes(leftValue as string); + } else return false; + } else if ( + (rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date && + typeof leftValue === "string" && + typeof rightValue === "string" + ) { + return new Date(leftValue).getTime() === new Date(rightValue).getTime(); + } + } + + return ( + (Array.isArray(leftValue) && + leftValue.length === 1 && + typeof rightValue === "string" && + leftValue.includes(rightValue)) || + leftValue === rightValue + ); + case "doesNotEqual": + // when left value is of picture selection question and right value is its option + if ( + condition.leftOperand.type === "question" && + (leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.PictureSelection && + Array.isArray(leftValue) && + leftValue.length > 0 && + typeof rightValue === "string" + ) { + return !leftValue.includes(rightValue); + } + + // when left value is of date question and right value is string + if ( + condition.leftOperand.type === "question" && + (leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date && + typeof leftValue === "string" && + typeof rightValue === "string" + ) { + return new Date(leftValue).getTime() !== new Date(rightValue).getTime(); + } + + // when left value is of openText, hiddenField, variable and right value is of multichoice + if (condition.rightOperand?.type === "question") { + if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) { + if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) { + return !rightValue.includes(leftValue as string); + } else return false; + } else if ( + (rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date && + typeof leftValue === "string" && + typeof rightValue === "string" + ) { + return new Date(leftValue).getTime() !== new Date(rightValue).getTime(); + } + } + + return ( + (Array.isArray(leftValue) && + leftValue.length === 1 && + typeof rightValue === "string" && + !leftValue.includes(rightValue)) || + leftValue !== rightValue + ); + case "contains": + return String(leftValue).includes(String(rightValue)); + case "doesNotContain": + return !String(leftValue).includes(String(rightValue)); + case "startsWith": + return String(leftValue).startsWith(String(rightValue)); + case "doesNotStartWith": + return !String(leftValue).startsWith(String(rightValue)); + case "endsWith": + return String(leftValue).endsWith(String(rightValue)); + case "doesNotEndWith": + return !String(leftValue).endsWith(String(rightValue)); + case "isSubmitted": + if (typeof leftValue === "string") { + if ( + condition.leftOperand.type === "question" && + (leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.FileUpload && + leftValue + ) { + return leftValue !== "skipped"; + } + return leftValue !== "" && leftValue !== null; + } else if (Array.isArray(leftValue)) { + return leftValue.length > 0; + } else if (typeof leftValue === "number") { + return leftValue !== null; + } + return false; + case "isSkipped": + return ( + (Array.isArray(leftValue) && leftValue.length === 0) || + leftValue === "" || + leftValue === null || + leftValue === undefined || + (typeof leftValue === "object" && Object.entries(leftValue).length === 0) + ); + case "isGreaterThan": + return Number(leftValue) > Number(rightValue); + case "isLessThan": + return Number(leftValue) < Number(rightValue); + case "isGreaterThanOrEqual": + return Number(leftValue) >= Number(rightValue); + case "isLessThanOrEqual": + return Number(leftValue) <= Number(rightValue); + case "equalsOneOf": + return Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.includes(leftValue); + case "includesAllOf": + return ( + Array.isArray(leftValue) && + Array.isArray(rightValue) && + rightValue.every((v) => leftValue.includes(v)) + ); + case "includesOneOf": + return ( + Array.isArray(leftValue) && + Array.isArray(rightValue) && + rightValue.some((v) => leftValue.includes(v)) + ); + case "doesNotIncludeAllOf": + return ( + Array.isArray(leftValue) && + Array.isArray(rightValue) && + rightValue.every((v) => !leftValue.includes(v)) + ); + case "doesNotIncludeOneOf": + return ( + Array.isArray(leftValue) && + Array.isArray(rightValue) && + rightValue.some((v) => !leftValue.includes(v)) + ); + case "isAccepted": + return leftValue === "accepted"; + case "isClicked": + return leftValue === "clicked"; + case "isAfter": + return new Date(String(leftValue)) > new Date(String(rightValue)); + case "isBefore": + return new Date(String(leftValue)) < new Date(String(rightValue)); + case "isBooked": + return leftValue === "booked" || !!(leftValue && leftValue !== ""); + case "isPartiallySubmitted": + if (typeof leftValue === "object") { + return Object.values(leftValue).includes(""); + } else return false; + case "isCompletelySubmitted": + if (typeof leftValue === "object") { + const values = Object.values(leftValue); + return values.length > 0 && !values.includes(""); + } else return false; + case "isSet": + case "isNotEmpty": + return leftValue !== undefined && leftValue !== null && leftValue !== ""; + case "isNotSet": + return leftValue === undefined || leftValue === null || leftValue === ""; + case "isEmpty": + return leftValue === ""; + case "isAnyOf": + if (Array.isArray(rightValue) && typeof leftValue === "string") { + return rightValue.includes(leftValue); + } + return false; + default: + return false; + } + } catch (e) { + return false; + } +}; + +const getVariableValue = ( + variables: TSurveyVariable[], + variableId: string, + variablesData: TResponseVariables +) => { + const variable = variables.find((v) => v.id === variableId); + if (!variable) return undefined; + const variableValue = variablesData[variableId]; + return variable.type === "number" ? Number(variableValue) || 0 : variableValue || ""; +}; + +const getLeftOperandValue = ( + localSurvey: TJsEnvironmentStateSurvey, + data: TResponseData, + variablesData: TResponseVariables, + leftOperand: TSingleCondition["leftOperand"], + selectedLanguage: string +) => { + switch (leftOperand.type) { + case "question": + const currentQuestion = localSurvey.questions.find((q) => q.id === leftOperand.value); + if (!currentQuestion) return undefined; + + const responseValue = data[leftOperand.value]; + + if (currentQuestion.type === "openText" && currentQuestion.inputType === "number") { + return Number(responseValue) || undefined; + } + + if (currentQuestion.type === "multipleChoiceSingle" || currentQuestion.type === "multipleChoiceMulti") { + const isOthersEnabled = currentQuestion.choices.at(-1)?.id === "other"; + + if (typeof responseValue === "string") { + const choice = currentQuestion.choices.find((choice) => { + return getLocalizedValue(choice.label, selectedLanguage) === responseValue; + }); + + if (!choice) { + if (isOthersEnabled) { + return "other"; + } + + return undefined; + } + + return choice.id; + } else if (Array.isArray(responseValue)) { + let choices: string[] = []; + responseValue.forEach((value) => { + const foundChoice = currentQuestion.choices.find((choice) => { + return getLocalizedValue(choice.label, selectedLanguage) === value; + }); + + if (foundChoice) { + choices.push(foundChoice.id); + } else if (isOthersEnabled) { + choices.push("other"); + } + }); + if (choices) { + return Array.from(new Set(choices)); + } + } + } + + if ( + currentQuestion.type === "matrix" && + typeof responseValue === "object" && + !Array.isArray(responseValue) + ) { + if (leftOperand.meta && leftOperand.meta.row !== undefined) { + const rowIndex = Number(leftOperand.meta.row); + + if (isNaN(rowIndex) || rowIndex < 0 || rowIndex >= currentQuestion.rows.length) { + return undefined; + } + const row = getLocalizedValue(currentQuestion.rows[rowIndex], selectedLanguage); + + const rowValue = responseValue[row]; + if (rowValue === "") return ""; + + if (rowValue) { + const columnIndex = currentQuestion.columns.findIndex((column) => { + return getLocalizedValue(column, selectedLanguage) === rowValue; + }); + if (columnIndex === -1) return undefined; + return columnIndex.toString(); + } + return undefined; + } + } + + return data[leftOperand.value]; + case "variable": + const variables = localSurvey.variables || []; + return getVariableValue(variables, leftOperand.value, variablesData); + case "hiddenField": + return data[leftOperand.value]; + default: + return undefined; + } +}; + +const getRightOperandValue = ( + localSurvey: TJsEnvironmentStateSurvey, + data: TResponseData, + variablesData: TResponseVariables, + rightOperand: TSingleCondition["rightOperand"] +) => { + if (!rightOperand) return undefined; + + switch (rightOperand.type) { + case "question": + return data[rightOperand.value]; + case "variable": + const variables = localSurvey.variables || []; + return getVariableValue(variables, rightOperand.value, variablesData); + case "hiddenField": + return data[rightOperand.value]; + case "static": + return rightOperand.value; + default: + return undefined; + } +}; + +export const performActions = ( + survey: TJsEnvironmentStateSurvey, + actions: TSurveyLogicAction[], + data: TResponseData, + calculationResults: TResponseVariables +): { + jumpTarget: string | undefined; + requiredQuestionIds: string[]; + calculations: TResponseVariables; +} => { + let jumpTarget: string | undefined; + const requiredQuestionIds: string[] = []; + const calculations: TResponseVariables = { ...calculationResults }; + + actions.forEach((action) => { + switch (action.objective) { + case "calculate": + const result = performCalculation(survey, action, data, calculations); + if (result !== undefined) calculations[action.variableId] = result; + break; + case "requireAnswer": + requiredQuestionIds.push(action.target); + break; + case "jumpToQuestion": + if (!jumpTarget) { + jumpTarget = action.target; + } + break; + } + }); + + return { jumpTarget, requiredQuestionIds, calculations }; +}; + +const performCalculation = ( + survey: TJsEnvironmentStateSurvey, + action: TActionCalculate, + data: TResponseData, + calculations: Record +): number | string | undefined => { + const variables = survey.variables || []; + const variable = variables.find((v) => v.id === action.variableId); + + if (!variable) return undefined; + + let currentValue = calculations[action.variableId]; + if (currentValue === undefined) { + currentValue = variable.type === "number" ? 0 : ""; + } + let operandValue: string | number | undefined; + + // Determine the operand value based on the action.value type + switch (action.value.type) { + case "static": + operandValue = action.value.value; + break; + case "variable": + const value = calculations[action.value.value]; + if (typeof value === "number" || typeof value === "string") { + operandValue = value; + } + break; + case "question": + case "hiddenField": + const val = data[action.value.value]; + if (typeof val === "number" || typeof val === "string") { + if (variable.type === "number" && !isNaN(Number(val))) { + operandValue = Number(val); + } + operandValue = val; + } + break; + } + + if (operandValue === undefined || operandValue === null) return undefined; + + let result: number | string; + + switch (action.operator) { + case "add": + result = Number(currentValue) + Number(operandValue); + break; + case "subtract": + result = Number(currentValue) - Number(operandValue); + break; + case "multiply": + result = Number(currentValue) * Number(operandValue); + break; + case "divide": + if (Number(operandValue) === 0) return undefined; + result = Number(currentValue) / Number(operandValue); + break; + case "assign": + result = operandValue; + break; + case "concat": + result = String(currentValue) + String(operandValue); + break; + } + + return result; +}; diff --git a/apps/web/lib/tag/service.test.ts b/apps/web/lib/tag/service.test.ts new file mode 100644 index 000000000000..f18212be2701 --- /dev/null +++ b/apps/web/lib/tag/service.test.ts @@ -0,0 +1,150 @@ +import { TagError } from "@/modules/projects/settings/types/tag"; +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { TTag } from "@formbricks/types/tags"; +import { createTag, getTag, getTagsByEnvironmentId } from "./service"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + tag: { + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + }, + }, +})); + +describe("Tag Service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getTagsByEnvironmentId", () => { + test("should return tags for a given environment ID", async () => { + const mockTags: TTag[] = [ + { + id: "tag1", + name: "Tag 1", + environmentId: "env1", + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags); + + const result = await getTagsByEnvironmentId("env1"); + expect(result).toEqual(mockTags); + expect(prisma.tag.findMany).toHaveBeenCalledWith({ + where: { + environmentId: "env1", + }, + take: undefined, + skip: undefined, + }); + }); + + test("should handle pagination correctly", async () => { + const mockTags: TTag[] = [ + { + id: "tag1", + name: "Tag 1", + environmentId: "env1", + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags); + + const result = await getTagsByEnvironmentId("env1", 1); + expect(result).toEqual(mockTags); + expect(prisma.tag.findMany).toHaveBeenCalledWith({ + where: { + environmentId: "env1", + }, + take: 30, + skip: 0, + }); + }); + }); + + describe("getTag", () => { + test("should return a tag by ID", async () => { + const mockTag: TTag = { + id: "tag1", + name: "Tag 1", + environmentId: "env1", + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(prisma.tag.findUnique).mockResolvedValue(mockTag); + + const result = await getTag("tag1"); + expect(result).toEqual(mockTag); + expect(prisma.tag.findUnique).toHaveBeenCalledWith({ + where: { + id: "tag1", + }, + }); + }); + + test("should return null when tag is not found", async () => { + vi.mocked(prisma.tag.findUnique).mockResolvedValue(null); + + const result = await getTag("nonexistent"); + expect(result).toBeNull(); + }); + }); + + describe("createTag", () => { + test("should create a new tag", async () => { + const mockTag: TTag = { + id: "tag1", + name: "New Tag", + environmentId: "env1", + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(prisma.tag.create).mockResolvedValue(mockTag); + + const result = await createTag("env1", "New Tag"); + expect(result).toEqual({ ok: true, data: mockTag }); + expect(prisma.tag.create).toHaveBeenCalledWith({ + data: { + name: "New Tag", + environmentId: "env1", + }, + }); + }); + + test("should handle duplicate tag name error", async () => { + // const duplicateError = new Error("Unique constraint failed"); + // (duplicateError as any).code = "P2002"; + const duplicateError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "4.0.0", + }); + + vi.mocked(prisma.tag.create).mockRejectedValue(duplicateError); + const result = await createTag("env1", "Duplicate Tag"); + expect(result).toEqual({ + ok: false, + error: { message: "Tag with this name already exists", code: TagError.TAG_NAME_ALREADY_EXISTS }, + }); + }); + test("should handle general database errors", async () => { + const generalError = new Error("Database connection failed"); + vi.mocked(prisma.tag.create).mockRejectedValue(generalError); + const result = await createTag("env1", "New Tag"); + expect(result).toStrictEqual({ + ok: false, + error: { message: "Database connection failed", code: TagError.UNEXPECTED_ERROR }, + }); + }); + }); +}); diff --git a/apps/web/lib/tag/service.ts b/apps/web/lib/tag/service.ts new file mode 100644 index 000000000000..03f065531215 --- /dev/null +++ b/apps/web/lib/tag/service.ts @@ -0,0 +1,78 @@ +import "server-only"; +import { TagError } from "@/modules/projects/settings/types/tag"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common"; +import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { TTag } from "@formbricks/types/tags"; +import { ITEMS_PER_PAGE } from "../constants"; +import { validateInputs } from "../utils/validate"; + +export const getTagsByEnvironmentId = reactCache( + async (environmentId: string, page?: number): Promise => { + validateInputs([environmentId, ZId], [page, ZOptionalNumber]); + + try { + const tags = await prisma.tag.findMany({ + where: { + environmentId, + }, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + }); + + return tags; + } catch (error) { + throw error; + } + } +); + +export const getTag = reactCache(async (id: string): Promise => { + validateInputs([id, ZId]); + + try { + const tag = await prisma.tag.findUnique({ + where: { + id, + }, + }); + + return tag; + } catch (error) { + throw error; + } +}); + +export const createTag = async ( + environmentId: string, + name: string +): Promise }>> => { + validateInputs([environmentId, ZId], [name, ZString]); + + try { + const tag = await prisma.tag.create({ + data: { + name, + environmentId, + }, + }); + + return ok(tag); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === PrismaErrorType.UniqueConstraintViolation) { + return err({ + code: TagError.TAG_NAME_ALREADY_EXISTS, + message: "Tag with this name already exists", + }); + } + } + return err({ + code: TagError.UNEXPECTED_ERROR, + message: error.message, + }); + } +}; diff --git a/apps/web/lib/tagOnResponse/service.test.ts b/apps/web/lib/tagOnResponse/service.test.ts new file mode 100644 index 000000000000..0973f4a7e7cc --- /dev/null +++ b/apps/web/lib/tagOnResponse/service.test.ts @@ -0,0 +1,146 @@ +import { Prisma } from "@prisma/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { getResponse } from "../response/service"; +import { addTagToRespone, deleteTagOnResponse, getTagsOnResponsesCount } from "./service"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + tagsOnResponses: { + create: vi.fn(), + delete: vi.fn(), + groupBy: vi.fn(), + }, + }, +})); + +vi.mock("../response/service", () => ({ + getResponse: vi.fn(), +})); + +describe("TagOnResponse Service", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("addTagToRespone should add a tag to a response", async () => { + const mockResponse = { + id: "response1", + surveyId: "survey1", + contact: { id: "contact1" }, + }; + + const mockTagOnResponse = { + tag: { + environmentId: "env1", + }, + }; + + vi.mocked(getResponse).mockResolvedValue(mockResponse as any); + vi.mocked(prisma.tagsOnResponses.create).mockResolvedValue(mockTagOnResponse as any); + + const result = await addTagToRespone("response1", "tag1"); + + expect(result).toEqual({ + responseId: "response1", + tagId: "tag1", + }); + + expect(prisma.tagsOnResponses.create).toHaveBeenCalledWith({ + data: { + responseId: "response1", + tagId: "tag1", + }, + select: { + tag: { + select: { + environmentId: true, + }, + }, + }, + }); + }); + + test("deleteTagOnResponse should delete a tag from a response", async () => { + const mockResponse = { + id: "response1", + surveyId: "survey1", + contact: { id: "contact1" }, + }; + + const mockDeletedTag = { + tag: { + environmentId: "env1", + }, + }; + + vi.mocked(getResponse).mockResolvedValue(mockResponse as any); + vi.mocked(prisma.tagsOnResponses.delete).mockResolvedValue(mockDeletedTag as any); + + const result = await deleteTagOnResponse("response1", "tag1"); + + expect(result).toEqual({ + responseId: "response1", + tagId: "tag1", + }); + + expect(prisma.tagsOnResponses.delete).toHaveBeenCalledWith({ + where: { + responseId_tagId: { + responseId: "response1", + tagId: "tag1", + }, + }, + select: { + tag: { + select: { + environmentId: true, + }, + }, + }, + }); + }); + + test("getTagsOnResponsesCount should return tag counts for an environment", async () => { + const mockTagsCount = [ + { tagId: "tag1", _count: { _all: 5 } }, + { tagId: "tag2", _count: { _all: 3 } }, + ]; + + vi.mocked(prisma.tagsOnResponses.groupBy).mockResolvedValue(mockTagsCount as any); + + const result = await getTagsOnResponsesCount("env1"); + + expect(result).toEqual([ + { tagId: "tag1", count: 5 }, + { tagId: "tag2", count: 3 }, + ]); + + expect(prisma.tagsOnResponses.groupBy).toHaveBeenCalledWith({ + by: ["tagId"], + where: { + response: { + survey: { + environment: { + id: "env1", + }, + }, + }, + }, + _count: { + _all: true, + }, + }); + }); + + test("should throw DatabaseError when prisma operation fails", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.tagsOnResponses.create).mockRejectedValue(prismaError); + + await expect(addTagToRespone("response1", "tag1")).rejects.toThrow(DatabaseError); + }); +}); diff --git a/apps/web/lib/tagOnResponse/service.ts b/apps/web/lib/tagOnResponse/service.ts new file mode 100644 index 000000000000..667571d243bb --- /dev/null +++ b/apps/web/lib/tagOnResponse/service.ts @@ -0,0 +1,92 @@ +import "server-only"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { ZId } from "@formbricks/types/common"; +import { DatabaseError } from "@formbricks/types/errors"; +import { TTagsCount, TTagsOnResponses } from "@formbricks/types/tags"; +import { validateInputs } from "../utils/validate"; + +const selectTagsOnResponse = { + tag: { + select: { + environmentId: true, + }, + }, +}; + +export const addTagToRespone = async (responseId: string, tagId: string): Promise => { + try { + await prisma.tagsOnResponses.create({ + data: { + responseId, + tagId, + }, + select: selectTagsOnResponse, + }); + + return { + responseId, + tagId, + }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const deleteTagOnResponse = async (responseId: string, tagId: string): Promise => { + try { + await prisma.tagsOnResponses.delete({ + where: { + responseId_tagId: { + responseId, + tagId, + }, + }, + select: selectTagsOnResponse, + }); + + return { + tagId, + responseId, + }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const getTagsOnResponsesCount = reactCache(async (environmentId: string): Promise => { + validateInputs([environmentId, ZId]); + + try { + const tagsCount = await prisma.tagsOnResponses.groupBy({ + by: ["tagId"], + where: { + response: { + survey: { + environment: { + id: environmentId, + }, + }, + }, + }, + _count: { + _all: true, + }, + }); + + return tagsCount.map((tagCount) => ({ tagId: tagCount.tagId, count: tagCount._count._all })); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}); diff --git a/packages/lib/telemetry.ts b/apps/web/lib/telemetry.ts similarity index 95% rename from packages/lib/telemetry.ts rename to apps/web/lib/telemetry.ts index 4a06f18b20e9..25cc2408a95f 100644 --- a/packages/lib/telemetry.ts +++ b/apps/web/lib/telemetry.ts @@ -22,7 +22,7 @@ export const captureTelemetry = async (eventName: string, properties = {}) => { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - api_key: "phc_SoIFUJ8b9ufDm0YOnoOxJf6PXyuHpO7N6RztxFdZTy", + api_key: "phc_SoIFUJ8b9ufDm0YOnoOxJf6PXyuHpO7N6RztxFdZTy", // NOSONAR // This is a public API key for telemetry and not a secret event: eventName, properties: { distinct_id: getTelemetryId(), diff --git a/apps/web/lib/time.test.ts b/apps/web/lib/time.test.ts new file mode 100644 index 000000000000..5b17cd0b1a2f --- /dev/null +++ b/apps/web/lib/time.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "vitest"; +import { + convertDateString, + convertDateTimeString, + convertDateTimeStringShort, + convertDatesInObject, + convertTimeString, + formatDate, + getTodaysDateFormatted, + getTodaysDateTimeFormatted, + timeSince, + timeSinceDate, +} from "./time"; + +describe("Time Utilities", () => { + describe("convertDateString", () => { + test("should format date string correctly", () => { + expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024"); + }); + + test("should return empty string for empty input", () => { + expect(convertDateString("")).toBe(""); + }); + + test("should return null for null input", () => { + expect(convertDateString(null as any)).toBe(null); + }); + + test("should handle invalid date strings", () => { + expect(convertDateString("not-a-date")).toBe("Invalid Date"); + }); + }); + + describe("convertDateTimeString", () => { + test("should format date and time string correctly", () => { + expect(convertDateTimeString("2024-03-20T15:30:00")).toBe("Wednesday, March 20, 2024 at 3:30 PM"); + }); + + test("should return empty string for empty input", () => { + expect(convertDateTimeString("")).toBe(""); + }); + }); + + describe("convertDateTimeStringShort", () => { + test("should format date and time string in short format", () => { + expect(convertDateTimeStringShort("2024-03-20T15:30:00")).toBe("March 20, 2024 at 3:30 PM"); + }); + + test("should return empty string for empty input", () => { + expect(convertDateTimeStringShort("")).toBe(""); + }); + }); + + describe("convertTimeString", () => { + test("should format time string correctly", () => { + expect(convertTimeString("2024-03-20T15:30:45")).toBe("3:30:45 PM"); + }); + }); + + describe("timeSince", () => { + test("should format time since in English", () => { + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + expect(timeSince(oneHourAgo.toISOString(), "en-US")).toBe("about 1 hour ago"); + }); + + test("should format time since in German", () => { + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + expect(timeSince(oneHourAgo.toISOString(), "de-DE")).toBe("vor etwa 1 Stunde"); + }); + }); + + describe("timeSinceDate", () => { + test("should format time since from Date object", () => { + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + expect(timeSinceDate(oneHourAgo)).toBe("about 1 hour ago"); + }); + }); + + describe("formatDate", () => { + test("should format date correctly", () => { + const date = new Date(2024, 2, 20); // March is month 2 (0-based) + expect(formatDate(date)).toBe("March 20, 2024"); + }); + }); + + describe("getTodaysDateFormatted", () => { + test("should format today's date with specified separator", () => { + const today = new Date(); + const expected = today.toISOString().split("T")[0].split("-").join("."); + expect(getTodaysDateFormatted(".")).toBe(expected); + }); + }); + + describe("getTodaysDateTimeFormatted", () => { + test("should format today's date and time with specified separator", () => { + const today = new Date(); + const datePart = today.toISOString().split("T")[0].split("-").join("."); + const timePart = today.toTimeString().split(" ")[0].split(":").join("."); + const expected = `${datePart}.${timePart}`; + expect(getTodaysDateTimeFormatted(".")).toBe(expected); + }); + }); + + describe("convertDatesInObject", () => { + test("should convert date strings to Date objects in an object", () => { + const input = { + id: 1, + createdAt: "2024-03-20T15:30:00", + updatedAt: "2024-03-20T16:30:00", + nested: { + createdAt: "2024-03-20T17:30:00", + }, + }; + + const result = convertDatesInObject(input); + expect(result.createdAt).toBeInstanceOf(Date); + expect(result.updatedAt).toBeInstanceOf(Date); + expect(result.nested.createdAt).toBeInstanceOf(Date); + expect(result.id).toBe(1); + }); + + test("should handle arrays", () => { + const input = [{ createdAt: "2024-03-20T15:30:00" }, { createdAt: "2024-03-20T16:30:00" }]; + + const result = convertDatesInObject(input); + expect(result[0].createdAt).toBeInstanceOf(Date); + expect(result[1].createdAt).toBeInstanceOf(Date); + }); + + test("should return non-objects as is", () => { + expect(convertDatesInObject(null)).toBe(null); + expect(convertDatesInObject("string")).toBe("string"); + expect(convertDatesInObject(123)).toBe(123); + }); + }); +}); diff --git a/packages/lib/time.ts b/apps/web/lib/time.ts similarity index 92% rename from packages/lib/time.ts rename to apps/web/lib/time.ts index da3f5c27d88e..859cf0fb8759 100644 --- a/packages/lib/time.ts +++ b/apps/web/lib/time.ts @@ -1,12 +1,17 @@ import { formatDistance, intlFormat } from "date-fns"; -import { de, enUS, fr, pt, ptBR, zhTW } from "date-fns/locale"; +import { de, enUS, fr, pt, ptBR, ro, zhTW } from "date-fns/locale"; import { TUserLocale } from "@formbricks/types/user"; -export const convertDateString = (dateString: string) => { +export const convertDateString = (dateString: string | null) => { + if (dateString === null) return null; if (!dateString) { return dateString; } + const date = new Date(dateString); + if (isNaN(date.getTime())) { + return "Invalid Date"; + } return intlFormat( date, { @@ -90,6 +95,8 @@ const getLocaleForTimeSince = (locale: TUserLocale) => { return zhTW; case "pt-PT": return pt; + case "ro-RO": + return ro; } }; diff --git a/packages/lib/useDocumentVisibility.ts b/apps/web/lib/useDocumentVisibility.ts similarity index 100% rename from packages/lib/useDocumentVisibility.ts rename to apps/web/lib/useDocumentVisibility.ts diff --git a/apps/web/lib/user/service.test.ts b/apps/web/lib/user/service.test.ts new file mode 100644 index 000000000000..457f746db0d1 --- /dev/null +++ b/apps/web/lib/user/service.test.ts @@ -0,0 +1,271 @@ +import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; +import { IdentityProvider, Objective, Prisma, Role } from "@prisma/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user"; +import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/fileValidation", () => ({ + isValidImageFile: vi.fn(), +})); + +vi.mock("@/lib/organization/service", () => ({ + getOrganizationsWhereUserIsSingleOwner: vi.fn(), + deleteOrganization: vi.fn(), +})); + +describe("User Service", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const mockPrismaUser = { + id: "user1", + name: "Test User", + email: "test@example.com", + emailVerified: new Date(), + imageUrl: null, + createdAt: new Date(), + updatedAt: new Date(), + role: Role.project_manager, + twoFactorEnabled: false, + identityProvider: IdentityProvider.email, + objective: Objective.increase_conversion, + notificationSettings: { + alert: {}, + + unsubscribedOrganizationIds: [], + }, + locale: "en-US" as TUserLocale, + lastLoginAt: new Date(), + isActive: true, + twoFactorSecret: null, + backupCodes: null, + password: null, + identityProviderAccountId: null, + groupId: null, + }; + + const mockOrganizations: TOrganization[] = [ + { + id: "org1", + name: "Organization 1", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: null, + plan: "free", + period: "monthly", + limits: { + projects: 3, + monthly: { + responses: 1500, + miu: 2000, + }, + }, + periodStart: new Date(), + }, + isAIEnabled: false, + }, + { + id: "org2", + name: "Organization 2", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: null, + plan: "free", + period: "monthly", + limits: { + projects: 3, + monthly: { + responses: 1500, + miu: 2000, + }, + }, + periodStart: new Date(), + }, + isAIEnabled: false, + }, + ]; + + describe("getUser", () => { + test("should return user when found", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockPrismaUser); + + const result = await getUser("user1"); + + expect(result).toEqual(mockPrismaUser); + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { id: "user1" }, + select: expect.any(Object), + }); + }); + + test("should return null when user not found", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + + const result = await getUser("nonexistent"); + + expect(result).toBeNull(); + }); + + test("should throw DatabaseError when prisma throws", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.user.findUnique).mockRejectedValue(prismaError); + + await expect(getUser("user1")).rejects.toThrow(DatabaseError); + }); + }); + + describe("getUserByEmail", () => { + test("should return user when found by email", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue(mockPrismaUser); + + const result = await getUserByEmail("test@example.com"); + + expect(result).toEqual(mockPrismaUser); + expect(prisma.user.findFirst).toHaveBeenCalledWith({ + where: { email: "test@example.com" }, + select: expect.any(Object), + }); + }); + + test("should return null when user not found by email", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + + const result = await getUserByEmail("nonexistent@example.com"); + + expect(result).toBeNull(); + }); + + test("should throw DatabaseError when prisma throws", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.user.findFirst).mockRejectedValue(prismaError); + + await expect(getUserByEmail("test@example.com")).rejects.toThrow(DatabaseError); + }); + }); + + describe("updateUser", () => { + test("should update user successfully", async () => { + const updatedPrismaUser = { + ...mockPrismaUser, + name: "Updated User", + }; + + const updateData: TUserUpdateInput = { + name: "Updated User", + }; + + vi.mocked(prisma.user.update).mockResolvedValue(updatedPrismaUser); + + const result = await updateUser("user1", updateData); + + expect(result).toEqual(updatedPrismaUser); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user1" }, + data: updateData, + select: expect.any(Object), + }); + }); + + test("should throw ResourceNotFoundError when user not found", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", { + code: PrismaErrorType.RecordDoesNotExist, + clientVersion: "5.0.0", + }); + vi.mocked(prisma.user.update).mockRejectedValue(prismaError); + + await expect(updateUser("nonexistent", { name: "New Name" })).rejects.toThrow(ResourceNotFoundError); + }); + + test("should throw InvalidInputError when invalid image URL is provided", async () => { + const { isValidImageFile } = await import("@/lib/fileValidation"); + vi.mocked(isValidImageFile).mockReturnValue(false); + + await expect(updateUser("user1", { imageUrl: "invalid-image-url" })).rejects.toThrow(InvalidInputError); + }); + }); + + describe("deleteUser", () => { + test("should delete user and their organizations when they are single owner", async () => { + vi.mocked(prisma.user.delete).mockResolvedValue(mockPrismaUser); + vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValue(mockOrganizations); + vi.mocked(deleteOrganization).mockResolvedValue(); + + const result = await deleteUser("user1"); + + expect(result).toEqual(mockPrismaUser); + expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("user1"); + expect(deleteOrganization).toHaveBeenCalledWith("org1"); + expect(prisma.user.delete).toHaveBeenCalledWith({ + where: { id: "user1" }, + select: expect.any(Object), + }); + }); + + test("should throw DatabaseError when prisma throws", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValue([]); + vi.mocked(prisma.user.delete).mockRejectedValue(prismaError); + + await expect(deleteUser("user1")).rejects.toThrow(DatabaseError); + }); + }); + + describe("getUsersWithOrganization", () => { + test("should return users in an organization", async () => { + const mockUsers = [mockPrismaUser]; + vi.mocked(prisma.user.findMany).mockResolvedValue(mockUsers); + + const result = await getUsersWithOrganization("org1"); + + expect(result).toEqual(mockUsers); + expect(prisma.user.findMany).toHaveBeenCalledWith({ + where: { + memberships: { + some: { + organizationId: "org1", + }, + }, + }, + select: expect.any(Object), + }); + }); + + test("should throw DatabaseError when prisma throws", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.user.findMany).mockRejectedValue(prismaError); + + await expect(getUsersWithOrganization("org1")).rejects.toThrow(DatabaseError); + }); + }); +}); diff --git a/apps/web/lib/user/service.ts b/apps/web/lib/user/service.ts new file mode 100644 index 000000000000..750ed3c45387 --- /dev/null +++ b/apps/web/lib/user/service.ts @@ -0,0 +1,196 @@ +import "server-only"; +import { isValidImageFile } from "@/lib/fileValidation"; +import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; +import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo"; +import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { z } from "zod"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { ZId } from "@formbricks/types/common"; +import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbricks/types/user"; +import { validateInputs } from "../utils/validate"; + +const responseSelection = { + id: true, + name: true, + email: true, + emailVerified: true, + imageUrl: true, + createdAt: true, + updatedAt: true, + role: true, + twoFactorEnabled: true, + identityProvider: true, + objective: true, + notificationSettings: true, + locale: true, + lastLoginAt: true, + isActive: true, +}; + +// function to retrive basic information about a user's user +export const getUser = reactCache(async (id: string): Promise => { + validateInputs([id, ZId]); + + try { + const user = await prisma.user.findUnique({ + where: { + id, + }, + select: responseSelection, + }); + + if (!user) { + return null; + } + return user; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}); + +export const getUserByEmail = reactCache(async (email: string): Promise => { + validateInputs([email, z.string().email()]); + + try { + const user = await prisma.user.findFirst({ + where: { + email, + }, + select: responseSelection, + }); + + return user; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}); + +// function to update a user's user +export const updateUser = async (personId: string, data: TUserUpdateInput): Promise => { + validateInputs([personId, ZId], [data, ZUserUpdateInput.partial()]); + if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file"); + + try { + const updatedUser = await prisma.user.update({ + where: { + id: personId, + }, + data: data, + select: responseSelection, + }); + + return updatedUser; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === PrismaErrorType.RecordDoesNotExist + ) { + throw new ResourceNotFoundError("User", personId); + } + throw error; // Re-throw any other errors + } +}; + +const deleteUserById = async (id: string): Promise => { + validateInputs([id, ZId]); + + try { + const user = await prisma.user.delete({ + where: { + id, + }, + select: responseSelection, + }); + return user; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +// function to delete a user's user including organizations +export const deleteUser = async (id: string): Promise => { + validateInputs([id, ZId]); + + try { + const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(id); + + for (const organization of organizationsWithSingleOwner) { + await deleteOrganization(organization.id); + } + + const deletedUser = await deleteUserById(id); + await deleteBrevoCustomerByEmail({ email: deletedUser.email }); + + return deletedUser; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const getUsersWithOrganization = async (organizationId: string): Promise => { + validateInputs([organizationId, ZId]); + + try { + const users = await prisma.user.findMany({ + where: { + memberships: { + some: { + organizationId, + }, + }, + }, + select: responseSelection, + }); + + return users; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; + +export const getUserLocale = reactCache(async (id: string): Promise => { + validateInputs([id, ZId]); + + try { + const user = await prisma.user.findUnique({ + where: { + id, + }, + select: responseSelection, + }); + + if (!user) { + return undefined; + } + return user.locale; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}); diff --git a/apps/web/lib/utils/action-client-middleware.ts b/apps/web/lib/utils/action-client-middleware.ts deleted file mode 100644 index 1a5d36d21b6a..000000000000 --- a/apps/web/lib/utils/action-client-middleware.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "@/modules/ee/teams/lib/roles"; -import { type TTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; -import { type TTeamRole } from "@/modules/ee/teams/team-list/types/team"; -import { returnValidationErrors } from "next-safe-action"; -import { ZodIssue, z } from "zod"; -import { getMembershipRole } from "@formbricks/lib/membership/hooks/actions"; -import { AuthorizationError } from "@formbricks/types/errors"; -import { type TOrganizationRole } from "@formbricks/types/memberships"; - -const formatErrors = (issues: ZodIssue[]): Record => { - return { - ...issues.reduce((acc, issue) => { - acc[issue.path.join(".")] = { - _errors: [issue.message], - }; - return acc; - }, {}), - }; -}; - -export type TAccess = - | { - type: "organization"; - schema?: z.ZodObject; - data?: z.ZodObject["_output"]; - roles: TOrganizationRole[]; - } - | { - type: "projectTeam"; - minPermission?: TTeamPermission; - projectId: string; - } - | { - type: "team"; - minPermission?: TTeamRole; - teamId: string; - }; - -const teamPermissionWeight = { - read: 1, - readWrite: 2, - manage: 3, -}; - -const teamRoleWeight = { - contributor: 1, - admin: 2, -}; - -export const checkAuthorizationUpdated = async ({ - userId, - organizationId, - access, -}: { - userId: string; - organizationId: string; - access: TAccess[]; -}) => { - const role = await getMembershipRole(userId, organizationId); - - for (const accessItem of access) { - if (accessItem.type === "organization") { - if (accessItem.schema) { - const resultSchema = accessItem.schema.strict(); - const parsedResult = resultSchema.safeParse(accessItem.data); - if (!parsedResult.success) { - // @ts-expect-error -- TODO: match dynamic next-safe-action types - return returnValidationErrors(resultSchema, formatErrors(parsedResult.error.issues)); - } - } - - if (accessItem.roles.includes(role)) { - return true; - } - } else { - if (accessItem.type === "projectTeam") { - const projectPermission = await getProjectPermissionByUserId(userId, accessItem.projectId); - if ( - !projectPermission || - (accessItem.minPermission !== undefined && - teamPermissionWeight[projectPermission] < teamPermissionWeight[accessItem.minPermission]) - ) { - continue; - } - } else { - const teamRole = await getTeamRoleByTeamIdUserId(accessItem.teamId, userId); - if ( - !teamRole || - (accessItem.minPermission !== undefined && - teamRoleWeight[teamRole] < teamRoleWeight[accessItem.minPermission]) - ) { - continue; - } - } - return true; - } - } - - throw new AuthorizationError("Not authorized"); -}; diff --git a/apps/web/lib/utils/action-client.ts b/apps/web/lib/utils/action-client.ts deleted file mode 100644 index ba73be13de5a..000000000000 --- a/apps/web/lib/utils/action-client.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { authOptions } from "@/modules/auth/lib/authOptions"; -import { getServerSession } from "next-auth"; -import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action"; -import { getUser } from "@formbricks/lib/user/service"; -import { logger } from "@formbricks/logger"; -import { - AuthenticationError, - AuthorizationError, - InvalidInputError, - OperationNotAllowedError, - ResourceNotFoundError, - UnknownError, -} from "@formbricks/types/errors"; - -export const actionClient = createSafeActionClient({ - handleServerError(e) { - if ( - e instanceof ResourceNotFoundError || - e instanceof AuthorizationError || - e instanceof InvalidInputError || - e instanceof UnknownError || - e instanceof AuthenticationError || - e instanceof OperationNotAllowedError - ) { - return e.message; - } - - // eslint-disable-next-line no-console -- This error needs to be logged for debugging server-side errors - logger.error(e, "SERVER ERROR"); - return DEFAULT_SERVER_ERROR_MESSAGE; - }, -}); - -export const authenticatedActionClient = actionClient.use(async ({ next }) => { - const session = await getServerSession(authOptions); - if (!session?.user) { - throw new AuthenticationError("Not authenticated"); - } - - const userId = session.user.id; - - const user = await getUser(userId); - if (!user) { - throw new AuthorizationError("User not found"); - } - - return next({ ctx: { user } }); -}); diff --git a/apps/web/lib/utils/action-client/action-client-middleware.test.ts b/apps/web/lib/utils/action-client/action-client-middleware.test.ts new file mode 100644 index 000000000000..71709fc7c995 --- /dev/null +++ b/apps/web/lib/utils/action-client/action-client-middleware.test.ts @@ -0,0 +1,386 @@ +import { getMembershipRole } from "@/lib/membership/hooks/actions"; +import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "@/modules/ee/teams/lib/roles"; +import { cleanup } from "@testing-library/react"; +import { returnValidationErrors } from "next-safe-action"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ZodIssue, z } from "zod"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { checkAuthorizationUpdated, formatErrors } from "./action-client-middleware"; + +vi.mock("@/lib/membership/hooks/actions", () => ({ + getMembershipRole: vi.fn(), +})); + +vi.mock("@/modules/ee/teams/lib/roles", () => ({ + getProjectPermissionByUserId: vi.fn(), + getTeamRoleByTeamIdUserId: vi.fn(), +})); + +vi.mock("next-safe-action", () => ({ + returnValidationErrors: vi.fn(), +})); + +describe("action-client-middleware", () => { + const userId = "user-1"; + const organizationId = "org-1"; + const projectId = "project-1"; + const teamId = "team-1"; + + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + describe("formatErrors", () => { + // We need to access the private function for testing + // Using any to access the function directly + + test("formats simple path ZodIssue", () => { + const issues = [ + { + code: "custom", + path: ["name"], + message: "Name is required", + }, + ] as ZodIssue[]; + + const result = formatErrors(issues); + expect(result).toEqual({ + name: { + _errors: ["Name is required"], + }, + }); + }); + + test("formats nested path ZodIssue", () => { + const issues = [ + { + code: "custom", + path: ["user", "address", "street"], + message: "Street is required", + }, + ] as ZodIssue[]; + + const result = formatErrors(issues); + expect(result).toEqual({ + "user.address.street": { + _errors: ["Street is required"], + }, + }); + }); + + test("formats multiple ZodIssues", () => { + const issues = [ + { + code: "custom", + path: ["name"], + message: "Name is required", + }, + { + code: "custom", + path: ["email"], + message: "Invalid email", + }, + ] as ZodIssue[]; + + const result = formatErrors(issues); + expect(result).toEqual({ + name: { + _errors: ["Name is required"], + }, + email: { + _errors: ["Invalid email"], + }, + }); + }); + }); + + describe("checkAuthorizationUpdated", () => { + test("returns validation errors when schema validation fails", async () => { + vi.mocked(getMembershipRole).mockResolvedValue("owner"); + + const mockSchema = z.object({ + name: z.string(), + }); + + const mockData = { name: 123 }; // Type error to trigger validation failure + + vi.mocked(returnValidationErrors).mockReturnValue("validation-error" as unknown as never); + + const access = [ + { + type: "organization" as const, + schema: mockSchema, + data: mockData as any, + roles: ["owner" as const], + }, + ]; + + const result = await checkAuthorizationUpdated({ + userId, + organizationId, + access, + }); + + expect(returnValidationErrors).toHaveBeenCalledWith(expect.any(Object), expect.any(Object)); + expect(result).toBe("validation-error"); + }); + + test("returns true when organization access matches role", async () => { + vi.mocked(getMembershipRole).mockResolvedValue("owner"); + + const access = [ + { + type: "organization" as const, + roles: ["owner" as const], + }, + ]; + + const result = await checkAuthorizationUpdated({ userId, organizationId, access }); + + expect(result).toBe(true); + }); + + test("continues checking other access items when organization role doesn't match", async () => { + vi.mocked(getMembershipRole).mockResolvedValue("member"); + + const access = [ + { + type: "organization" as const, + roles: ["owner" as const], + }, + { + type: "projectTeam" as const, + projectId, + minPermission: "read" as const, + }, + ]; + + vi.mocked(getProjectPermissionByUserId).mockResolvedValue("readWrite"); + + const result = await checkAuthorizationUpdated({ userId, organizationId, access }); + + expect(result).toBe(true); + expect(getProjectPermissionByUserId).toHaveBeenCalledWith(userId, projectId); + }); + + test("returns true when projectTeam access matches permission", async () => { + vi.mocked(getMembershipRole).mockResolvedValue("member"); + + const access = [ + { + type: "projectTeam" as const, + projectId, + minPermission: "read" as const, + }, + ]; + + vi.mocked(getProjectPermissionByUserId).mockResolvedValue("readWrite"); + + const result = await checkAuthorizationUpdated({ userId, organizationId, access }); + + expect(result).toBe(true); + expect(getProjectPermissionByUserId).toHaveBeenCalledWith(userId, projectId); + }); + + test("continues checking other access items when projectTeam permission is insufficient", async () => { + vi.mocked(getMembershipRole).mockResolvedValue("member"); + + const access = [ + { + type: "projectTeam" as const, + projectId, + minPermission: "manage" as const, + }, + { + type: "team" as const, + teamId, + minPermission: "contributor" as const, + }, + ]; + + vi.mocked(getProjectPermissionByUserId).mockResolvedValue("read"); + vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("admin"); + + const result = await checkAuthorizationUpdated({ userId, organizationId, access }); + + expect(result).toBe(true); + expect(getProjectPermissionByUserId).toHaveBeenCalledWith(userId, projectId); + expect(getTeamRoleByTeamIdUserId).toHaveBeenCalledWith(teamId, userId); + }); + + test("returns true when team access matches role", async () => { + vi.mocked(getMembershipRole).mockResolvedValue("member"); + + const access = [ + { + type: "team" as const, + teamId, + minPermission: "contributor" as const, + }, + ]; + + vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("admin"); + + const result = await checkAuthorizationUpdated({ userId, organizationId, access }); + + expect(result).toBe(true); + expect(getTeamRoleByTeamIdUserId).toHaveBeenCalledWith(teamId, userId); + }); + + test("continues checking other access items when team role is insufficient", async () => { + vi.mocked(getMembershipRole).mockResolvedValue("member"); + + const access = [ + { + type: "team" as const, + teamId, + minPermission: "admin" as const, + }, + { + type: "organization" as const, + roles: ["member" as const], + }, + ]; + + vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("contributor"); + + const result = await checkAuthorizationUpdated({ userId, organizationId, access }); + + expect(result).toBe(true); + expect(getTeamRoleByTeamIdUserId).toHaveBeenCalledWith(teamId, userId); + }); + + test("throws AuthorizationError when no access matches", async () => { + vi.mocked(getMembershipRole).mockResolvedValue("member"); + + const access = [ + { + type: "organization" as const, + roles: ["owner" as const], + }, + { + type: "projectTeam" as const, + projectId, + minPermission: "manage" as const, + }, + { + type: "team" as const, + teamId, + minPermission: "admin" as const, + }, + ]; + + vi.mocked(getProjectPermissionByUserId).mockResolvedValue("read"); + vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("contributor"); + + await expect(checkAuthorizationUpdated({ userId, organizationId, access })).rejects.toThrow( + AuthorizationError + ); + await expect(checkAuthorizationUpdated({ userId, organizationId, access })).rejects.toThrow( + "Not authorized" + ); + }); + + test("continues to check when projectPermission is null", async () => { + vi.mocked(getMembershipRole).mockResolvedValue("member"); + + const access = [ + { + type: "projectTeam" as const, + projectId, + minPermission: "read" as const, + }, + { + type: "organization" as const, + roles: ["member" as const], + }, + ]; + + vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null); + + const result = await checkAuthorizationUpdated({ userId, organizationId, access }); + + expect(result).toBe(true); + }); + + test("continues to check when teamRole is null", async () => { + vi.mocked(getMembershipRole).mockResolvedValue("member"); + + const access = [ + { + type: "team" as const, + teamId, + minPermission: "contributor" as const, + }, + { + type: "organization" as const, + roles: ["member" as const], + }, + ]; + + vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue(null); + + const result = await checkAuthorizationUpdated({ userId, organizationId, access }); + + expect(result).toBe(true); + }); + + test("returns true when schema validation passes", async () => { + vi.mocked(getMembershipRole).mockResolvedValue("owner"); + + const mockSchema = z.object({ + name: z.string(), + }); + + const mockData = { name: "test" }; + + const access = [ + { + type: "organization" as const, + schema: mockSchema, + data: mockData, + roles: ["owner" as const], + }, + ]; + + const result = await checkAuthorizationUpdated({ userId, organizationId, access }); + + expect(result).toBe(true); + }); + + test("handles projectTeam access without minPermission specified", async () => { + vi.mocked(getMembershipRole).mockResolvedValue("member"); + + const access = [ + { + type: "projectTeam" as const, + projectId, + }, + ]; + + vi.mocked(getProjectPermissionByUserId).mockResolvedValue("read"); + + const result = await checkAuthorizationUpdated({ userId, organizationId, access }); + + expect(result).toBe(true); + }); + + test("handles team access without minPermission specified", async () => { + vi.mocked(getMembershipRole).mockResolvedValue("member"); + + const access = [ + { + type: "team" as const, + teamId, + }, + ]; + + vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("contributor"); + + const result = await checkAuthorizationUpdated({ userId, organizationId, access }); + + expect(result).toBe(true); + }); + }); +}); diff --git a/apps/web/lib/utils/action-client/action-client-middleware.ts b/apps/web/lib/utils/action-client/action-client-middleware.ts new file mode 100644 index 000000000000..60425a9438b2 --- /dev/null +++ b/apps/web/lib/utils/action-client/action-client-middleware.ts @@ -0,0 +1,120 @@ +import { getMembershipRole } from "@/lib/membership/hooks/actions"; +import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "@/modules/ee/teams/lib/roles"; +import { type TTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; +import { type TTeamRole } from "@/modules/ee/teams/team-list/types/team"; +import { returnValidationErrors } from "next-safe-action"; +import { ZodIssue, z } from "zod"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { type TOrganizationRole } from "@formbricks/types/memberships"; + +export const formatErrors = (issues: ZodIssue[]): Record => { + return { + ...issues.reduce((acc, issue) => { + acc[issue.path.join(".")] = { + _errors: [issue.message], + }; + return acc; + }, {}), + }; +}; + +export type TAccess = + | { + type: "organization"; + schema?: z.ZodObject; + data?: z.ZodObject["_output"]; + roles: TOrganizationRole[]; + } + | { + type: "projectTeam"; + minPermission?: TTeamPermission; + projectId: string; + } + | { + type: "team"; + minPermission?: TTeamRole; + teamId: string; + }; + +const teamPermissionWeight = { + read: 1, + readWrite: 2, + manage: 3, +}; + +const teamRoleWeight = { + contributor: 1, + admin: 2, +}; + +const checkOrganizationAccess = ( + accessItem: TAccess, + role: TOrganizationRole +) => { + if (accessItem.type !== "organization") return false; + if (accessItem.schema) { + const resultSchema = accessItem.schema.strict(); + const parsedResult = resultSchema.safeParse(accessItem.data); + if (!parsedResult.success) { + // @ts-expect-error -- match dynamic next-safe-action types + return returnValidationErrors(resultSchema, formatErrors(parsedResult.error.issues)); + } + } + return accessItem.roles.includes(role); +}; + +const checkProjectTeamAccess = async (accessItem: any, userId: string) => { + if (accessItem.type !== "projectTeam") return false; + const projectPermission = await getProjectPermissionByUserId(userId, accessItem.projectId); + if (!projectPermission) return false; + if ( + accessItem.minPermission !== undefined && + teamPermissionWeight[projectPermission] < teamPermissionWeight[accessItem.minPermission] + ) { + return false; + } + return true; +}; + +const checkTeamAccess = async (accessItem: any, userId: string) => { + if (accessItem.type !== "team") return false; + const teamRole = await getTeamRoleByTeamIdUserId(accessItem.teamId, userId); + if (!teamRole) return false; + if ( + accessItem.minPermission !== undefined && + teamRoleWeight[teamRole] < teamRoleWeight[accessItem.minPermission] + ) { + return false; + } + return true; +}; + +export const checkAuthorizationUpdated = async ({ + userId, + organizationId, + access, +}: { + userId: string; + organizationId: string; + access: TAccess[]; +}) => { + const role = await getMembershipRole(userId, organizationId); + + for (const accessItem of access) { + if (accessItem.type === "organization") { + const orgResult = checkOrganizationAccess(accessItem, role); + if (orgResult === true) return true; + if (orgResult) return orgResult; // validation error + } + + if (accessItem.type === "projectTeam" && (await checkProjectTeamAccess(accessItem, userId))) { + return true; + } + + if (accessItem.type === "team" && (await checkTeamAccess(accessItem, userId))) { + return true; + } + } + + throw new AuthorizationError("Not authorized"); +}; diff --git a/apps/web/lib/utils/action-client/index.ts b/apps/web/lib/utils/action-client/index.ts new file mode 100644 index 000000000000..5f6ce676c9b6 --- /dev/null +++ b/apps/web/lib/utils/action-client/index.ts @@ -0,0 +1,79 @@ +import { AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP } from "@/lib/constants"; +import { getUser } from "@/lib/user/service"; +import { getClientIpFromHeaders } from "@/lib/utils/client-ip"; +import { authOptions } from "@/modules/auth/lib/authOptions"; +import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; +import * as Sentry from "@sentry/nextjs"; +import { getServerSession } from "next-auth"; +import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe-action"; +import { v4 as uuidv4 } from "uuid"; +import { logger } from "@formbricks/logger"; +import { + AuthenticationError, + AuthorizationError, + InvalidInputError, + OperationNotAllowedError, + ResourceNotFoundError, + TooManyRequestsError, + UnknownError, +} from "@formbricks/types/errors"; +import { ActionClientCtx } from "./types/context"; + +export const actionClient = createSafeActionClient({ + handleServerError(e, utils) { + const eventId = (utils.ctx as Record)?.auditLoggingCtx?.eventId ?? undefined; // keep explicit fallback + Sentry.captureException(e, { + extra: { + eventId, + }, + }); + + if ( + e instanceof ResourceNotFoundError || + e instanceof AuthorizationError || + e instanceof InvalidInputError || + e instanceof UnknownError || + e instanceof AuthenticationError || + e instanceof OperationNotAllowedError || + e instanceof TooManyRequestsError + ) { + return e.message; + } + + // eslint-disable-next-line no-console -- This error needs to be logged for debugging server-side errors + logger.withContext({ eventId }).error(e, "SERVER ERROR"); + return DEFAULT_SERVER_ERROR_MESSAGE; + }, +}).use(async ({ next }) => { + // Create a unique event id + const eventId = uuidv4(); + const ctx: ActionClientCtx = { auditLoggingCtx: { eventId, ipAddress: UNKNOWN_DATA } }; + + if (AUDIT_LOG_ENABLED && AUDIT_LOG_GET_USER_IP) { + try { + const ipAddress = await getClientIpFromHeaders(); + ctx.auditLoggingCtx.ipAddress = ipAddress; + } catch (err) { + // Non-fatal – we keep UNKNOWN_DATA + logger.warn({ err }, "Failed to resolve client IP for audit logging"); + } + } + + return next({ ctx }); +}); + +export const authenticatedActionClient = actionClient.use(async ({ ctx, next }) => { + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new AuthenticationError("Not authenticated"); + } + + const userId = session.user.id; + + const user = await getUser(userId); + if (!user) { + throw new AuthorizationError("User not found"); + } + + return next({ ctx: { ...ctx, user } }); +}); diff --git a/apps/web/lib/utils/action-client/types/context.ts b/apps/web/lib/utils/action-client/types/context.ts new file mode 100644 index 000000000000..655af455fdf4 --- /dev/null +++ b/apps/web/lib/utils/action-client/types/context.ts @@ -0,0 +1,34 @@ +import { TUser } from "@formbricks/types/user"; + +export type AuditLoggingCtx = { + organizationId?: string; + ipAddress: string; + segmentId?: string; + oldObject?: Record | null; + newObject?: Record | null; + eventId?: string; + surveyId?: string; + tagId?: string; + webhookId?: string; + userId?: string; + projectId?: string; + languageId?: string; + inviteId?: string; + membershipId?: string; + actionClassId?: string; + contactId?: string; + apiKeyId?: string; + responseId?: string; + + teamId?: string; + integrationId?: string; +}; + +export type ActionClientCtx = { + auditLoggingCtx: AuditLoggingCtx; + user?: TUser; +}; + +export type AuthenticatedActionClientCtx = ActionClientCtx & { + user: TUser; +}; diff --git a/apps/web/lib/utils/billing.test.ts b/apps/web/lib/utils/billing.test.ts new file mode 100644 index 000000000000..f00ed8d30e76 --- /dev/null +++ b/apps/web/lib/utils/billing.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { getBillingPeriodStartDate } from "./billing"; + +describe("getBillingPeriodStartDate", () => { + let originalDate: DateConstructor; + + beforeEach(() => { + // Store the original Date constructor + originalDate = global.Date; + }); + + afterEach(() => { + // Restore the original Date constructor + global.Date = originalDate; + vi.useRealTimers(); + }); + + test("returns first day of month for free plans", () => { + // Mock the current date to be 2023-03-15 + vi.setSystemTime(new Date(2023, 2, 15)); + + const organization = { + billing: { + plan: "free", + periodStart: new Date("2023-01-15"), + period: "monthly", + }, + }; + + const result = getBillingPeriodStartDate(organization.billing); + + // For free plans, should return first day of current month + expect(result).toEqual(new Date(2023, 2, 1)); + }); + + test("returns correct date for monthly plans", () => { + // Mock the current date to be 2023-03-15 + vi.setSystemTime(new Date(2023, 2, 15)); + + const organization = { + billing: { + plan: "scale", + periodStart: new Date("2023-02-10"), + period: "monthly", + }, + }; + + const result = getBillingPeriodStartDate(organization.billing); + + // For monthly plans, should return periodStart directly + expect(result).toEqual(new Date("2023-02-10")); + }); + + test("returns current month's subscription day for yearly plans when today is after subscription day", () => { + // Mock the current date to be March 20, 2023 + vi.setSystemTime(new Date(2023, 2, 20)); + + const organization = { + billing: { + plan: "scale", + periodStart: new Date("2022-05-15"), // Original subscription on 15th + period: "yearly", + }, + }; + + const result = getBillingPeriodStartDate(organization.billing); + + // Should return March 15, 2023 (same day in current month) + expect(result).toEqual(new Date(2023, 2, 15)); + }); + + test("returns previous month's subscription day for yearly plans when today is before subscription day", () => { + // Mock the current date to be March 10, 2023 + vi.setSystemTime(new Date(2023, 2, 10)); + + const organization = { + billing: { + plan: "scale", + periodStart: new Date("2022-05-15"), // Original subscription on 15th + period: "yearly", + }, + }; + + const result = getBillingPeriodStartDate(organization.billing); + + // Should return February 15, 2023 (same day in previous month) + expect(result).toEqual(new Date(2023, 1, 15)); + }); + + test("handles subscription day that doesn't exist in current month (February edge case)", () => { + // Mock the current date to be February 15, 2023 + vi.setSystemTime(new Date(2023, 1, 15)); + + const organization = { + billing: { + plan: "scale", + periodStart: new Date("2022-01-31"), // Original subscription on 31st + period: "yearly", + }, + }; + + const result = getBillingPeriodStartDate(organization.billing); + + // Should return January 31, 2023 (previous month's subscription day) + // since today (Feb 15) is less than the subscription day (31st) + expect(result).toEqual(new Date(2023, 0, 31)); + }); + + test("handles subscription day that doesn't exist in previous month (February to March transition)", () => { + // Mock the current date to be March 10, 2023 + vi.setSystemTime(new Date(2023, 2, 10)); + + const organization = { + billing: { + plan: "scale", + periodStart: new Date("2022-01-30"), // Original subscription on 30th + period: "yearly", + }, + }; + + const result = getBillingPeriodStartDate(organization.billing); + + // Should return February 28, 2023 (last day of February) + // since February 2023 doesn't have a 30th day + expect(result).toEqual(new Date(2023, 1, 28)); + }); + + test("handles subscription day that doesn't exist in previous month (leap year)", () => { + // Mock the current date to be March 10, 2024 (leap year) + vi.setSystemTime(new Date(2024, 2, 10)); + + const organization = { + billing: { + plan: "scale", + periodStart: new Date("2023-01-30"), // Original subscription on 30th + period: "yearly", + }, + }; + + const result = getBillingPeriodStartDate(organization.billing); + + // Should return February 29, 2024 (last day of February in leap year) + expect(result).toEqual(new Date(2024, 1, 29)); + }); + test("handles current month with fewer days than subscription day", () => { + // Mock the current date to be April 25, 2023 (April has 30 days) + vi.setSystemTime(new Date(2023, 3, 25)); + + const organization = { + billing: { + plan: "scale", + periodStart: new Date("2022-01-31"), // Original subscription on 31st + period: "yearly", + }, + }; + + const result = getBillingPeriodStartDate(organization.billing); + + // Should return March 31, 2023 (since today is before April's adjusted subscription day) + expect(result).toEqual(new Date(2023, 2, 31)); + }); + + test("throws error when periodStart is not set for non-free plans", () => { + const organization = { + billing: { + plan: "scale", + periodStart: null, + period: "monthly", + }, + }; + + expect(() => { + getBillingPeriodStartDate(organization.billing); + }).toThrow("billing period start is not set"); + }); +}); diff --git a/apps/web/lib/utils/billing.ts b/apps/web/lib/utils/billing.ts new file mode 100644 index 000000000000..58d88764cfc9 --- /dev/null +++ b/apps/web/lib/utils/billing.ts @@ -0,0 +1,54 @@ +import { TOrganizationBilling } from "@formbricks/types/organizations"; + +// Function to calculate billing period start date based on organization plan and billing period +export const getBillingPeriodStartDate = (billing: TOrganizationBilling): Date => { + const now = new Date(); + if (billing.plan === "free") { + // For free plans, use the first day of the current calendar month + return new Date(now.getFullYear(), now.getMonth(), 1); + } else if (billing.period === "yearly" && billing.periodStart) { + // For yearly plans, use the same day of the month as the original subscription date + const periodStart = new Date(billing.periodStart); + // Use UTC to avoid timezone-offset shifting when parsing ISO date-only strings + const subscriptionDay = periodStart.getUTCDate(); + + // Helper function to get the last day of a specific month + const getLastDayOfMonth = (year: number, month: number): number => { + // Create a date for the first day of the next month, then subtract one day + return new Date(year, month + 1, 0).getDate(); + }; + + // Calculate the adjusted day for the current month + const lastDayOfCurrentMonth = getLastDayOfMonth(now.getFullYear(), now.getMonth()); + const adjustedCurrentMonthDay = Math.min(subscriptionDay, lastDayOfCurrentMonth); + + // Calculate the current month's adjusted subscription date + const currentMonthSubscriptionDate = new Date(now.getFullYear(), now.getMonth(), adjustedCurrentMonthDay); + + // If today is before the subscription day in the current month (or its adjusted equivalent), + // we should use the previous month's subscription day as our start date + if (now.getDate() < adjustedCurrentMonthDay) { + // Calculate previous month and year + const prevMonth = now.getMonth() === 0 ? 11 : now.getMonth() - 1; + const prevYear = now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(); + + // Calculate the adjusted day for the previous month + const lastDayOfPreviousMonth = getLastDayOfMonth(prevYear, prevMonth); + const adjustedPreviousMonthDay = Math.min(subscriptionDay, lastDayOfPreviousMonth); + + // Return the adjusted previous month date + return new Date(prevYear, prevMonth, adjustedPreviousMonthDay); + } else { + return currentMonthSubscriptionDate; + } + } else if (billing.period === "monthly" && billing.periodStart) { + // For monthly plans with a periodStart, use that date + return new Date(billing.periodStart); + } else { + // For other plans, use the periodStart from billing + if (!billing.periodStart) { + throw new Error("billing period start is not set"); + } + return new Date(billing.periodStart); + } +}; diff --git a/apps/web/lib/utils/client-ip.test.ts b/apps/web/lib/utils/client-ip.test.ts new file mode 100644 index 000000000000..1ddfe9cc3868 --- /dev/null +++ b/apps/web/lib/utils/client-ip.test.ts @@ -0,0 +1,82 @@ +import * as nextHeaders from "next/headers"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getClientIpFromHeaders } from "./client-ip"; + +// Mock next/headers +declare module "next/headers" { + export function headers(): any; +} + +vi.mock("next/headers", () => ({ + headers: vi.fn(), +})); + +const mockHeaders = (headerMap: Record) => { + vi.mocked(nextHeaders.headers).mockReturnValue({ + get: (key: string) => headerMap[key.toLowerCase()] ?? undefined, + }); +}; + +describe("getClientIpFromHeaders", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns cf-connecting-ip if present", async () => { + mockHeaders({ "cf-connecting-ip": "1.2.3.4" }); + const ip = await getClientIpFromHeaders(); + expect(ip).toBe("1.2.3.4"); + }); + + test("returns first x-forwarded-for if cf-connecting-ip is missing", async () => { + mockHeaders({ "x-forwarded-for": "5.6.7.8, 9.10.11.12" }); + const ip = await getClientIpFromHeaders(); + expect(ip).toBe("5.6.7.8"); + }); + + test("returns x-real-ip if cf-connecting-ip and x-forwarded-for are missing", async () => { + mockHeaders({ "x-real-ip": "13.14.15.16" }); + const ip = await getClientIpFromHeaders(); + expect(ip).toBe("13.14.15.16"); + }); + + test("returns ::1 if no headers are present", async () => { + mockHeaders({}); + const ip = await getClientIpFromHeaders(); + expect(ip).toBe("::1"); + }); + + test("trims whitespace in x-forwarded-for", async () => { + mockHeaders({ "x-forwarded-for": " 21.22.23.24 , 25.26.27.28" }); + const ip = await getClientIpFromHeaders(); + expect(ip).toBe("21.22.23.24"); + }); + + test("getClientIpFromHeaders should return the value of the cf-connecting-ip header when it is present", async () => { + const testIp = "123.123.123.123"; + + vi.mocked(nextHeaders.headers).mockReturnValue({ + get: vi.fn().mockImplementation((headerName: string) => { + if (headerName === "cf-connecting-ip") { + return testIp; + } + return null; + }), + } as any); + + const result = await getClientIpFromHeaders(); + + expect(result).toBe(testIp); + expect(nextHeaders.headers).toHaveBeenCalled(); + }); + + test("getClientIpFromHeaders should handle errors when headers() throws an exception", async () => { + vi.mocked(nextHeaders.headers).mockImplementation(() => { + throw new Error("Failed to get headers"); + }); + + const result = await getClientIpFromHeaders(); + + expect(result).toBe("::1"); + }); +}); diff --git a/apps/web/lib/utils/client-ip.ts b/apps/web/lib/utils/client-ip.ts new file mode 100644 index 000000000000..0cefef5b48e5 --- /dev/null +++ b/apps/web/lib/utils/client-ip.ts @@ -0,0 +1,22 @@ +import { headers } from "next/headers"; +import { logger } from "@formbricks/logger"; + +export async function getClientIpFromHeaders(): Promise { + let headersList: Headers; + try { + headersList = await headers(); + } catch (e) { + logger.error(e, "Failed to get headers in getClientIpFromHeaders"); + return "::1"; + } + + // Try common proxy headers first + const cfConnectingIp = headersList.get("cf-connecting-ip"); + if (cfConnectingIp) return cfConnectingIp; + + const xForwardedFor = headersList.get("x-forwarded-for"); + if (xForwardedFor) return xForwardedFor.split(",")[0].trim(); + + // Fallback (may be undefined or localhost in dev) + return headersList.get("x-real-ip") || "::1"; // NOSONAR - We want to fallback when the result is "" +} diff --git a/apps/web/lib/utils/colors.test.ts b/apps/web/lib/utils/colors.test.ts new file mode 100644 index 000000000000..908423fd8fe9 --- /dev/null +++ b/apps/web/lib/utils/colors.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test, vi } from "vitest"; +import { hexToRGBA, isLight, mixColor } from "./colors"; + +describe("Color utilities", () => { + describe("hexToRGBA", () => { + test("should convert hex to rgba", () => { + expect(hexToRGBA("#000000", 1)).toBe("rgba(0, 0, 0, 1)"); + expect(hexToRGBA("#FFFFFF", 0.5)).toBe("rgba(255, 255, 255, 0.5)"); + expect(hexToRGBA("#FF0000", 0.8)).toBe("rgba(255, 0, 0, 0.8)"); + }); + + test("should convert shorthand hex to rgba", () => { + expect(hexToRGBA("#000", 1)).toBe("rgba(0, 0, 0, 1)"); + expect(hexToRGBA("#FFF", 0.5)).toBe("rgba(255, 255, 255, 0.5)"); + expect(hexToRGBA("#F00", 0.8)).toBe("rgba(255, 0, 0, 0.8)"); + }); + + test("should handle hex without # prefix", () => { + expect(hexToRGBA("000000", 1)).toBe("rgba(0, 0, 0, 1)"); + expect(hexToRGBA("FFFFFF", 0.5)).toBe("rgba(255, 255, 255, 0.5)"); + }); + + test("should return undefined for undefined or empty input", () => { + expect(hexToRGBA(undefined, 1)).toBeUndefined(); + expect(hexToRGBA("", 0.5)).toBeUndefined(); + }); + + test("should return empty string for invalid hex", () => { + expect(hexToRGBA("invalid", 1)).toBe(""); + }); + }); + + describe("mixColor", () => { + test("should mix two colors with given weight", () => { + expect(mixColor("#000000", "#FFFFFF", 0.5)).toBe("#808080"); + expect(mixColor("#FF0000", "#0000FF", 0.5)).toBe("#800080"); + expect(mixColor("#FF0000", "#00FF00", 0.75)).toBe("#40bf00"); + }); + + test("should handle edge cases", () => { + expect(mixColor("#000000", "#FFFFFF", 0)).toBe("#000000"); + expect(mixColor("#000000", "#FFFFFF", 1)).toBe("#ffffff"); + }); + }); + + describe("isLight", () => { + test("should determine if a color is light", () => { + expect(isLight("#FFFFFF")).toBe(true); + expect(isLight("#EEEEEE")).toBe(true); + expect(isLight("#FFFF00")).toBe(true); + }); + + test("should determine if a color is dark", () => { + expect(isLight("#000000")).toBe(false); + expect(isLight("#333333")).toBe(false); + expect(isLight("#0000FF")).toBe(false); + }); + + test("should handle shorthand hex colors", () => { + expect(isLight("#FFF")).toBe(true); + expect(isLight("#000")).toBe(false); + expect(isLight("#F00")).toBe(false); + }); + + test("should throw error for invalid colors", () => { + expect(() => isLight("invalid-color")).toThrow("Invalid color"); + expect(() => isLight("#1")).toThrow("Invalid color"); + }); + }); +}); diff --git a/packages/lib/utils/colors.ts b/apps/web/lib/utils/colors.ts similarity index 95% rename from packages/lib/utils/colors.ts rename to apps/web/lib/utils/colors.ts index 5f8ba6d34313..3b1e6d0099ba 100644 --- a/packages/lib/utils/colors.ts +++ b/apps/web/lib/utils/colors.ts @@ -1,4 +1,4 @@ -const hexToRGBA = (hex: string | undefined, opacity: number): string | undefined => { +export const hexToRGBA = (hex: string | undefined, opacity: number): string | undefined => { // return undefined if hex is undefined, this is important for adding the default values to the CSS variables // TODO: find a better way to handle this if (!hex || hex === "") return undefined; diff --git a/apps/web/lib/utils/contact.test.ts b/apps/web/lib/utils/contact.test.ts new file mode 100644 index 000000000000..ffee4e913b44 --- /dev/null +++ b/apps/web/lib/utils/contact.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "vitest"; +import { TContactAttributes } from "@formbricks/types/contact-attribute"; +import { TResponseContact } from "@formbricks/types/responses"; +import { getContactIdentifier } from "./contact"; + +describe("getContactIdentifier", () => { + test("should return email from contactAttributes when available", () => { + const contactAttributes: TContactAttributes = { + email: "test@example.com", + }; + const contact: TResponseContact = { + id: "contact1", + userId: "user123", + }; + + const result = getContactIdentifier(contact, contactAttributes); + expect(result).toBe("test@example.com"); + }); + + test("should return userId from contact when email is not available", () => { + const contactAttributes: TContactAttributes = {}; + const contact: TResponseContact = { + id: "contact2", + userId: "user123", + }; + + const result = getContactIdentifier(contact, contactAttributes); + expect(result).toBe("user123"); + }); + + test("should return empty string when both email and userId are not available", () => { + const contactAttributes: TContactAttributes = {}; + const contact: TResponseContact = { + id: "contact3", + }; + + const result = getContactIdentifier(contact, contactAttributes); + expect(result).toBe(""); + }); + + test("should return empty string when both contact and contactAttributes are null", () => { + const result = getContactIdentifier(null, null); + expect(result).toBe(""); + }); + + test("should return userId when contactAttributes is null", () => { + const contact: TResponseContact = { + id: "contact4", + userId: "user123", + }; + + const result = getContactIdentifier(contact, null); + expect(result).toBe("user123"); + }); + + test("should return email when contact is null", () => { + const contactAttributes: TContactAttributes = { + email: "test@example.com", + }; + + const result = getContactIdentifier(null, contactAttributes); + expect(result).toBe("test@example.com"); + }); +}); diff --git a/packages/lib/utils/contact.ts b/apps/web/lib/utils/contact.ts similarity index 100% rename from packages/lib/utils/contact.ts rename to apps/web/lib/utils/contact.ts diff --git a/apps/web/lib/utils/datetime.test.ts b/apps/web/lib/utils/datetime.test.ts new file mode 100644 index 000000000000..635f6306db1b --- /dev/null +++ b/apps/web/lib/utils/datetime.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test, vi } from "vitest"; +import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime"; + +describe("datetime utils", () => { + test("diffInDays calculates the difference in days between two dates", () => { + const date1 = new Date("2025-05-01"); + const date2 = new Date("2025-05-06"); + expect(diffInDays(date1, date2)).toBe(5); + }); + + test("formatDateWithOrdinal formats a date with ordinal suffix", () => { + // Create a date that's fixed to May 6, 2025 at noon UTC + // Using noon ensures the date won't change in most timezones + const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0)); + + // Test the function + expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025"); + }); + + test("isValidDateString validates correct date strings", () => { + expect(isValidDateString("2025-05-06")).toBeTruthy(); + expect(isValidDateString("06-05-2025")).toBeTruthy(); + expect(isValidDateString("2025/05/06")).toBeFalsy(); + expect(isValidDateString("invalid-date")).toBeFalsy(); + }); + + test("getFormattedDateTimeString formats a date-time string correctly", () => { + const date = new Date("2025-05-06T14:30:00"); + expect(getFormattedDateTimeString(date)).toBe("2025-05-06 14:30:00"); + }); +}); diff --git a/packages/lib/utils/datetime.ts b/apps/web/lib/utils/datetime.ts similarity index 100% rename from packages/lib/utils/datetime.ts rename to apps/web/lib/utils/datetime.ts diff --git a/apps/web/lib/utils/email.test.ts b/apps/web/lib/utils/email.test.ts new file mode 100644 index 000000000000..e5bf58c5312e --- /dev/null +++ b/apps/web/lib/utils/email.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; +import { isValidEmail } from "./email"; + +describe("isValidEmail", () => { + test("validates correct email formats", () => { + // Valid email addresses + expect(isValidEmail("test@example.com")).toBe(true); + expect(isValidEmail("test.user@example.com")).toBe(true); + expect(isValidEmail("test+user@example.com")).toBe(true); + expect(isValidEmail("test_user@example.com")).toBe(true); + expect(isValidEmail("test-user@example.com")).toBe(true); + expect(isValidEmail("test'user@example.com")).toBe(true); + expect(isValidEmail("test@example.co.uk")).toBe(true); + expect(isValidEmail("test@subdomain.example.com")).toBe(true); + }); + + test("rejects invalid email formats", () => { + // Missing @ symbol + expect(isValidEmail("testexample.com")).toBe(false); + + // Multiple @ symbols + expect(isValidEmail("test@example@com")).toBe(false); + + // Invalid characters + expect(isValidEmail("test user@example.com")).toBe(false); + expect(isValidEmail("test<>user@example.com")).toBe(false); + + // Missing domain + expect(isValidEmail("test@")).toBe(false); + + // Missing local part + expect(isValidEmail("@example.com")).toBe(false); + + // Starting or ending with dots in local part + expect(isValidEmail(".test@example.com")).toBe(false); + expect(isValidEmail("test.@example.com")).toBe(false); + + // Consecutive dots + expect(isValidEmail("test..user@example.com")).toBe(false); + + // Empty string + expect(isValidEmail("")).toBe(false); + + // Only whitespace + expect(isValidEmail(" ")).toBe(false); + + // TLD too short + expect(isValidEmail("test@example.c")).toBe(false); + }); +}); diff --git a/apps/web/lib/utils/email.ts b/apps/web/lib/utils/email.ts new file mode 100644 index 000000000000..0efb5a72f4ae --- /dev/null +++ b/apps/web/lib/utils/email.ts @@ -0,0 +1,5 @@ +export const isValidEmail = (email: string): boolean => { + // This regex comes from zod + const regex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i; + return regex.test(email); +}; diff --git a/apps/web/lib/utils/file-conversion.test.ts b/apps/web/lib/utils/file-conversion.test.ts new file mode 100644 index 000000000000..8f1d149a6f87 --- /dev/null +++ b/apps/web/lib/utils/file-conversion.test.ts @@ -0,0 +1,63 @@ +import { AsyncParser } from "@json2csv/node"; +import { describe, expect, test, vi } from "vitest"; +import * as xlsx from "xlsx"; +import { logger } from "@formbricks/logger"; +import { convertToCsv, convertToXlsxBuffer } from "./file-conversion"; + +// Mock the logger to capture error calls +vi.mock("@formbricks/logger", () => ({ + logger: { error: vi.fn() }, +})); + +describe("convertToCsv", () => { + const fields = ["name", "age"]; + const data = [ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + ]; + + test("should convert JSON array to CSV string with header", async () => { + const csv = await convertToCsv(fields, data); + const lines = csv.trim().split("\n"); + // json2csv quotes headers by default + expect(lines[0]).toBe('"name","age"'); + expect(lines[1]).toBe('"Alice",30'); + expect(lines[2]).toBe('"Bob",25'); + }); + + test("should log an error and throw when conversion fails", async () => { + const parseSpy = vi.spyOn(AsyncParser.prototype, "parse").mockImplementation( + () => + ({ + promise: () => Promise.reject(new Error("Test parse error")), + }) as any + ); + + await expect(convertToCsv(fields, data)).rejects.toThrow("Failed to convert to CSV"); + expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Failed to convert to CSV"); + + parseSpy.mockRestore(); + }); +}); + +describe("convertToXlsxBuffer", () => { + const fields = ["name", "age"]; + const data = [ + { name: "Alice", age: 30 }, + { name: "Bob", age: 25 }, + ]; + + test("should convert JSON array to XLSX buffer and preserve data", () => { + const buffer = convertToXlsxBuffer(fields, data); + const wb = xlsx.read(buffer, { type: "buffer" }); + const sheet = wb.Sheets["Sheet1"]; + // Skip header row (range:1) and remove internal row metadata + const raw = xlsx.utils.sheet_to_json>(sheet, { + header: fields, + defval: "", + range: 1, + }); + const cleaned = raw.map(({ __rowNum__, ...rest }) => rest); + expect(cleaned).toEqual(data); + }); +}); diff --git a/packages/lib/utils/fileConversion.ts b/apps/web/lib/utils/file-conversion.ts similarity index 100% rename from packages/lib/utils/fileConversion.ts rename to apps/web/lib/utils/file-conversion.ts diff --git a/apps/web/lib/utils/headers.test.ts b/apps/web/lib/utils/headers.test.ts new file mode 100644 index 000000000000..d213eccb1689 --- /dev/null +++ b/apps/web/lib/utils/headers.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; +import { deviceType } from "./headers"; + +describe("deviceType", () => { + test("should return 'phone' for mobile user agents", () => { + const mobileUserAgents = [ + "Mozilla/5.0 (Linux; Android 10; SM-G960F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (BlackBerry; U; BlackBerry 9900; en) AppleWebKit/534.11+ (KHTML, like Gecko) Version/7.1.0.346 Mobile Safari/534.11+", + "Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch)", + "Mozilla/5.0 (iPod; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1", + "Opera/9.80 (Android; Opera Mini/36.2.2254/119.132; U; id) Presto/2.12.423 Version/12.16", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59 (Edition Campaign WPDesktop)", + ]; + + mobileUserAgents.forEach((userAgent) => { + expect(deviceType(userAgent)).toBe("phone"); + }); + }); + + test("should return 'desktop' for non-mobile user agents", () => { + const desktopUserAgents = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0", + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0", + "", + ]; + + desktopUserAgents.forEach((userAgent) => { + expect(deviceType(userAgent)).toBe("desktop"); + }); + }); +}); diff --git a/packages/lib/utils/headers.ts b/apps/web/lib/utils/headers.ts similarity index 100% rename from packages/lib/utils/headers.ts rename to apps/web/lib/utils/headers.ts diff --git a/apps/web/lib/utils/helper.test.ts b/apps/web/lib/utils/helper.test.ts new file mode 100644 index 000000000000..4a4364bbe62a --- /dev/null +++ b/apps/web/lib/utils/helper.test.ts @@ -0,0 +1,743 @@ +import * as services from "@/lib/utils/services"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { + getEnvironmentIdFromInsightId, + getEnvironmentIdFromResponseId, + getEnvironmentIdFromSegmentId, + getEnvironmentIdFromSurveyId, + getEnvironmentIdFromTagId, + getFormattedErrorMessage, + getOrganizationIdFromActionClassId, + getOrganizationIdFromApiKeyId, + getOrganizationIdFromContactId, + getOrganizationIdFromDocumentId, + getOrganizationIdFromEnvironmentId, + getOrganizationIdFromInsightId, + getOrganizationIdFromIntegrationId, + getOrganizationIdFromInviteId, + getOrganizationIdFromLanguageId, + getOrganizationIdFromProjectId, + getOrganizationIdFromResponseId, + getOrganizationIdFromSegmentId, + getOrganizationIdFromSurveyId, + getOrganizationIdFromTagId, + getOrganizationIdFromTeamId, + getOrganizationIdFromWebhookId, + getProductIdFromContactId, + getProjectIdFromActionClassId, + getProjectIdFromContactId, + getProjectIdFromDocumentId, + getProjectIdFromEnvironmentId, + getProjectIdFromInsightId, + getProjectIdFromIntegrationId, + getProjectIdFromLanguageId, + getProjectIdFromResponseId, + getProjectIdFromSegmentId, + getProjectIdFromSurveyId, + getProjectIdFromTagId, + getProjectIdFromWebhookId, + isStringMatch, +} from "./helper"; + +// Mock all service functions +vi.mock("@/lib/utils/services", () => ({ + getProject: vi.fn(), + getEnvironment: vi.fn(), + getSurvey: vi.fn(), + getResponse: vi.fn(), + getContact: vi.fn(), + + getSegment: vi.fn(), + getActionClass: vi.fn(), + getIntegration: vi.fn(), + getWebhook: vi.fn(), + getApiKey: vi.fn(), + getInvite: vi.fn(), + getLanguage: vi.fn(), + getTeam: vi.fn(), + getInsight: vi.fn(), + getDocument: vi.fn(), + getTag: vi.fn(), +})); + +describe("Helper Utilities", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getFormattedErrorMessage", () => { + test("returns server error when present", () => { + const result = { + serverError: "Internal server error occurred", + validationErrors: {}, + }; + expect(getFormattedErrorMessage(result)).toBe("Internal server error occurred"); + }); + + test("formats validation errors correctly with _errors", () => { + const result = { + validationErrors: { + _errors: ["Invalid input", "Missing required field"], + }, + }; + expect(getFormattedErrorMessage(result)).toBe("Invalid input, Missing required field"); + }); + + test("formats validation errors for specific fields", () => { + const result = { + validationErrors: { + name: { _errors: ["Name is required"] }, + email: { _errors: ["Email is invalid"] }, + }, + }; + expect(getFormattedErrorMessage(result)).toBe("nameName is required\nemailEmail is invalid"); + }); + + test("returns empty string for undefined errors", () => { + const result = { validationErrors: undefined }; + expect(getFormattedErrorMessage(result)).toBe(""); + }); + }); + + describe("Organization ID retrieval functions", () => { + test("getOrganizationIdFromProjectId returns organization ID when project exists", async () => { + vi.mocked(services.getProject).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromProjectId("project1"); + expect(orgId).toBe("org1"); + expect(services.getProject).toHaveBeenCalledWith("project1"); + }); + + test("getOrganizationIdFromProjectId throws error when project not found", async () => { + vi.mocked(services.getProject).mockResolvedValueOnce(null); + + await expect(getOrganizationIdFromProjectId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + expect(services.getProject).toHaveBeenCalledWith("nonexistent"); + }); + + test("getOrganizationIdFromEnvironmentId returns organization ID through project", async () => { + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + vi.mocked(services.getProject).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromEnvironmentId("env1"); + expect(orgId).toBe("org1"); + expect(services.getEnvironment).toHaveBeenCalledWith("env1"); + expect(services.getProject).toHaveBeenCalledWith("project1"); + }); + + test("getOrganizationIdFromEnvironmentId throws error when environment not found", async () => { + vi.mocked(services.getEnvironment).mockResolvedValueOnce(null); + + await expect(getOrganizationIdFromEnvironmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getOrganizationIdFromSurveyId returns organization ID through environment and project", async () => { + vi.mocked(services.getSurvey).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + vi.mocked(services.getProject).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromSurveyId("survey1"); + expect(orgId).toBe("org1"); + expect(services.getSurvey).toHaveBeenCalledWith("survey1"); + expect(services.getEnvironment).toHaveBeenCalledWith("env1"); + expect(services.getProject).toHaveBeenCalledWith("project1"); + }); + + test("getOrganizationIdFromSurveyId throws error when survey not found", async () => { + vi.mocked(services.getSurvey).mockResolvedValueOnce(null); + + await expect(getOrganizationIdFromSurveyId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getOrganizationIdFromResponseId returns organization ID through the response hierarchy", async () => { + vi.mocked(services.getResponse).mockResolvedValueOnce({ + surveyId: "survey1", + }); + vi.mocked(services.getSurvey).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + vi.mocked(services.getProject).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromResponseId("response1"); + expect(orgId).toBe("org1"); + }); + + test("getOrganizationIdFromResponseId throws error when response not found", async () => { + vi.mocked(services.getResponse).mockResolvedValueOnce(null); + + await expect(getOrganizationIdFromResponseId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getOrganizationIdFromContactId returns organization ID correctly", async () => { + vi.mocked(services.getContact).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + vi.mocked(services.getProject).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromContactId("contact1"); + expect(orgId).toBe("org1"); + }); + + test("getOrganizationIdFromContactId throws error when contact not found", async () => { + vi.mocked(services.getContact).mockResolvedValueOnce(null); + + await expect(getOrganizationIdFromContactId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getOrganizationIdFromTagId returns organization ID correctly", async () => { + vi.mocked(services.getTag).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + vi.mocked(services.getProject).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromTagId("tag1"); + expect(orgId).toBe("org1"); + }); + + test("getOrganizationIdFromTagId throws error when tag not found", async () => { + vi.mocked(services.getTag).mockResolvedValueOnce(null); + + await expect(getOrganizationIdFromTagId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getOrganizationIdFromSegmentId returns organization ID correctly", async () => { + vi.mocked(services.getSegment).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + vi.mocked(services.getProject).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromSegmentId("segment1"); + expect(orgId).toBe("org1"); + }); + + test("getOrganizationIdFromSegmentId throws error when segment not found", async () => { + vi.mocked(services.getSegment).mockResolvedValueOnce(null); + await expect(getOrganizationIdFromSegmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getOrganizationIdFromActionClassId returns organization ID correctly", async () => { + vi.mocked(services.getActionClass).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + vi.mocked(services.getProject).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromActionClassId("action1"); + expect(orgId).toBe("org1"); + }); + + test("getOrganizationIdFromActionClassId throws error when actionClass not found", async () => { + vi.mocked(services.getActionClass).mockResolvedValueOnce(null); + await expect(getOrganizationIdFromActionClassId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getOrganizationIdFromIntegrationId returns organization ID correctly", async () => { + vi.mocked(services.getIntegration).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + vi.mocked(services.getProject).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromIntegrationId("integration1"); + expect(orgId).toBe("org1"); + }); + + test("getOrganizationIdFromIntegrationId throws error when integration not found", async () => { + vi.mocked(services.getIntegration).mockResolvedValueOnce(null); + await expect(getOrganizationIdFromIntegrationId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getOrganizationIdFromWebhookId returns organization ID correctly", async () => { + vi.mocked(services.getWebhook).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + vi.mocked(services.getProject).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromWebhookId("webhook1"); + expect(orgId).toBe("org1"); + }); + + test("getOrganizationIdFromWebhookId throws error when webhook not found", async () => { + vi.mocked(services.getWebhook).mockResolvedValueOnce(null); + await expect(getOrganizationIdFromWebhookId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getOrganizationIdFromApiKeyId returns organization ID directly", async () => { + vi.mocked(services.getApiKey).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromApiKeyId("apikey1"); + expect(orgId).toBe("org1"); + }); + + test("getOrganizationIdFromApiKeyId throws error when apiKey not found", async () => { + vi.mocked(services.getApiKey).mockResolvedValueOnce(null); + await expect(getOrganizationIdFromApiKeyId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getOrganizationIdFromInviteId returns organization ID directly", async () => { + vi.mocked(services.getInvite).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromInviteId("invite1"); + expect(orgId).toBe("org1"); + }); + + test("getOrganizationIdFromInviteId throws error when invite not found", async () => { + vi.mocked(services.getInvite).mockResolvedValueOnce(null); + await expect(getOrganizationIdFromInviteId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getOrganizationIdFromLanguageId returns organization ID correctly", async () => { + vi.mocked(services.getLanguage).mockResolvedValueOnce({ + projectId: "project1", + }); + vi.mocked(services.getProject).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromLanguageId("lang1"); + expect(orgId).toBe("org1"); + }); + + test("getOrganizationIdFromLanguageId throws error when language not found", async () => { + vi.mocked(services.getLanguage).mockResolvedValueOnce(undefined as unknown as any); + await expect(getOrganizationIdFromLanguageId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getOrganizationIdFromTeamId returns organization ID directly", async () => { + vi.mocked(services.getTeam).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromTeamId("team1"); + expect(orgId).toBe("org1"); + }); + + test("getOrganizationIdFromTeamId throws error when team not found", async () => { + vi.mocked(services.getTeam).mockResolvedValueOnce(null); + await expect(getOrganizationIdFromTeamId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getOrganizationIdFromInsightId returns organization ID correctly", async () => { + vi.mocked(services.getInsight).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + vi.mocked(services.getProject).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromInsightId("insight1"); + expect(orgId).toBe("org1"); + }); + + test("getOrganizationIdFromInsightId throws error when insight not found", async () => { + vi.mocked(services.getInsight).mockResolvedValueOnce(null); + await expect(getOrganizationIdFromInsightId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getOrganizationIdFromDocumentId returns organization ID correctly", async () => { + vi.mocked(services.getDocument).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + vi.mocked(services.getProject).mockResolvedValueOnce({ + organizationId: "org1", + }); + + const orgId = await getOrganizationIdFromDocumentId("doc1"); + expect(orgId).toBe("org1"); + }); + + test("getOrganizationIdFromDocumentId throws error when document not found", async () => { + vi.mocked(services.getDocument).mockResolvedValueOnce(null); + await expect(getOrganizationIdFromDocumentId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + }); + + describe("Project ID retrieval functions", () => { + test("getProjectIdFromEnvironmentId returns project ID directly", async () => { + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + + const projectId = await getProjectIdFromEnvironmentId("env1"); + expect(projectId).toBe("project1"); + expect(services.getEnvironment).toHaveBeenCalledWith("env1"); + }); + + test("getProjectIdFromEnvironmentId throws error when environment not found", async () => { + vi.mocked(services.getEnvironment).mockResolvedValueOnce(null); + + await expect(getProjectIdFromEnvironmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getProjectIdFromSurveyId returns project ID through environment", async () => { + vi.mocked(services.getSurvey).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + + const projectId = await getProjectIdFromSurveyId("survey1"); + expect(projectId).toBe("project1"); + expect(services.getSurvey).toHaveBeenCalledWith("survey1"); + expect(services.getEnvironment).toHaveBeenCalledWith("env1"); + }); + + test("getProjectIdFromSurveyId throws error when survey not found", async () => { + vi.mocked(services.getSurvey).mockResolvedValueOnce(null); + await expect(getProjectIdFromSurveyId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getProjectIdFromContactId returns project ID correctly", async () => { + vi.mocked(services.getContact).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + + const projectId = await getProjectIdFromContactId("contact1"); + expect(projectId).toBe("project1"); + }); + + test("getProjectIdFromContactId throws error when contact not found", async () => { + vi.mocked(services.getContact).mockResolvedValueOnce(null); + await expect(getProjectIdFromContactId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getProjectIdFromInsightId returns project ID correctly", async () => { + vi.mocked(services.getInsight).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + + const projectId = await getProjectIdFromInsightId("insight1"); + expect(projectId).toBe("project1"); + }); + + test("getProjectIdFromInsightId throws error when insight not found", async () => { + vi.mocked(services.getInsight).mockResolvedValueOnce(null); + await expect(getProjectIdFromInsightId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getProjectIdFromSegmentId returns project ID correctly", async () => { + vi.mocked(services.getSegment).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + + const projectId = await getProjectIdFromSegmentId("segment1"); + expect(projectId).toBe("project1"); + }); + + test("getProjectIdFromSegmentId throws error when segment not found", async () => { + vi.mocked(services.getSegment).mockResolvedValueOnce(null); + await expect(getProjectIdFromSegmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getProjectIdFromActionClassId returns project ID correctly", async () => { + vi.mocked(services.getActionClass).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + + const projectId = await getProjectIdFromActionClassId("action1"); + expect(projectId).toBe("project1"); + }); + + test("getProjectIdFromActionClassId throws error when actionClass not found", async () => { + vi.mocked(services.getActionClass).mockResolvedValueOnce(null); + await expect(getProjectIdFromActionClassId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getProjectIdFromTagId returns project ID correctly", async () => { + vi.mocked(services.getTag).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + + const projectId = await getProjectIdFromTagId("tag1"); + expect(projectId).toBe("project1"); + }); + + test("getProjectIdFromTagId throws error when tag not found", async () => { + vi.mocked(services.getTag).mockResolvedValueOnce(null); + await expect(getProjectIdFromTagId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getProjectIdFromLanguageId returns project ID directly", async () => { + vi.mocked(services.getLanguage).mockResolvedValueOnce({ + projectId: "project1", + }); + + const projectId = await getProjectIdFromLanguageId("lang1"); + expect(projectId).toBe("project1"); + }); + + test("getProjectIdFromLanguageId throws error when language not found", async () => { + vi.mocked(services.getLanguage).mockResolvedValueOnce(undefined as unknown as any); + await expect(getProjectIdFromLanguageId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getProjectIdFromResponseId returns project ID correctly", async () => { + vi.mocked(services.getResponse).mockResolvedValueOnce({ + surveyId: "survey1", + }); + vi.mocked(services.getSurvey).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + + const projectId = await getProjectIdFromResponseId("response1"); + expect(projectId).toBe("project1"); + }); + + test("getProjectIdFromResponseId throws error when response not found", async () => { + vi.mocked(services.getResponse).mockResolvedValueOnce(null); + await expect(getProjectIdFromResponseId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getProductIdFromContactId returns project ID correctly", async () => { + vi.mocked(services.getContact).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + + const projectId = await getProductIdFromContactId("contact1"); + expect(projectId).toBe("project1"); + }); + + test("getProductIdFromContactId throws error when contact not found", async () => { + vi.mocked(services.getContact).mockResolvedValueOnce(null); + await expect(getProductIdFromContactId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getProjectIdFromDocumentId returns project ID correctly", async () => { + vi.mocked(services.getDocument).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + + const projectId = await getProjectIdFromDocumentId("doc1"); + expect(projectId).toBe("project1"); + }); + + test("getProjectIdFromDocumentId throws error when document not found", async () => { + vi.mocked(services.getDocument).mockResolvedValueOnce(null); + await expect(getProjectIdFromDocumentId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getProjectIdFromIntegrationId returns project ID correctly", async () => { + vi.mocked(services.getIntegration).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + + const projectId = await getProjectIdFromIntegrationId("integration1"); + expect(projectId).toBe("project1"); + }); + + test("getProjectIdFromIntegrationId throws error when integration not found", async () => { + vi.mocked(services.getIntegration).mockResolvedValueOnce(null); + await expect(getProjectIdFromIntegrationId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getProjectIdFromWebhookId returns project ID correctly", async () => { + vi.mocked(services.getWebhook).mockResolvedValueOnce({ + environmentId: "env1", + }); + vi.mocked(services.getEnvironment).mockResolvedValueOnce({ + projectId: "project1", + }); + + const projectId = await getProjectIdFromWebhookId("webhook1"); + expect(projectId).toBe("project1"); + }); + + test("getProjectIdFromWebhookId throws error when webhook not found", async () => { + vi.mocked(services.getWebhook).mockResolvedValueOnce(null); + await expect(getProjectIdFromWebhookId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + }); + + describe("Environment ID retrieval functions", () => { + test("getEnvironmentIdFromSurveyId returns environment ID directly", async () => { + vi.mocked(services.getSurvey).mockResolvedValueOnce({ + environmentId: "env1", + }); + + const environmentId = await getEnvironmentIdFromSurveyId("survey1"); + expect(environmentId).toBe("env1"); + }); + + test("getEnvironmentIdFromSurveyId throws error when survey not found", async () => { + vi.mocked(services.getSurvey).mockResolvedValueOnce(null); + await expect(getEnvironmentIdFromSurveyId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getEnvironmentIdFromResponseId returns environment ID correctly", async () => { + vi.mocked(services.getResponse).mockResolvedValueOnce({ + surveyId: "survey1", + }); + vi.mocked(services.getSurvey).mockResolvedValueOnce({ + environmentId: "env1", + }); + + const environmentId = await getEnvironmentIdFromResponseId("response1"); + expect(environmentId).toBe("env1"); + }); + + test("getEnvironmentIdFromResponseId throws error when response not found", async () => { + vi.mocked(services.getResponse).mockResolvedValueOnce(null); + await expect(getEnvironmentIdFromResponseId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getEnvironmentIdFromInsightId returns environment ID directly", async () => { + vi.mocked(services.getInsight).mockResolvedValueOnce({ + environmentId: "env1", + }); + + const environmentId = await getEnvironmentIdFromInsightId("insight1"); + expect(environmentId).toBe("env1"); + }); + + test("getEnvironmentIdFromInsightId throws error when insight not found", async () => { + vi.mocked(services.getInsight).mockResolvedValueOnce(null); + await expect(getEnvironmentIdFromInsightId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getEnvironmentIdFromSegmentId returns environment ID directly", async () => { + vi.mocked(services.getSegment).mockResolvedValueOnce({ + environmentId: "env1", + }); + + const environmentId = await getEnvironmentIdFromSegmentId("segment1"); + expect(environmentId).toBe("env1"); + }); + + test("getEnvironmentIdFromSegmentId throws error when segment not found", async () => { + vi.mocked(services.getSegment).mockResolvedValueOnce(null); + await expect(getEnvironmentIdFromSegmentId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + + test("getEnvironmentIdFromTagId returns environment ID directly", async () => { + vi.mocked(services.getTag).mockResolvedValueOnce({ + environmentId: "env1", + }); + + const environmentId = await getEnvironmentIdFromTagId("tag1"); + expect(environmentId).toBe("env1"); + }); + + test("getEnvironmentIdFromTagId throws error when tag not found", async () => { + vi.mocked(services.getTag).mockResolvedValueOnce(null); + await expect(getEnvironmentIdFromTagId("nonexistent")).rejects.toThrow(ResourceNotFoundError); + }); + }); + + describe("isStringMatch", () => { + test("returns true for exact matches", () => { + expect(isStringMatch("test", "test")).toBe(true); + }); + + test("returns true for case-insensitive matches", () => { + expect(isStringMatch("TEST", "test")).toBe(true); + expect(isStringMatch("test", "TEST")).toBe(true); + }); + + test("returns true for matches with spaces", () => { + expect(isStringMatch("test case", "testcase")).toBe(true); + expect(isStringMatch("testcase", "test case")).toBe(true); + }); + + test("returns true for matches with underscores", () => { + expect(isStringMatch("test_case", "testcase")).toBe(true); + expect(isStringMatch("testcase", "test_case")).toBe(true); + }); + + test("returns true for matches with dashes", () => { + expect(isStringMatch("test-case", "testcase")).toBe(true); + expect(isStringMatch("testcase", "test-case")).toBe(true); + }); + + test("returns true for partial matches", () => { + expect(isStringMatch("test", "testing")).toBe(true); + }); + + test("returns false for non-matches", () => { + expect(isStringMatch("test", "other")).toBe(false); + }); + }); +}); diff --git a/apps/web/lib/utils/helper.ts b/apps/web/lib/utils/helper.ts index 6b54561681b4..57e1c6b3549d 100644 --- a/apps/web/lib/utils/helper.ts +++ b/apps/web/lib/utils/helper.ts @@ -10,7 +10,6 @@ import { getLanguage, getProject, getResponse, - getResponseNote, getSegment, getSurvey, getTag, @@ -104,15 +103,6 @@ export const getOrganizationIdFromTagId = async (tagId: string) => { return await getOrganizationIdFromEnvironmentId(tag.environmentId); }; -export const getOrganizationIdFromResponseNoteId = async (responseNoteId: string) => { - const responseNote = await getResponseNote(responseNoteId); - if (!responseNote) { - throw new ResourceNotFoundError("responseNote", responseNoteId); - } - - return await getOrganizationIdFromResponseId(responseNote.responseId); -}; - export const getOrganizationIdFromSegmentId = async (segmentId: string) => { const segment = await getSegment(segmentId); if (!segment) { @@ -276,15 +266,6 @@ export const getProjectIdFromResponseId = async (responseId: string) => { return await getProjectIdFromSurveyId(response.surveyId); }; -export const getProjectIdFromResponseNoteId = async (responseNoteId: string) => { - const responseNote = await getResponseNote(responseNoteId); - if (!responseNote) { - throw new ResourceNotFoundError("responseNote", responseNoteId); - } - - return await getProjectIdFromResponseId(responseNote.responseId); -}; - export const getProductIdFromContactId = async (contactId: string) => { const contact = await getContact(contactId); if (!contact) { diff --git a/packages/lib/utils/hooks/useClickOutside.ts b/apps/web/lib/utils/hooks/useClickOutside.ts similarity index 100% rename from packages/lib/utils/hooks/useClickOutside.ts rename to apps/web/lib/utils/hooks/useClickOutside.ts diff --git a/packages/lib/utils/hooks/useSyncScroll.ts b/apps/web/lib/utils/hooks/useSyncScroll.ts similarity index 100% rename from packages/lib/utils/hooks/useSyncScroll.ts rename to apps/web/lib/utils/hooks/useSyncScroll.ts diff --git a/apps/web/lib/utils/locale.test.ts b/apps/web/lib/utils/locale.test.ts new file mode 100644 index 000000000000..e4701f06e8ae --- /dev/null +++ b/apps/web/lib/utils/locale.test.ts @@ -0,0 +1,87 @@ +import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants"; +import * as nextHeaders from "next/headers"; +import { describe, expect, test, vi } from "vitest"; +import { findMatchingLocale } from "./locale"; + +// Mock the Next.js headers function +vi.mock("next/headers", () => ({ + headers: vi.fn(), +})); + +describe("locale", () => { + test("returns DEFAULT_LOCALE when Accept-Language header is missing", async () => { + // Set up the mock to return null for accept-language header + vi.mocked(nextHeaders.headers).mockReturnValue({ + get: vi.fn().mockReturnValue(null), + } as any); + + const result = await findMatchingLocale(); + + expect(result).toBe(DEFAULT_LOCALE); + expect(nextHeaders.headers).toHaveBeenCalled(); + }); + + test("returns exact match when available", async () => { + // Assuming we have 'en-US' in AVAILABLE_LOCALES + const testLocale = AVAILABLE_LOCALES[0]; + + vi.mocked(nextHeaders.headers).mockReturnValue({ + get: vi.fn().mockReturnValue(`${testLocale},fr-FR,de-DE`), + } as any); + + const result = await findMatchingLocale(); + + expect(result).toBe(testLocale); + expect(nextHeaders.headers).toHaveBeenCalled(); + }); + + test("returns normalized match when available", async () => { + // Assuming we have 'en-US' in AVAILABLE_LOCALES but not 'en-GB' + const availableLocale = AVAILABLE_LOCALES.find((locale) => locale.startsWith("en-")); + + if (!availableLocale) { + // Skip this test if no English locale is available + return; + } + + vi.mocked(nextHeaders.headers).mockReturnValue({ + get: vi.fn().mockReturnValue("en-US,fr-FR,de-DE"), + } as any); + + const result = await findMatchingLocale(); + + expect(result).toBe(availableLocale); + expect(nextHeaders.headers).toHaveBeenCalled(); + }); + + test("returns DEFAULT_LOCALE when no match is found", async () => { + // Use a locale that should not exist in AVAILABLE_LOCALES + vi.mocked(nextHeaders.headers).mockReturnValue({ + get: vi.fn().mockReturnValue("xx-XX,yy-YY"), + } as any); + + const result = await findMatchingLocale(); + + expect(result).toBe(DEFAULT_LOCALE); + expect(nextHeaders.headers).toHaveBeenCalled(); + }); + + test("handles multiple potential matches correctly", async () => { + // If we have multiple locales for the same language, it should return the first match + const germanLocale = AVAILABLE_LOCALES.find((locale) => locale.toLowerCase().startsWith("de")); + + if (!germanLocale) { + // Skip this test if no German locale is available + return; + } + + vi.mocked(nextHeaders.headers).mockReturnValue({ + get: vi.fn().mockReturnValue("de-DE,en-US,fr-FR"), + } as any); + + const result = await findMatchingLocale(); + + expect(result).toBe(germanLocale); + expect(nextHeaders.headers).toHaveBeenCalled(); + }); +}); diff --git a/packages/lib/utils/locale.ts b/apps/web/lib/utils/locale.ts similarity index 94% rename from packages/lib/utils/locale.ts rename to apps/web/lib/utils/locale.ts index 1e4c0d0637d4..63ebdc2cb097 100644 --- a/packages/lib/utils/locale.ts +++ b/apps/web/lib/utils/locale.ts @@ -1,6 +1,6 @@ +import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants"; import { headers } from "next/headers"; import { TUserLocale } from "@formbricks/types/user"; -import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "../constants"; export const findMatchingLocale = async (): Promise => { const headersList = await headers(); diff --git a/apps/web/lib/utils/logger-helpers.test.ts b/apps/web/lib/utils/logger-helpers.test.ts new file mode 100644 index 000000000000..f23f5fc93153 --- /dev/null +++ b/apps/web/lib/utils/logger-helpers.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { deepDiff, redactPII, sanitizeUrlForLogging } from "./logger-helpers"; + +// Patch redis multi before any imports +beforeEach(async () => { + const redis = (await import("@/modules/cache/redis")).default; + if ((redis?.multi as any)?.mockReturnValue) { + (redis?.multi as any).mockReturnValue({ + set: vi.fn(), + exec: vi.fn().mockResolvedValue([["OK"]]), + }); + } +}); + +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getIsAuditLogsEnabled: vi.fn().mockResolvedValue(true), +})); + +// Move all relevant mocks to the very top +vi.mock("@formbricks/logger", () => ({ + logger: { error: vi.fn() }, +})); +vi.mock("@/lib/utils/helper", () => ({ + getOrganizationIdFromEnvironmentId: vi.fn().mockResolvedValue("org-env-id"), +})); + +// Mocks +vi.mock("@/lib/constants", () => ({ + AUDIT_LOG_ENABLED: true, + AUDIT_LOG_GET_USER_IP: true, + ENCRYPTION_KEY: "testsecret", +})); +vi.mock("@/lib/utils/client-ip", () => ({ + getClientIpFromHeaders: vi.fn().mockResolvedValue("127.0.0.1"), +})); +vi.mock("@/modules/ee/audit-logs/lib/service", () => ({ + logAuditEvent: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/modules/cache/redis", () => ({ + default: { + watch: vi.fn().mockResolvedValue("OK"), + multi: vi.fn().mockReturnValue({ + set: vi.fn(), + exec: vi.fn().mockResolvedValue([["OK"]]), + }), + get: vi.fn().mockResolvedValue(null), + }, +})); + +// Set ENCRYPTION_KEY for all tests unless explicitly testing its absence +process.env.ENCRYPTION_KEY = "testsecret"; + +describe("redactPII", () => { + test("redacts sensitive keys in objects", () => { + const input = { email: "test@example.com", name: "John", foo: "bar" }; + expect(redactPII(input)).toEqual({ email: "********", name: "********", foo: "bar" }); + }); + test("redacts nested sensitive keys", () => { + const input = { user: { password: "secret", profile: { address: "123 St" } } }; + expect(redactPII(input)).toEqual({ user: { password: "********", profile: { address: "********" } } }); + }); + test("redacts arrays of objects", () => { + const input = [{ email: "a@b.com" }, { name: "Jane" }]; + expect(redactPII(input)).toEqual([{ email: "********" }, { name: "********" }]); + }); + test("returns primitives as is", () => { + expect(redactPII(42)).toBe(42); + expect(redactPII("foo")).toBe("foo"); + expect(redactPII(null)).toBe(null); + }); +}); + +describe("deepDiff", () => { + test("returns undefined for equal primitives", () => { + expect(deepDiff(1, 1)).toBeUndefined(); + expect(deepDiff("a", "a")).toBeUndefined(); + }); + test("returns new value for different primitives", () => { + expect(deepDiff(1, 2)).toBe(2); + expect(deepDiff("a", "b")).toBe("b"); + }); + test("returns diff for objects", () => { + expect(deepDiff({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); + expect(deepDiff({ a: 1, b: 2 }, { a: 1, b: 3 })).toEqual({ b: 3 }); + }); + test("returns diff for nested objects", () => { + expect(deepDiff({ a: { b: 1 } }, { a: { b: 2 } })).toEqual({ a: { b: 2 } }); + }); + test("returns diff for added/removed keys", () => { + expect(deepDiff({ a: 1 }, { a: 1, b: 2 })).toEqual({ b: 2 }); + // The following case should return undefined, as removed keys are not included in the diff + expect(deepDiff({ a: 1, b: 2 }, { a: 1 })).toBeUndefined(); + }); +}); + +describe("withAuditLogging", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + test("logs audit event for successful handler", async () => { + const handler = vi.fn().mockResolvedValue("ok"); + const { withAuditLogging } = await import("../../modules/ee/audit-logs/lib/handler"); + const wrapped = withAuditLogging("created", "survey", handler); + const ctx = { + user: { + id: "u1", + name: "Test User", + email: "test@example.com", + emailVerified: null, + imageUrl: null, + twoFactorEnabled: false, + identityProvider: "email" as const, + createdAt: new Date(), + updatedAt: new Date(), + role: null, + organizationId: "org1", + isActive: true, + lastLoginAt: null, + locale: "en-US" as const, + teams: [], + organizations: [], + objective: null, + notificationSettings: { + alert: {}, + }, + }, + organizationId: "org1", + ipAddress: "127.0.0.1", + auditLoggingCtx: { + ipAddress: "127.0.0.1", + organizationId: "org1", + }, + }; + const parsedInput = {}; + await wrapped({ ctx, parsedInput }); + vi.runAllTimers(); + expect(handler).toHaveBeenCalled(); + }); + test("logs audit event for failed handler and throws", async () => { + const handler = vi.fn().mockRejectedValue(new Error("fail")); + const { withAuditLogging } = await import("../../modules/ee/audit-logs/lib/handler"); + const wrapped = withAuditLogging("created", "survey", handler); + const ctx = { + user: { + id: "u1", + name: "Test User", + email: "test@example.com", + emailVerified: null, + imageUrl: null, + twoFactorEnabled: false, + identityProvider: "email" as const, + createdAt: new Date(), + updatedAt: new Date(), + role: null, + organizationId: "org1", + isActive: true, + lastLoginAt: null, + locale: "en-US" as const, + teams: [], + organizations: [], + objective: null, + notificationSettings: { + alert: {}, + }, + }, + organizationId: "org1", + ipAddress: "127.0.0.1", + auditLoggingCtx: { + ipAddress: "127.0.0.1", + organizationId: "org1", + }, + }; + const parsedInput = {}; + await expect(wrapped({ ctx, parsedInput })).rejects.toThrow("fail"); + vi.runAllTimers(); + expect(handler).toHaveBeenCalled(); + }); +}); + +describe("sanitizeUrlForLogging", () => { + test("returns sanitized URL with token", () => { + expect(sanitizeUrlForLogging("https://example.com?token=1234567890")).toBe( + "https://example.com/?token=********" + ); + }); + + test("returns sanitized URL with code", () => { + expect(sanitizeUrlForLogging("https://example.com?code=1234567890")).toBe( + "https://example.com/?code=********" + ); + }); + + test("returns sanitized URL with state", () => { + expect(sanitizeUrlForLogging("https://example.com?state=1234567890")).toBe( + "https://example.com/?state=********" + ); + }); + + test("returns sanitized URL with multiple keys", () => { + expect( + sanitizeUrlForLogging("https://example.com?token=1234567890&code=1234567890&state=1234567890") + ).toBe("https://example.com/?token=********&code=********&state=********"); + }); + + test("returns sanitized URL without query params", () => { + expect(sanitizeUrlForLogging("https://example.com")).toBe("https://example.com/"); + }); + + test("returns sanitized URL with invalid URL", () => { + expect(sanitizeUrlForLogging("not-a-valid-url")).toBe("[invalid-url]"); + }); +}); diff --git a/apps/web/lib/utils/logger-helpers.ts b/apps/web/lib/utils/logger-helpers.ts new file mode 100644 index 000000000000..bf24ed6f467d --- /dev/null +++ b/apps/web/lib/utils/logger-helpers.ts @@ -0,0 +1,121 @@ +import { isStringUrl } from "@/lib/utils/url"; + +const SENSITIVE_KEYS = [ + "email", + "name", + "password", + "access_token", + "refresh_token", + "id_token", + "twofactorsecret", + "backupcodes", + "session_state", + "provideraccountid", + "imageurl", + "identityprovideraccountid", + "locale", + "token", + "key", + "secret", + "code", + "address", + "phone", + "hashedkey", + "apikey", + "createdby", + "lastusedat", + "expiresat", + "acceptorid", + "creatorid", + "firstname", + "lastname", + "userid", + "attributes", + "pin", + "image", + "stripeCustomerId", + "fileName", + "state", +]; + +const URL_SENSITIVE_KEYS = ["token", "code", "state"]; + +/** + * Redacts sensitive data from the object by replacing the sensitive keys with "********". + * @param obj - The object to redact. + * @returns The object with the sensitive data redacted. + */ +export const redactPII = (obj: any, seen: WeakSet = new WeakSet()): any => { + if (obj instanceof Date) { + return obj.toISOString(); + } + + if (typeof obj === "string" && isStringUrl(obj)) { + return sanitizeUrlForLogging(obj); + } + + if (obj && typeof obj === "object") { + if (seen.has(obj)) return "[Circular]"; + seen.add(obj); + } + if (Array.isArray(obj)) { + return obj.map((v) => redactPII(v, seen)); + } + if (obj && typeof obj === "object") { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => { + if (SENSITIVE_KEYS.some((sensitiveKey) => key.toLowerCase() === sensitiveKey)) { + return [key, "********"]; + } + return [key, redactPII(value, seen)]; + }) + ); + } + return obj; +}; + +/** + * Computes the difference between two objects and returns the new object with the changes. + * @param oldObj - The old object. + * @param newObj - The new object. + * @returns The difference between the two objects. + */ +export const deepDiff = (oldObj: any, newObj: any): any => { + if (typeof oldObj !== "object" || typeof newObj !== "object" || oldObj === null || newObj === null) { + if (JSON.stringify(oldObj) !== JSON.stringify(newObj)) { + return newObj; + } + return undefined; + } + + const diff: Record = {}; + const keys = new Set([...Object.keys(oldObj ?? {}), ...Object.keys(newObj ?? {})]); + for (const key of keys) { + const valueDiff = deepDiff(oldObj?.[key], newObj?.[key]); + if (valueDiff !== undefined) { + diff[key] = valueDiff; + } + } + return Object.keys(diff).length > 0 ? diff : undefined; +}; + +/** + * Sanitizes a URL for logging by redacting sensitive parameters. + * @param url - The URL to sanitize. + * @returns The sanitized URL. + */ +export const sanitizeUrlForLogging = (url: string): string => { + try { + const urlObj = new URL(url); + + URL_SENSITIVE_KEYS.forEach((key) => { + if (urlObj.searchParams.has(key)) { + urlObj.searchParams.set(key, "********"); + } + }); + + return urlObj.origin + urlObj.pathname + (urlObj.search ? `${urlObj.search}` : ""); + } catch { + return "[invalid-url]"; + } +}; diff --git a/apps/web/lib/utils/promises.test.ts b/apps/web/lib/utils/promises.test.ts new file mode 100644 index 000000000000..80680a175934 --- /dev/null +++ b/apps/web/lib/utils/promises.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test, vi } from "vitest"; +import { delay, isFulfilled, isRejected } from "./promises"; + +describe("promises utilities", () => { + test("delay resolves after specified time", async () => { + const delayTime = 100; + + vi.useFakeTimers(); + const promise = delay(delayTime); + + vi.advanceTimersByTime(delayTime); + await promise; + + vi.useRealTimers(); + }); + + test("isFulfilled returns true for fulfilled promises", () => { + const fulfilledResult: PromiseSettledResult = { + status: "fulfilled", + value: "success", + }; + + expect(isFulfilled(fulfilledResult)).toBe(true); + + if (isFulfilled(fulfilledResult)) { + expect(fulfilledResult.value).toBe("success"); + } + }); + + test("isFulfilled returns false for rejected promises", () => { + const rejectedResult: PromiseSettledResult = { + status: "rejected", + reason: "error", + }; + + expect(isFulfilled(rejectedResult)).toBe(false); + }); + + test("isRejected returns true for rejected promises", () => { + const rejectedResult: PromiseSettledResult = { + status: "rejected", + reason: "error", + }; + + expect(isRejected(rejectedResult)).toBe(true); + + if (isRejected(rejectedResult)) { + expect(rejectedResult.reason).toBe("error"); + } + }); + + test("isRejected returns false for fulfilled promises", () => { + const fulfilledResult: PromiseSettledResult = { + status: "fulfilled", + value: "success", + }; + + expect(isRejected(fulfilledResult)).toBe(false); + }); + + test("delay can be used in actual timing scenarios", async () => { + const mockCallback = vi.fn(); + + setTimeout(mockCallback, 50); + await delay(100); + + expect(mockCallback).toHaveBeenCalled(); + }); + + test("type guard functions work correctly with Promise.allSettled", async () => { + const promises = [Promise.resolve("success"), Promise.reject("failure")]; + + const results = await Promise.allSettled(promises); + + const fulfilled = results.filter(isFulfilled); + const rejected = results.filter(isRejected); + + expect(fulfilled.length).toBe(1); + expect(fulfilled[0].value).toBe("success"); + + expect(rejected.length).toBe(1); + expect(rejected[0].reason).toBe("failure"); + }); +}); diff --git a/packages/lib/utils/promises.ts b/apps/web/lib/utils/promises.ts similarity index 100% rename from packages/lib/utils/promises.ts rename to apps/web/lib/utils/promises.ts diff --git a/apps/web/lib/utils/rate-limit.test.ts b/apps/web/lib/utils/rate-limit.test.ts new file mode 100644 index 000000000000..90c6bb10697f --- /dev/null +++ b/apps/web/lib/utils/rate-limit.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +describe("in-memory rate limiter", () => { + test("allows requests within limit and throws after limit", async () => { + const { rateLimit } = await import("./rate-limit"); + const limiterFn = rateLimit({ interval: 1, allowedPerInterval: 2 }); + await expect(limiterFn("a")).resolves.toBeUndefined(); + await expect(limiterFn("a")).resolves.toBeUndefined(); + await expect(limiterFn("a")).rejects.toThrow("Rate limit exceeded"); + }); + + test("separate tokens have separate counts", async () => { + const { rateLimit } = await import("./rate-limit"); + const limiterFn = rateLimit({ interval: 1, allowedPerInterval: 2 }); + await expect(limiterFn("x")).resolves.toBeUndefined(); + await expect(limiterFn("y")).resolves.toBeUndefined(); + await expect(limiterFn("x")).resolves.toBeUndefined(); + await expect(limiterFn("y")).resolves.toBeUndefined(); + }); +}); + +describe("redis rate limiter", () => { + beforeEach(async () => { + vi.resetModules(); + const constants = await vi.importActual("@/lib/constants"); + vi.doMock("@/lib/constants", () => ({ + ...constants, + REDIS_HTTP_URL: "http://redis", + })); + }); + + test("sets expire on first use and does not throw", async () => { + global.fetch = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ INCR: 1 }) }) + .mockResolvedValueOnce({ ok: true }); + const { rateLimit } = await import("./rate-limit"); + const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 }); + await expect(limiter("t")).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledWith("http://redis/INCR/t"); + expect(fetch).toHaveBeenCalledWith("http://redis/EXPIRE/t/10"); + }); + + test("does not throw when redis INCR response not ok", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ ok: false }); + const { rateLimit } = await import("./rate-limit"); + const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 }); + await expect(limiter("t")).resolves.toBeUndefined(); + }); + + test("throws when INCR exceeds limit", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ ok: true, json: async () => ({ INCR: 3 }) }); + const { rateLimit } = await import("./rate-limit"); + const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 }); + await expect(limiter("t")).rejects.toThrow("Rate limit exceeded for IP: t"); + }); +}); diff --git a/apps/web/lib/utils/rate-limit.ts b/apps/web/lib/utils/rate-limit.ts new file mode 100644 index 000000000000..3d07fa42822f --- /dev/null +++ b/apps/web/lib/utils/rate-limit.ts @@ -0,0 +1,54 @@ +import { REDIS_HTTP_URL } from "@/lib/constants"; +import { LRUCache } from "lru-cache"; +import { logger } from "@formbricks/logger"; + +interface Options { + interval: number; + allowedPerInterval: number; +} + +const inMemoryRateLimiter = (options: Options) => { + const tokenCache = new LRUCache({ + max: 1000, + ttl: options.interval * 1000, // converts to expected input of milliseconds + }); + + return async (token: string) => { + const currentUsage = tokenCache.get(token) ?? 0; + if (currentUsage >= options.allowedPerInterval) { + throw new Error("Rate limit exceeded"); + } + tokenCache.set(token, currentUsage + 1); + }; +}; + +const redisRateLimiter = (options: Options) => async (token: string) => { + try { + if (!REDIS_HTTP_URL) { + throw new Error("Redis HTTP URL is not set"); + } + const tokenCountResponse = await fetch(`${REDIS_HTTP_URL}/INCR/${token}`); + if (!tokenCountResponse.ok) { + logger.error({ tokenCountResponse }, "Failed to increment token count in Redis"); + return; + } + + const { INCR } = await tokenCountResponse.json(); + if (INCR === 1) { + await fetch(`${REDIS_HTTP_URL}/EXPIRE/${token}/${options.interval.toString()}`); + } else if (INCR > options.allowedPerInterval) { + throw new Error(); + } + } catch (e) { + logger.error({ error: e }, "Rate limit exceeded"); + throw new Error("Rate limit exceeded for IP: " + token); + } +}; + +export const rateLimit = (options: Options) => { + if (REDIS_HTTP_URL) { + return redisRateLimiter(options); + } else { + return inMemoryRateLimiter(options); + } +}; diff --git a/apps/web/lib/utils/recall.test.ts b/apps/web/lib/utils/recall.test.ts new file mode 100644 index 000000000000..027378cffc5b --- /dev/null +++ b/apps/web/lib/utils/recall.test.ts @@ -0,0 +1,516 @@ +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { structuredClone } from "@/lib/pollyfills/structuredClone"; +import { describe, expect, test, vi } from "vitest"; +import { TResponseData, TResponseVariables } from "@formbricks/types/responses"; +import { TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types"; +import { + checkForEmptyFallBackValue, + extractFallbackValue, + extractId, + extractIds, + extractRecallInfo, + fallbacks, + findRecallInfoById, + getFallbackValues, + getRecallItems, + headlineToRecall, + parseRecallInfo, + recallToHeadline, + replaceHeadlineRecall, + replaceRecallInfoWithUnderline, +} from "./recall"; + +// Mock dependencies +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: vi.fn().mockImplementation((obj, lang) => { + return typeof obj === "string" ? obj : obj[lang] || obj["default"] || ""; + }), +})); + +vi.mock("@/lib/pollyfills/structuredClone", () => ({ + structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))), +})); + +vi.mock("@/lib/utils/datetime", () => ({ + isValidDateString: vi.fn((value) => { + try { + return !isNaN(new Date(value as string).getTime()); + } catch { + return false; + } + }), + formatDateWithOrdinal: vi.fn((date) => { + return "January 1st, 2023"; + }), +})); + +describe("recall utility functions", () => { + describe("extractId", () => { + test("extracts ID correctly from a string with recall pattern", () => { + const text = "This is a #recall:question123 example"; + const result = extractId(text); + expect(result).toBe("question123"); + }); + + test("returns null when no ID is found", () => { + const text = "This has no recall pattern"; + const result = extractId(text); + expect(result).toBeNull(); + }); + + test("returns null for malformed recall pattern", () => { + const text = "This is a #recall: malformed pattern"; + const result = extractId(text); + expect(result).toBeNull(); + }); + }); + + describe("extractIds", () => { + test("extracts multiple IDs from a string with multiple recall patterns", () => { + const text = "This has #recall:id1 and #recall:id2 and #recall:id3"; + const result = extractIds(text); + expect(result).toEqual(["id1", "id2", "id3"]); + }); + + test("returns empty array when no IDs are found", () => { + const text = "This has no recall patterns"; + const result = extractIds(text); + expect(result).toEqual([]); + }); + + test("handles mixed content correctly", () => { + const text = "Text #recall:id1 more text #recall:id2"; + const result = extractIds(text); + expect(result).toEqual(["id1", "id2"]); + }); + }); + + describe("extractFallbackValue", () => { + test("extracts fallback value correctly", () => { + const text = "Text #recall:id1/fallback:defaultValue# more text"; + const result = extractFallbackValue(text); + expect(result).toBe("defaultValue"); + }); + + test("returns empty string when no fallback value is found", () => { + const text = "Text with no fallback"; + const result = extractFallbackValue(text); + expect(result).toBe(""); + }); + + test("handles empty fallback value", () => { + const text = "Text #recall:id1/fallback:# more text"; + const result = extractFallbackValue(text); + expect(result).toBe(""); + }); + }); + + describe("extractRecallInfo", () => { + test("extracts complete recall info from text", () => { + const text = "This is #recall:id1/fallback:default# text"; + const result = extractRecallInfo(text); + expect(result).toBe("#recall:id1/fallback:default#"); + }); + + test("returns null when no recall info is found", () => { + const text = "This has no recall info"; + const result = extractRecallInfo(text); + expect(result).toBeNull(); + }); + + test("extracts recall info for a specific ID when provided", () => { + const text = "This has #recall:id1/fallback:default1# and #recall:id2/fallback:default2#"; + const result = extractRecallInfo(text, "id2"); + expect(result).toBe("#recall:id2/fallback:default2#"); + }); + }); + + describe("findRecallInfoById", () => { + test("finds recall info by ID", () => { + const text = "Text #recall:id1/fallback:value1# and #recall:id2/fallback:value2#"; + const result = findRecallInfoById(text, "id2"); + expect(result).toBe("#recall:id2/fallback:value2#"); + }); + + test("returns null when ID is not found", () => { + const text = "Text #recall:id1/fallback:value1#"; + const result = findRecallInfoById(text, "id2"); + expect(result).toBeNull(); + }); + }); + + describe("recallToHeadline", () => { + test("converts recall pattern to headline format without slash", () => { + const headline = { en: "How do you like #recall:product/fallback:ournbspproduct#?" }; + const survey: TSurvey = { + id: "test-survey", + questions: [{ id: "product", headline: { en: "Product Question" } }] as unknown as TSurveyQuestion[], + hiddenFields: { fieldIds: [] }, + variables: [], + } as unknown as TSurvey; + + const result = recallToHeadline(headline, survey, false, "en"); + expect(result.en).toBe("How do you like @Product Question?"); + }); + + test("converts recall pattern to headline format with slash", () => { + const headline = { en: "Rate #recall:product/fallback:ournbspproduct#" }; + const survey: TSurvey = { + id: "test-survey", + questions: [{ id: "product", headline: { en: "Product Question" } }] as unknown as TSurveyQuestion[], + hiddenFields: { fieldIds: [] }, + variables: [], + } as unknown as TSurvey; + + const result = recallToHeadline(headline, survey, true, "en"); + expect(result.en).toBe("Rate /Product Question\\"); + }); + + test("handles hidden fields in recall", () => { + const headline = { en: "Your email is #recall:email/fallback:notnbspprovided#" }; + const survey: TSurvey = { + id: "test-survey", + questions: [], + hiddenFields: { fieldIds: ["email"] }, + variables: [], + } as unknown as TSurvey; + + const result = recallToHeadline(headline, survey, false, "en"); + expect(result.en).toBe("Your email is @email"); + }); + + test("handles variables in recall", () => { + const headline = { en: "Your plan is #recall:plan/fallback:unknown#" }; + const survey: TSurvey = { + id: "test-survey", + questions: [], + hiddenFields: { fieldIds: [] }, + variables: [{ id: "plan", name: "Subscription Plan" }], + } as unknown as TSurvey; + + const result = recallToHeadline(headline, survey, false, "en"); + expect(result.en).toBe("Your plan is @Subscription Plan"); + }); + + test("returns unchanged headline when no recall pattern is found", () => { + const headline = { en: "Regular headline with no recall" }; + const survey = {} as TSurvey; + + const result = recallToHeadline(headline, survey, false, "en"); + expect(result).toEqual(headline); + }); + + test("handles nested recall patterns", () => { + const headline = { + en: "This is #recall:outer/fallback:withnbsp#recall:inner/fallback:nested#nbsptext#", + }; + const survey: TSurvey = { + id: "test-survey", + questions: [ + { id: "outer", headline: { en: "Outer with @inner" } }, + { id: "inner", headline: { en: "Inner value" } }, + ] as unknown as TSurveyQuestion[], + hiddenFields: { fieldIds: [] }, + variables: [], + } as unknown as TSurvey; + + const result = recallToHeadline(headline, survey, false, "en"); + expect(result.en).toBe("This is @Outer with @inner"); + }); + }); + + describe("replaceRecallInfoWithUnderline", () => { + test("replaces recall info with underline", () => { + const text = "This is a #recall:id1/fallback:default# example"; + const result = replaceRecallInfoWithUnderline(text); + expect(result).toBe("This is a ___ example"); + }); + + test("replaces multiple recall infos with underlines", () => { + const text = "This #recall:id1/fallback:v1# has #recall:id2/fallback:v2# multiple recalls"; + const result = replaceRecallInfoWithUnderline(text); + expect(result).toBe("This ___ has ___ multiple recalls"); + }); + + test("returns unchanged text when no recall info is present", () => { + const text = "This has no recall info"; + const result = replaceRecallInfoWithUnderline(text); + expect(result).toBe(text); + }); + }); + + describe("checkForEmptyFallBackValue", () => { + test("identifies question with empty fallback value", () => { + const questionHeadline = { en: "Question with #recall:id1/fallback:# empty fallback" }; + const survey: TSurvey = { + questions: [ + { + id: "q1", + headline: questionHeadline, + }, + ] as unknown as TSurveyQuestion[], + } as unknown as TSurvey; + + vi.mocked(getLocalizedValue).mockReturnValueOnce(questionHeadline.en); + + const result = checkForEmptyFallBackValue(survey, "en"); + expect(result).toBe(survey.questions[0]); + }); + + test("identifies question with empty fallback in subheader", () => { + const questionSubheader = { en: "Subheader with #recall:id1/fallback:# empty fallback" }; + const survey: TSurvey = { + questions: [ + { + id: "q1", + headline: { en: "Normal question" }, + subheader: questionSubheader, + }, + ] as unknown as TSurveyQuestion[], + } as unknown as TSurvey; + + vi.mocked(getLocalizedValue).mockReturnValueOnce(questionSubheader.en); + + const result = checkForEmptyFallBackValue(survey, "en"); + expect(result).toBe(survey.questions[0]); + }); + + test("returns null when no empty fallback values are found", () => { + const questionHeadline = { en: "Question with #recall:id1/fallback:default# valid fallback" }; + const survey: TSurvey = { + questions: [ + { + id: "q1", + headline: questionHeadline, + }, + ] as unknown as TSurveyQuestion[], + } as unknown as TSurvey; + + vi.mocked(getLocalizedValue).mockReturnValueOnce(questionHeadline.en); + + const result = checkForEmptyFallBackValue(survey, "en"); + expect(result).toBeNull(); + }); + }); + + describe("replaceHeadlineRecall", () => { + test("processes all questions in a survey", () => { + const survey: TSurvey = { + questions: [ + { + id: "q1", + headline: { en: "Question with #recall:id1/fallback:default#" }, + }, + { + id: "q2", + headline: { en: "Another with #recall:id2/fallback:other#" }, + }, + ] as unknown as TSurveyQuestion[], + hiddenFields: { fieldIds: [] }, + variables: [], + } as unknown as TSurvey; + + vi.mocked(structuredClone).mockImplementation((obj) => JSON.parse(JSON.stringify(obj))); + + const result = replaceHeadlineRecall(survey, "en"); + + // Verify recallToHeadline was called for each question + expect(result).not.toBe(survey); // Should be a clone + expect(result.questions[0].headline).not.toEqual(survey.questions[0].headline); + expect(result.questions[1].headline).not.toEqual(survey.questions[1].headline); + }); + }); + + describe("getRecallItems", () => { + test("extracts recall items from text", () => { + const text = "Text with #recall:id1/fallback:val1# and #recall:id2/fallback:val2#"; + const survey: TSurvey = { + questions: [ + { id: "id1", headline: { en: "Question One" } }, + { id: "id2", headline: { en: "Question Two" } }, + ] as unknown as TSurveyQuestion[], + hiddenFields: { fieldIds: [] }, + variables: [], + } as unknown as TSurvey; + + const result = getRecallItems(text, survey, "en"); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe("id1"); + expect(result[0].label).toBe("Question One"); + expect(result[0].type).toBe("question"); + expect(result[1].id).toBe("id2"); + expect(result[1].label).toBe("Question Two"); + expect(result[1].type).toBe("question"); + }); + + test("handles hidden fields in recall items", () => { + const text = "Text with #recall:hidden1/fallback:val1#"; + const survey: TSurvey = { + questions: [], + hiddenFields: { fieldIds: ["hidden1"] }, + variables: [], + } as unknown as TSurvey; + + const result = getRecallItems(text, survey, "en"); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("hidden1"); + expect(result[0].type).toBe("hiddenField"); + }); + + test("handles variables in recall items", () => { + const text = "Text with #recall:var1/fallback:val1#"; + const survey: TSurvey = { + questions: [], + hiddenFields: { fieldIds: [] }, + variables: [{ id: "var1", name: "Variable One" }], + } as unknown as TSurvey; + + const result = getRecallItems(text, survey, "en"); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("var1"); + expect(result[0].label).toBe("Variable One"); + expect(result[0].type).toBe("variable"); + }); + + test("returns empty array when no recall items are found", () => { + const text = "Text with no recall items"; + const survey: TSurvey = {} as TSurvey; + + const result = getRecallItems(text, survey, "en"); + expect(result).toEqual([]); + }); + }); + + describe("getFallbackValues", () => { + test("extracts fallback values from text", () => { + const text = "Text #recall:id1/fallback:value1# and #recall:id2/fallback:value2#"; + const result = getFallbackValues(text); + + expect(result).toEqual({ + id1: "value1", + id2: "value2", + }); + }); + + test("returns empty object when no fallback values are found", () => { + const text = "Text with no fallback values"; + const result = getFallbackValues(text); + expect(result).toEqual({}); + }); + }); + + describe("headlineToRecall", () => { + test("transforms headlines to recall info", () => { + const text = "What do you think of @Product?"; + const recallItems: TSurveyRecallItem[] = [{ id: "product", label: "Product", type: "question" }]; + const fallbacks: fallbacks = { + product: "our product", + }; + + const result = headlineToRecall(text, recallItems, fallbacks); + expect(result).toBe("What do you think of #recall:product/fallback:our product#?"); + }); + + test("transforms multiple headlines", () => { + const text = "Rate @Product made by @Company"; + const recallItems: TSurveyRecallItem[] = [ + { id: "product", label: "Product", type: "question" }, + { id: "company", label: "Company", type: "question" }, + ]; + const fallbacks: fallbacks = { + product: "our product", + company: "our company", + }; + + const result = headlineToRecall(text, recallItems, fallbacks); + expect(result).toBe( + "Rate #recall:product/fallback:our product# made by #recall:company/fallback:our company#" + ); + }); + }); + + describe("parseRecallInfo", () => { + test("replaces recall info with response data", () => { + const text = "Your answer was #recall:q1/fallback:not-provided#"; + const responseData: TResponseData = { + q1: "Yes definitely", + }; + + const result = parseRecallInfo(text, responseData); + expect(result).toBe("Your answer was Yes definitely"); + }); + + test("uses fallback when response data is missing", () => { + const text = "Your answer was #recall:q1/fallback:notnbspprovided#"; + const responseData: TResponseData = { + q2: "Some other answer", + }; + + const result = parseRecallInfo(text, responseData); + expect(result).toBe("Your answer was not provided"); + }); + + test("formats date values", () => { + const text = "You joined on #recall:joinDate/fallback:an-unknown-date#"; + const responseData: TResponseData = { + joinDate: "2023-01-01", + }; + + const result = parseRecallInfo(text, responseData); + expect(result).toBe("You joined on January 1st, 2023"); + }); + + test("formats array values as comma-separated list", () => { + const text = "Your selections: #recall:preferences/fallback:none#"; + const responseData: TResponseData = { + preferences: ["Option A", "Option B", "Option C"], + }; + + const result = parseRecallInfo(text, responseData); + expect(result).toBe("Your selections: Option A, Option B, Option C"); + }); + + test("uses variables when available", () => { + const text = "Welcome back, #recall:username/fallback:user#"; + const variables: TResponseVariables = { + username: "John Doe", + }; + + const result = parseRecallInfo(text, {}, variables); + expect(result).toBe("Welcome back, John Doe"); + }); + + test("prioritizes variables over response data", () => { + const text = "Your email is #recall:email/fallback:no-email#"; + const responseData: TResponseData = { + email: "response@example.com", + }; + const variables: TResponseVariables = { + email: "variable@example.com", + }; + + const result = parseRecallInfo(text, responseData, variables); + expect(result).toBe("Your email is variable@example.com"); + }); + + test("handles withSlash parameter", () => { + const text = "Your name is #recall:name/fallback:anonymous#"; + const variables: TResponseVariables = { + name: "John Doe", + }; + + const result = parseRecallInfo(text, {}, variables, true); + expect(result).toBe("Your name is #/John Doe\\#"); + }); + + test("handles 'nbsp' in fallback values", () => { + const text = "Default spacing: #recall:space/fallback:nonnbspbreaking#"; + + const result = parseRecallInfo(text); + expect(result).toBe("Default spacing: non breaking"); + }); + }); +}); diff --git a/packages/lib/utils/recall.ts b/apps/web/lib/utils/recall.ts similarity index 97% rename from packages/lib/utils/recall.ts rename to apps/web/lib/utils/recall.ts index 88f610883d6b..5a04ddb8c784 100644 --- a/packages/lib/utils/recall.ts +++ b/apps/web/lib/utils/recall.ts @@ -1,7 +1,7 @@ +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { structuredClone } from "@/lib/pollyfills/structuredClone"; import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses"; import { TI18nString, TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types"; -import { getLocalizedValue } from "../i18n/utils"; -import { structuredClone } from "../pollyfills/structuredClone"; import { formatDateWithOrdinal, isValidDateString } from "./datetime"; export interface fallbacks { @@ -124,6 +124,7 @@ export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): T const recalls = text.match(/#recall:[^ ]+/g); return recalls && recalls.some((recall) => !extractFallbackValue(recall)); }; + for (const question of survey.questions) { if ( findRecalls(getLocalizedValue(question.headline, language)) || @@ -196,13 +197,15 @@ export const getFallbackValues = (text: string): fallbacks => { // Transforms headlines in a text to their corresponding recall information. export const headlineToRecall = ( - text: string, + text: string | undefined, recallItems: TSurveyRecallItem[], fallbacks: fallbacks ): string => { + if (!text) return ""; + recallItems.forEach((recallItem) => { const recallInfo = `#recall:${recallItem.id}/fallback:${fallbacks[recallItem.id]}#`; - text = text.replace(`@${recallItem.label}`, recallInfo); + text = text?.replace(`@${recallItem.label}`, recallInfo); }); return text; }; diff --git a/apps/web/lib/utils/services.test.ts b/apps/web/lib/utils/services.test.ts new file mode 100644 index 000000000000..ff0fd4140f7c --- /dev/null +++ b/apps/web/lib/utils/services.test.ts @@ -0,0 +1,583 @@ +import { validateInputs } from "@/lib/utils/validate"; +import { Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { + getActionClass, + getApiKey, + getContact, + getDocument, + getEnvironment, + getInsight, + getIntegration, + getInvite, + getLanguage, + getProject, + getResponse, + getSegment, + getSurvey, + getTag, + getTeam, + getWebhook, + isProjectPartOfOrganization, + isTeamPartOfOrganization, +} from "./services"; + +// Mock all dependencies +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + actionClass: { + findUnique: vi.fn(), + }, + apiKey: { + findUnique: vi.fn(), + }, + environment: { + findUnique: vi.fn(), + }, + integration: { + findUnique: vi.fn(), + }, + invite: { + findUnique: vi.fn(), + }, + language: { + findFirst: vi.fn(), + }, + project: { + findUnique: vi.fn(), + }, + response: { + findUnique: vi.fn(), + }, + + survey: { + findUnique: vi.fn(), + }, + tag: { + findUnique: vi.fn(), + }, + webhook: { + findUnique: vi.fn(), + }, + team: { + findUnique: vi.fn(), + }, + insight: { + findUnique: vi.fn(), + }, + document: { + findUnique: vi.fn(), + }, + contact: { + findUnique: vi.fn(), + }, + segment: { + findUnique: vi.fn(), + }, + }, +})); + +describe("Service Functions", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("getActionClass", () => { + const actionClassId = "action123"; + + test("returns the action class when found", async () => { + const mockActionClass = { environmentId: "env123" }; + vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(mockActionClass); + + const result = await getActionClass(actionClassId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.actionClass.findUnique).toHaveBeenCalledWith({ + where: { id: actionClassId }, + select: { environmentId: true }, + }); + expect(result).toEqual(mockActionClass); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.actionClass.findUnique).mockRejectedValue(new Error("Database error")); + + await expect(getActionClass(actionClassId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getApiKey", () => { + const apiKeyId = "apiKey123"; + + test("returns the api key when found", async () => { + const mockApiKey = { organizationId: "org123" }; + vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKey); + + const result = await getApiKey(apiKeyId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({ + where: { id: apiKeyId }, + select: { organizationId: true }, + }); + expect(result).toEqual(mockApiKey); + }); + + test("throws InvalidInputError if apiKeyId is empty", async () => { + await expect(getApiKey("")).rejects.toThrow(InvalidInputError); + expect(prisma.apiKey.findUnique).not.toHaveBeenCalled(); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.apiKey.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getApiKey(apiKeyId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getEnvironment", () => { + const environmentId = "env123"; + + test("returns the environment when found", async () => { + const mockEnvironment = { projectId: "proj123" }; + vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironment); + + const result = await getEnvironment(environmentId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.environment.findUnique).toHaveBeenCalledWith({ + where: { id: environmentId }, + select: { projectId: true }, + }); + expect(result).toEqual(mockEnvironment); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.environment.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getEnvironment(environmentId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getIntegration", () => { + const integrationId = "int123"; + + test("returns the integration when found", async () => { + const mockIntegration = { environmentId: "env123" }; + vi.mocked(prisma.integration.findUnique).mockResolvedValue(mockIntegration); + + const result = await getIntegration(integrationId); + expect(prisma.integration.findUnique).toHaveBeenCalledWith({ + where: { id: integrationId }, + select: { environmentId: true }, + }); + expect(result).toEqual(mockIntegration); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.integration.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getIntegration(integrationId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getInvite", () => { + const inviteId = "invite123"; + + test("returns the invite when found", async () => { + const mockInvite = { organizationId: "org123" }; + vi.mocked(prisma.invite.findUnique).mockResolvedValue(mockInvite); + + const result = await getInvite(inviteId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.invite.findUnique).toHaveBeenCalledWith({ + where: { id: inviteId }, + select: { organizationId: true }, + }); + expect(result).toEqual(mockInvite); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.invite.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getInvite(inviteId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getLanguage", () => { + const languageId = "lang123"; + + test("returns the language when found", async () => { + const mockLanguage = { projectId: "proj123" }; + vi.mocked(prisma.language.findFirst).mockResolvedValue(mockLanguage); + + const result = await getLanguage(languageId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.language.findFirst).toHaveBeenCalledWith({ + where: { id: languageId }, + select: { projectId: true }, + }); + expect(result).toEqual(mockLanguage); + }); + + test("throws ResourceNotFoundError when language not found", async () => { + vi.mocked(prisma.language.findFirst).mockResolvedValue(null); + + await expect(getLanguage(languageId)).rejects.toThrow(ResourceNotFoundError); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.language.findFirst).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getLanguage(languageId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getProject", () => { + const projectId = "proj123"; + + test("returns the project when found", async () => { + const mockProject = { organizationId: "org123" }; + vi.mocked(prisma.project.findUnique).mockResolvedValue(mockProject); + + const result = await getProject(projectId); + expect(prisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: projectId }, + select: { organizationId: true }, + }); + expect(result).toEqual(mockProject); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.project.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getProject(projectId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getResponse", () => { + const responseId = "resp123"; + + test("returns the response when found", async () => { + const mockResponse = { surveyId: "survey123" }; + vi.mocked(prisma.response.findUnique).mockResolvedValue(mockResponse); + + const result = await getResponse(responseId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.response.findUnique).toHaveBeenCalledWith({ + where: { id: responseId }, + select: { surveyId: true }, + }); + expect(result).toEqual(mockResponse); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.response.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getResponse(responseId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getSurvey", () => { + const surveyId = "survey123"; + + test("returns the survey when found", async () => { + const mockSurvey = { environmentId: "env123" }; + vi.mocked(prisma.survey.findUnique).mockResolvedValue(mockSurvey); + + const result = await getSurvey(surveyId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.survey.findUnique).toHaveBeenCalledWith({ + where: { id: surveyId }, + select: { environmentId: true }, + }); + expect(result).toEqual(mockSurvey); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.survey.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getSurvey(surveyId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getTag", () => { + const tagId = "tag123"; + + test("returns the tag when found", async () => { + const mockTag = { environmentId: "env123" }; + vi.mocked(prisma.tag.findUnique).mockResolvedValue(mockTag); + + const result = await getTag(tagId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.tag.findUnique).toHaveBeenCalledWith({ + where: { id: tagId }, + select: { environmentId: true }, + }); + expect(result).toEqual(mockTag); + }); + }); + + describe("getWebhook", () => { + const webhookId = "webhook123"; + + test("returns the webhook when found", async () => { + const mockWebhook = { environmentId: "env123" }; + vi.mocked(prisma.webhook.findUnique).mockResolvedValue(mockWebhook); + + const result = await getWebhook(webhookId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.webhook.findUnique).toHaveBeenCalledWith({ + where: { id: webhookId }, + select: { environmentId: true }, + }); + expect(result).toEqual(mockWebhook); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.webhook.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getWebhook(webhookId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getTeam", () => { + const teamId = "team123"; + + test("returns the team when found", async () => { + const mockTeam = { organizationId: "org123" }; + vi.mocked(prisma.team.findUnique).mockResolvedValue(mockTeam); + + const result = await getTeam(teamId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.team.findUnique).toHaveBeenCalledWith({ + where: { id: teamId }, + select: { organizationId: true }, + }); + expect(result).toEqual(mockTeam); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.team.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getTeam(teamId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getInsight", () => { + const insightId = "insight123"; + + test("returns the insight when found", async () => { + const mockInsight = { environmentId: "env123" }; + vi.mocked(prisma.insight.findUnique).mockResolvedValue(mockInsight); + + const result = await getInsight(insightId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.insight.findUnique).toHaveBeenCalledWith({ + where: { id: insightId }, + select: { environmentId: true }, + }); + expect(result).toEqual(mockInsight); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.insight.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getInsight(insightId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getDocument", () => { + const documentId = "doc123"; + + test("returns the document when found", async () => { + const mockDocument = { environmentId: "env123" }; + vi.mocked(prisma.document.findUnique).mockResolvedValue(mockDocument); + + const result = await getDocument(documentId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.document.findUnique).toHaveBeenCalledWith({ + where: { id: documentId }, + select: { environmentId: true }, + }); + expect(result).toEqual(mockDocument); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.document.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getDocument(documentId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("isProjectPartOfOrganization", () => { + const projectId = "proj123"; + const organizationId = "org123"; + + test("returns true when project belongs to organization", async () => { + vi.mocked(prisma.project.findUnique).mockResolvedValue({ organizationId }); + + const result = await isProjectPartOfOrganization(organizationId, projectId); + expect(result).toBe(true); + }); + + test("returns false when project belongs to different organization", async () => { + vi.mocked(prisma.project.findUnique).mockResolvedValue({ organizationId: "otherOrg" }); + + const result = await isProjectPartOfOrganization(organizationId, projectId); + expect(result).toBe(false); + }); + + test("throws ResourceNotFoundError when project not found", async () => { + vi.mocked(prisma.project.findUnique).mockResolvedValue(null); + + await expect(isProjectPartOfOrganization(organizationId, projectId)).rejects.toThrow( + ResourceNotFoundError + ); + }); + }); + + describe("isTeamPartOfOrganization", () => { + const teamId = "team123"; + const organizationId = "org123"; + + test("returns true when team belongs to organization", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue({ organizationId }); + + const result = await isTeamPartOfOrganization(organizationId, teamId); + expect(result).toBe(true); + }); + + test("returns false when team belongs to different organization", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue({ organizationId: "otherOrg" }); + + const result = await isTeamPartOfOrganization(organizationId, teamId); + expect(result).toBe(false); + }); + + test("throws ResourceNotFoundError when team not found", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue(null); + + await expect(isTeamPartOfOrganization(organizationId, teamId)).rejects.toThrow(ResourceNotFoundError); + }); + }); + + describe("getContact", () => { + const contactId = "contact123"; + + test("returns the contact when found", async () => { + const mockContact = { environmentId: "env123" }; + vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContact); + + const result = await getContact(contactId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.contact.findUnique).toHaveBeenCalledWith({ + where: { id: contactId }, + select: { environmentId: true }, + }); + expect(result).toEqual(mockContact); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.contact.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getContact(contactId)).rejects.toThrow(DatabaseError); + }); + }); + + describe("getSegment", () => { + const segmentId = "segment123"; + + test("returns the segment when found", async () => { + const mockSegment = { environmentId: "env123" }; + vi.mocked(prisma.segment.findUnique).mockResolvedValue(mockSegment); + + const result = await getSegment(segmentId); + expect(validateInputs).toHaveBeenCalled(); + expect(prisma.segment.findUnique).toHaveBeenCalledWith({ + where: { id: segmentId }, + select: { environmentId: true }, + }); + expect(result).toEqual(mockSegment); + }); + + test("throws DatabaseError when database operation fails", async () => { + vi.mocked(prisma.segment.findUnique).mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Error", { + code: "P2002", + clientVersion: "4.7.0", + }) + ); + + await expect(getSegment(segmentId)).rejects.toThrow(DatabaseError); + }); + }); +}); diff --git a/apps/web/lib/utils/services.ts b/apps/web/lib/utils/services.ts index 05f2d3ab3c16..8f5425fa4015 100644 --- a/apps/web/lib/utils/services.ts +++ b/apps/web/lib/utils/services.ts @@ -1,180 +1,126 @@ "use server"; -import { apiKeyCache } from "@/lib/cache/api-key"; -import { contactCache } from "@/lib/cache/contact"; -import { inviteCache } from "@/lib/cache/invite"; -import { teamCache } from "@/lib/cache/team"; -import { webhookCache } from "@/lib/cache/webhook"; +import { validateInputs } from "@/lib/utils/validate"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { actionClassCache } from "@formbricks/lib/actionClass/cache"; -import { cache } from "@formbricks/lib/cache"; -import { segmentCache } from "@formbricks/lib/cache/segment"; -import { environmentCache } from "@formbricks/lib/environment/cache"; -import { integrationCache } from "@formbricks/lib/integration/cache"; -import { projectCache } from "@formbricks/lib/project/cache"; -import { responseCache } from "@formbricks/lib/response/cache"; -import { responseNoteCache } from "@formbricks/lib/responseNote/cache"; -import { surveyCache } from "@formbricks/lib/survey/cache"; -import { tagCache } from "@formbricks/lib/tag/cache"; -import { validateInputs } from "@formbricks/lib/utils/validate"; import { ZId, ZString } from "@formbricks/types/common"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; export const getActionClass = reactCache( - async (actionClassId: string): Promise<{ environmentId: string } | null> => - cache( - async () => { - validateInputs([actionClassId, ZId]); - - try { - const actionClass = await prisma.actionClass.findUnique({ - where: { - id: actionClassId, - }, - select: { - environmentId: true, - }, - }); - - return actionClass; - } catch (error) { - throw new DatabaseError(`Database error when fetching action`); - } - }, - [`utils-getActionClass-${actionClassId}`], - { - tags: [actionClassCache.tag.byId(actionClassId)], - } - )() + async (actionClassId: string): Promise<{ environmentId: string } | null> => { + validateInputs([actionClassId, ZId]); + + try { + const actionClass = await prisma.actionClass.findUnique({ + where: { + id: actionClassId, + }, + select: { + environmentId: true, + }, + }); + + return actionClass; + } catch (error) { + throw new DatabaseError(`Database error when fetching action`); + } + } ); -export const getApiKey = reactCache( - async (apiKeyId: string): Promise<{ organizationId: string } | null> => - cache( - async () => { - validateInputs([apiKeyId, ZString]); - - if (!apiKeyId) { - throw new InvalidInputError("API key cannot be null or undefined."); - } - - try { - const apiKeyData = await prisma.apiKey.findUnique({ - where: { - id: apiKeyId, - }, - select: { - organizationId: true, - }, - }); - - return apiKeyData; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } +export const getApiKey = reactCache(async (apiKeyId: string): Promise<{ organizationId: string } | null> => { + validateInputs([apiKeyId, ZString]); + + if (!apiKeyId) { + throw new InvalidInputError("API key cannot be null or undefined."); + } + + try { + const apiKeyData = await prisma.apiKey.findUnique({ + where: { + id: apiKeyId, }, - [`utils-getApiKey-${apiKeyId}`], - { - tags: [apiKeyCache.tag.byId(apiKeyId)], - } - )() -); + select: { + organizationId: true, + }, + }); + + return apiKeyData; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}); export const getEnvironment = reactCache( - async (environmentId: string): Promise<{ projectId: string } | null> => - cache( - async () => { - validateInputs([environmentId, ZId]); - - try { - const environment = await prisma.environment.findUnique({ - where: { - id: environmentId, - }, - select: { - projectId: true, - }, - }); - return environment; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - throw error; - } - }, - [`utils-getEnvironment-${environmentId}`], - { - tags: [environmentCache.tag.byId(environmentId)], + async (environmentId: string): Promise<{ projectId: string } | null> => { + validateInputs([environmentId, ZId]); + + try { + const environment = await prisma.environment.findUnique({ + where: { + id: environmentId, + }, + select: { + projectId: true, + }, + }); + return environment; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )() + throw error; + } + } ); export const getIntegration = reactCache( - async (integrationId: string): Promise<{ environmentId: string } | null> => - cache( - async () => { - try { - const integration = await prisma.integration.findUnique({ - where: { - id: integrationId, - }, - select: { - environmentId: true, - }, - }); - return integration; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - throw error; - } - }, - [`utils-getIntegration-${integrationId}`], - { - tags: [integrationCache.tag.byId(integrationId)], + async (integrationId: string): Promise<{ environmentId: string } | null> => { + try { + const integration = await prisma.integration.findUnique({ + where: { + id: integrationId, + }, + select: { + environmentId: true, + }, + }); + return integration; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )() + throw error; + } + } ); -export const getInvite = reactCache( - async (inviteId: string): Promise<{ organizationId: string } | null> => - cache( - async () => { - validateInputs([inviteId, ZString]); - - try { - const invite = await prisma.invite.findUnique({ - where: { - id: inviteId, - }, - select: { - organizationId: true, - }, - }); - - return invite; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } +export const getInvite = reactCache(async (inviteId: string): Promise<{ organizationId: string } | null> => { + validateInputs([inviteId, ZString]); + + try { + const invite = await prisma.invite.findUnique({ + where: { + id: inviteId, }, - [`utils-getInvite-${inviteId}`], - { - tags: [inviteCache.tag.byId(inviteId)], - } - )() -); + select: { + organizationId: true, + }, + }); + + return invite; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}); export const getLanguage = async (languageId: string): Promise<{ projectId: string }> => { try { @@ -199,265 +145,170 @@ export const getLanguage = async (languageId: string): Promise<{ projectId: stri }; export const getProject = reactCache( - async (projectId: string): Promise<{ organizationId: string } | null> => - cache( - async () => { - try { - const projectPrisma = await prisma.project.findUnique({ - where: { - id: projectId, - }, - select: { organizationId: true }, - }); - return projectPrisma; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - throw error; - } - }, - [`utils-getProject-${projectId}`], - { - tags: [projectCache.tag.byId(projectId)], + async (projectId: string): Promise<{ organizationId: string } | null> => { + try { + const projectPrisma = await prisma.project.findUnique({ + where: { + id: projectId, + }, + select: { organizationId: true }, + }); + return projectPrisma; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )() + throw error; + } + } ); -export const getResponse = reactCache( - async (responseId: string): Promise<{ surveyId: string } | null> => - cache( - async () => { - validateInputs([responseId, ZId]); - - try { - const response = await prisma.response.findUnique({ - where: { - id: responseId, - }, - select: { surveyId: true }, - }); - - return response; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } - }, - [`utils-getResponse-${responseId}`], - { - tags: [responseCache.tag.byId(responseId), responseNoteCache.tag.byResponseId(responseId)], - } - )() -); +export const getResponse = reactCache(async (responseId: string): Promise<{ surveyId: string } | null> => { + validateInputs([responseId, ZId]); -export const getResponseNote = reactCache( - async (responseNoteId: string): Promise<{ responseId: string } | null> => - cache( - async () => { - try { - const responseNote = await prisma.responseNote.findUnique({ - where: { - id: responseNoteId, - }, - select: { - responseId: true, - }, - }); - return responseNote; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } + try { + const response = await prisma.response.findUnique({ + where: { + id: responseId, }, - [`utils-getResponseNote-${responseNoteId}`], - { - tags: [responseNoteCache.tag.byId(responseNoteId)], - } - )() -); + select: { surveyId: true }, + }); -export const getSurvey = reactCache( - async (surveyId: string): Promise<{ environmentId: string } | null> => - cache( - async () => { - validateInputs([surveyId, ZId]); - try { - const survey = await prisma.survey.findUnique({ - where: { - id: surveyId, - }, - select: { - environmentId: true, - }, - }); - - return survey; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - throw error; - } - }, - [`utils-getSurvey-${surveyId}`], - { - tags: [surveyCache.tag.byId(surveyId)], - } - )() -); + return response; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } -export const getTag = reactCache( - async (id: string): Promise<{ environmentId: string } | null> => - cache( - async () => { - validateInputs([id, ZId]); - const tag = await prisma.tag.findUnique({ - where: { - id, - }, - select: { - environmentId: true, - }, - }); - return tag; + throw error; + } +}); + +export const getSurvey = reactCache(async (surveyId: string): Promise<{ environmentId: string } | null> => { + validateInputs([surveyId, ZId]); + try { + const survey = await prisma.survey.findUnique({ + where: { + id: surveyId, }, - [`utils-getTag-${id}`], - { - tags: [tagCache.tag.byId(id)], - } - )() -); + select: { + environmentId: true, + }, + }); -export const getWebhook = async (id: string): Promise<{ environmentId: string } | null> => - cache( - async () => { - validateInputs([id, ZId]); - - try { - const webhook = await prisma.webhook.findUnique({ - where: { - id, - }, - select: { - environmentId: true, - }, - }); - return webhook; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } + return survey; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } +}); + +export const getTag = reactCache(async (id: string): Promise<{ environmentId: string } | null> => { + validateInputs([id, ZId]); + const tag = await prisma.tag.findUnique({ + where: { + id, + }, + select: { + environmentId: true, }, - [`utils-getWebhook-${id}`], - { - tags: [webhookCache.tag.byId(id)], + }); + return tag; +}); + +export const getWebhook = async (id: string): Promise<{ environmentId: string } | null> => { + validateInputs([id, ZId]); + + try { + const webhook = await prisma.webhook.findUnique({ + where: { + id, + }, + select: { + environmentId: true, + }, + }); + return webhook; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )(); - -export const getTeam = reactCache( - async (teamId: string): Promise<{ organizationId: string } | null> => - cache( - async () => { - validateInputs([teamId, ZString]); - - try { - const team = await prisma.team.findUnique({ - where: { - id: teamId, - }, - select: { - organizationId: true, - }, - }); - - return team; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } + + throw error; + } +}; + +export const getTeam = reactCache(async (teamId: string): Promise<{ organizationId: string } | null> => { + validateInputs([teamId, ZString]); + + try { + const team = await prisma.team.findUnique({ + where: { + id: teamId, }, - [`utils-getTeam-${teamId}`], - { - tags: [teamCache.tag.byId(teamId)], - } - )() -); + select: { + organizationId: true, + }, + }); + + return team; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } -export const getInsight = reactCache( - async (insightId: string): Promise<{ environmentId: string } | null> => - cache( - async () => { - validateInputs([insightId, ZId]); - - try { - const insight = await prisma.insight.findUnique({ - where: { - id: insightId, - }, - select: { - environmentId: true, - }, - }); - - return insight; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } + throw error; + } +}); + +export const getInsight = reactCache(async (insightId: string): Promise<{ environmentId: string } | null> => { + validateInputs([insightId, ZId]); + + try { + const insight = await prisma.insight.findUnique({ + where: { + id: insightId, }, - [`utils-getInsight-${insightId}`], - { - tags: [tagCache.tag.byId(insightId)], - } - )() -); + select: { + environmentId: true, + }, + }); + + return insight; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}); export const getDocument = reactCache( - async (documentId: string): Promise<{ environmentId: string } | null> => - cache( - async () => { - validateInputs([documentId, ZId]); - - try { - const document = await prisma.document.findUnique({ - where: { - id: documentId, - }, - select: { - environmentId: true, - }, - }); - - return document; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } - }, - [`utils-getDocument-${documentId}`], - { - tags: [tagCache.tag.byId(documentId)], + async (documentId: string): Promise<{ environmentId: string } | null> => { + validateInputs([documentId, ZId]); + + try { + const document = await prisma.document.findUnique({ + where: { + id: documentId, + }, + select: { + environmentId: true, + }, + }); + + return document; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )() + + throw error; + } + } ); export const isProjectPartOfOrganization = async ( @@ -479,59 +330,41 @@ export const isTeamPartOfOrganization = async (organizationId: string, teamId: s return team.organizationId === organizationId; }; -export const getContact = reactCache( - async (contactId: string): Promise<{ environmentId: string } | null> => - cache( - async () => { - validateInputs([contactId, ZId]); - - try { - return await prisma.contact.findUnique({ - where: { - id: contactId, - }, - select: { environmentId: true }, - }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } +export const getContact = reactCache(async (contactId: string): Promise<{ environmentId: string } | null> => { + validateInputs([contactId, ZId]); + + try { + return await prisma.contact.findUnique({ + where: { + id: contactId, }, - [`utils-getPerson-${contactId}`], - { - tags: [contactCache.tag.byId(contactId)], - } - )() -); + select: { environmentId: true }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } -export const getSegment = reactCache( - async (segmentId: string): Promise<{ environmentId: string } | null> => - cache( - async () => { - validateInputs([segmentId, ZId]); - try { - const segment = await prisma.segment.findUnique({ - where: { - id: segmentId, - }, - select: { environmentId: true }, - }); - - return segment; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } + throw error; + } +}); + +export const getSegment = reactCache(async (segmentId: string): Promise<{ environmentId: string } | null> => { + validateInputs([segmentId, ZId]); + try { + const segment = await prisma.segment.findUnique({ + where: { + id: segmentId, }, - [`utils-getSegment-${segmentId}`], - { - tags: [segmentCache.tag.byId(segmentId)], - } - )() -); + select: { environmentId: true }, + }); + + return segment; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}); diff --git a/apps/web/lib/utils/single-use-surveys.test.ts b/apps/web/lib/utils/single-use-surveys.test.ts new file mode 100644 index 000000000000..ccd2813b245a --- /dev/null +++ b/apps/web/lib/utils/single-use-surveys.test.ts @@ -0,0 +1,115 @@ +import * as crypto from "@/lib/crypto"; +import { env } from "@/lib/env"; +import cuid2 from "@paralleldrive/cuid2"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { generateSurveySingleUseId, generateSurveySingleUseIds } from "./single-use-surveys"; + +vi.mock("@/lib/crypto", () => ({ + symmetricEncrypt: vi.fn(), + symmetricDecrypt: vi.fn(), +})); + +vi.mock( + "@paralleldrive/cuid2", + async (importOriginal: () => Promise) => { + const original = await importOriginal(); + return { + ...original, + createId: vi.fn(), + isCuid: vi.fn(), + }; + } +); + +vi.mock("@/lib/env", () => ({ + env: { + ENCRYPTION_KEY: "test-encryption-key", + }, +})); + +describe("Single Use Surveys", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("generateSurveySingleUseId", () => { + test("returns plain cuid when encryption is disabled", () => { + const createIdMock = vi.spyOn(cuid2, "createId"); + createIdMock.mockReturnValueOnce("test-cuid"); + + const result = generateSurveySingleUseId(false); + + expect(result).toBe("test-cuid"); + expect(createIdMock).toHaveBeenCalledTimes(1); + expect(crypto.symmetricEncrypt).not.toHaveBeenCalled(); + }); + + test("returns encrypted cuid when encryption is enabled", () => { + const createIdMock = vi.spyOn(cuid2, "createId"); + createIdMock.mockReturnValueOnce("test-cuid"); + vi.mocked(crypto.symmetricEncrypt).mockReturnValueOnce("encrypted-test-cuid"); + + const result = generateSurveySingleUseId(true); + + expect(result).toBe("encrypted-test-cuid"); + expect(createIdMock).toHaveBeenCalledTimes(1); + expect(crypto.symmetricEncrypt).toHaveBeenCalledWith("test-cuid", env.ENCRYPTION_KEY); + }); + + test("throws error when encryption key is missing", () => { + vi.mocked(env).ENCRYPTION_KEY = ""; + const createIdMock = vi.spyOn(cuid2, "createId"); + createIdMock.mockReturnValueOnce("test-cuid"); + + expect(() => generateSurveySingleUseId(true)).toThrow("ENCRYPTION_KEY is not set"); + + // Restore encryption key for subsequent tests + vi.mocked(env).ENCRYPTION_KEY = "test-encryption-key"; + }); + }); + + describe("generateSurveySingleUseIds", () => { + beforeEach(() => { + vi.mocked(env).ENCRYPTION_KEY = "test-encryption-key"; + }); + + test("generates multiple single use IDs", () => { + const createIdMock = vi.spyOn(cuid2, "createId"); + createIdMock + .mockReturnValueOnce("test-cuid-1") + .mockReturnValueOnce("test-cuid-2") + .mockReturnValueOnce("test-cuid-3"); + + const result = generateSurveySingleUseIds(3, false); + + expect(result).toEqual(["test-cuid-1", "test-cuid-2", "test-cuid-3"]); + expect(createIdMock).toHaveBeenCalledTimes(3); + }); + + test("generates encrypted IDs when encryption is enabled", () => { + const createIdMock = vi.spyOn(cuid2, "createId"); + + createIdMock.mockReturnValueOnce("test-cuid-1").mockReturnValueOnce("test-cuid-2"); + + vi.mocked(crypto.symmetricEncrypt) + .mockReturnValueOnce("encrypted-test-cuid-1") + .mockReturnValueOnce("encrypted-test-cuid-2"); + + const result = generateSurveySingleUseIds(2, true); + + expect(result).toEqual(["encrypted-test-cuid-1", "encrypted-test-cuid-2"]); + expect(createIdMock).toHaveBeenCalledTimes(2); + expect(crypto.symmetricEncrypt).toHaveBeenCalledTimes(2); + }); + + test("returns empty array when count is zero", () => { + const result = generateSurveySingleUseIds(0, false); + + const createIdMock = vi.spyOn(cuid2, "createId"); + createIdMock.mockReturnValueOnce("test-cuid"); + + expect(result).toEqual([]); + expect(createIdMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/lib/utils/single-use-surveys.ts b/apps/web/lib/utils/single-use-surveys.ts new file mode 100644 index 000000000000..05af0a193bd8 --- /dev/null +++ b/apps/web/lib/utils/single-use-surveys.ts @@ -0,0 +1,28 @@ +import { symmetricEncrypt } from "@/lib/crypto"; +import { env } from "@/lib/env"; +import cuid2 from "@paralleldrive/cuid2"; + +// generate encrypted single use id for the survey +export const generateSurveySingleUseId = (isEncrypted: boolean): string => { + const cuid = cuid2.createId(); + if (!isEncrypted) { + return cuid; + } + + if (!env.ENCRYPTION_KEY) { + throw new Error("ENCRYPTION_KEY is not set"); + } + + const encryptedCuid = symmetricEncrypt(cuid, env.ENCRYPTION_KEY); + return encryptedCuid; +}; + +export const generateSurveySingleUseIds = (count: number, isEncrypted: boolean): string[] => { + const singleUseIds: string[] = []; + + for (let i = 0; i < count; i++) { + singleUseIds.push(generateSurveySingleUseId(isEncrypted)); + } + + return singleUseIds; +}; diff --git a/apps/web/lib/utils/strings.test.ts b/apps/web/lib/utils/strings.test.ts new file mode 100644 index 000000000000..bf45d6e1d5ce --- /dev/null +++ b/apps/web/lib/utils/strings.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "vitest"; +import { + capitalizeFirstLetter, + isCapitalized, + sanitizeString, + startsWithVowel, + truncate, + truncateText, +} from "./strings"; + +describe("String Utilities", () => { + describe("capitalizeFirstLetter", () => { + test("capitalizes the first letter of a string", () => { + expect(capitalizeFirstLetter("hello")).toBe("Hello"); + }); + + test("returns empty string if input is null", () => { + expect(capitalizeFirstLetter(null)).toBe(""); + }); + + test("returns empty string if input is empty string", () => { + expect(capitalizeFirstLetter("")).toBe(""); + }); + + test("doesn't change already capitalized string", () => { + expect(capitalizeFirstLetter("Hello")).toBe("Hello"); + }); + + test("handles single character string", () => { + expect(capitalizeFirstLetter("a")).toBe("A"); + }); + }); + + describe("truncate", () => { + test("returns the string as is if length is less than the specified length", () => { + expect(truncate("hello", 10)).toBe("hello"); + }); + + test("truncates the string and adds ellipsis if length exceeds the specified length", () => { + expect(truncate("hello world", 5)).toBe("hello..."); + }); + + test("returns empty string if input is falsy", () => { + expect(truncate("", 5)).toBe(""); + }); + + test("handles exact length match correctly", () => { + expect(truncate("hello", 5)).toBe("hello"); + }); + }); + + describe("sanitizeString", () => { + test("replaces special characters with delimiter", () => { + expect(sanitizeString("hello@world")).toBe("hello_world"); + }); + + test("keeps alphanumeric and allowed characters", () => { + expect(sanitizeString("hello-world.123")).toBe("hello-world.123"); + }); + + test("truncates string to specified length", () => { + const longString = "a".repeat(300); + expect(sanitizeString(longString).length).toBe(255); + }); + + test("uses custom delimiter when provided", () => { + expect(sanitizeString("hello@world", "-")).toBe("hello-world"); + }); + + test("uses custom length when provided", () => { + expect(sanitizeString("hello world", "_", 5)).toBe("hello"); + }); + }); + + describe("isCapitalized", () => { + test("returns true for capitalized strings", () => { + expect(isCapitalized("Hello")).toBe(true); + }); + + test("returns false for non-capitalized strings", () => { + expect(isCapitalized("hello")).toBe(false); + }); + + test("handles single uppercase character", () => { + expect(isCapitalized("A")).toBe(true); + }); + + test("handles single lowercase character", () => { + expect(isCapitalized("a")).toBe(false); + }); + }); + + describe("startsWithVowel", () => { + test("returns true for strings starting with lowercase vowels", () => { + expect(startsWithVowel("apple")).toBe(true); + expect(startsWithVowel("elephant")).toBe(true); + expect(startsWithVowel("igloo")).toBe(true); + expect(startsWithVowel("octopus")).toBe(true); + expect(startsWithVowel("umbrella")).toBe(true); + }); + + test("returns true for strings starting with uppercase vowels", () => { + expect(startsWithVowel("Apple")).toBe(true); + expect(startsWithVowel("Elephant")).toBe(true); + expect(startsWithVowel("Igloo")).toBe(true); + expect(startsWithVowel("Octopus")).toBe(true); + expect(startsWithVowel("Umbrella")).toBe(true); + }); + + test("returns false for strings starting with consonants", () => { + expect(startsWithVowel("banana")).toBe(false); + expect(startsWithVowel("Carrot")).toBe(false); + }); + + test("returns false for empty strings", () => { + expect(startsWithVowel("")).toBe(false); + }); + }); + + describe("truncateText", () => { + test("returns the string as is if length is less than the specified limit", () => { + expect(truncateText("hello", 10)).toBe("hello"); + }); + + test("truncates the string and adds ellipsis if length exceeds the specified limit", () => { + expect(truncateText("hello world", 5)).toBe("hello..."); + }); + + test("handles exact limit match correctly", () => { + expect(truncateText("hello", 5)).toBe("hello"); + }); + }); +}); diff --git a/packages/lib/utils/strings.ts b/apps/web/lib/utils/strings.ts similarity index 100% rename from packages/lib/utils/strings.ts rename to apps/web/lib/utils/strings.ts diff --git a/apps/web/lib/utils/styling.test.ts b/apps/web/lib/utils/styling.test.ts new file mode 100644 index 000000000000..298321cc234d --- /dev/null +++ b/apps/web/lib/utils/styling.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "vitest"; +import { TJsEnvironmentStateProject, TJsEnvironmentStateSurvey } from "@formbricks/types/js"; +import { getStyling } from "./styling"; + +describe("Styling Utilities", () => { + test("returns project styling when project does not allow style overwrite", () => { + const project: TJsEnvironmentStateProject = { + styling: { + allowStyleOverwrite: false, + brandColor: "#000000", + highlightBorderColor: "#000000", + }, + } as unknown as TJsEnvironmentStateProject; + + const survey: TJsEnvironmentStateSurvey = { + styling: { + overwriteThemeStyling: true, + brandColor: "#ffffff", + highlightBorderColor: "#ffffff", + }, + } as unknown as TJsEnvironmentStateSurvey; + + expect(getStyling(project, survey)).toBe(project.styling); + }); + + test("returns project styling when project allows style overwrite but survey does not overwrite", () => { + const project: TJsEnvironmentStateProject = { + styling: { + allowStyleOverwrite: true, + brandColor: "#000000", + highlightBorderColor: "#000000", + }, + } as unknown as TJsEnvironmentStateProject; + + const survey: TJsEnvironmentStateSurvey = { + styling: { + overwriteThemeStyling: false, + brandColor: "#ffffff", + highlightBorderColor: "#ffffff", + }, + } as unknown as TJsEnvironmentStateSurvey; + + expect(getStyling(project, survey)).toBe(project.styling); + }); + + test("returns survey styling when both project and survey allow style overwrite", () => { + const project: TJsEnvironmentStateProject = { + styling: { + allowStyleOverwrite: true, + brandColor: "#000000", + highlightBorderColor: "#000000", + }, + } as unknown as TJsEnvironmentStateProject; + + const survey: TJsEnvironmentStateSurvey = { + styling: { + overwriteThemeStyling: true, + brandColor: "#ffffff", + highlightBorderColor: "#ffffff", + }, + } as unknown as TJsEnvironmentStateSurvey; + + expect(getStyling(project, survey)).toBe(survey.styling); + }); + + test("returns project styling when project allows style overwrite but survey styling is undefined", () => { + const project: TJsEnvironmentStateProject = { + styling: { + allowStyleOverwrite: true, + brandColor: "#000000", + highlightBorderColor: "#000000", + }, + } as unknown as TJsEnvironmentStateProject; + + const survey: TJsEnvironmentStateSurvey = { + styling: undefined, + } as unknown as TJsEnvironmentStateSurvey; + + expect(getStyling(project, survey)).toBe(project.styling); + }); + + test("returns project styling when project allows style overwrite but survey overwriteThemeStyling is undefined", () => { + const project: TJsEnvironmentStateProject = { + styling: { + allowStyleOverwrite: true, + brandColor: "#000000", + highlightBorderColor: "#000000", + }, + } as unknown as TJsEnvironmentStateProject; + + const survey: TJsEnvironmentStateSurvey = { + styling: { + brandColor: "#ffffff", + highlightBorderColor: "#ffffff", + }, + } as unknown as TJsEnvironmentStateSurvey; + + expect(getStyling(project, survey)).toBe(project.styling); + }); +}); diff --git a/packages/lib/utils/styling.ts b/apps/web/lib/utils/styling.ts similarity index 100% rename from packages/lib/utils/styling.ts rename to apps/web/lib/utils/styling.ts diff --git a/apps/web/lib/utils/templates.test.ts b/apps/web/lib/utils/templates.test.ts new file mode 100644 index 000000000000..421f8fd62379 --- /dev/null +++ b/apps/web/lib/utils/templates.test.ts @@ -0,0 +1,164 @@ +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { structuredClone } from "@/lib/pollyfills/structuredClone"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { TProject } from "@formbricks/types/project"; +import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { TTemplate } from "@formbricks/types/templates"; +import { replacePresetPlaceholders, replaceQuestionPresetPlaceholders } from "./templates"; + +// Mock the imported functions +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: vi.fn(), +})); + +vi.mock("@/lib/pollyfills/structuredClone", () => ({ + structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))), +})); + +describe("Template Utilities", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("replaceQuestionPresetPlaceholders", () => { + test("returns original question when project is not provided", () => { + const question: TSurveyQuestion = { + id: "test-id", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { + default: "Test Question $[projectName]", + }, + } as unknown as TSurveyQuestion; + + const result = replaceQuestionPresetPlaceholders(question, undefined as unknown as TProject); + + expect(result).toEqual(question); + expect(structuredClone).not.toHaveBeenCalled(); + }); + + test("replaces projectName placeholder in subheader", () => { + const question: TSurveyQuestion = { + id: "test-id", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { + default: "Test Question", + }, + subheader: { + default: "Subheader for $[projectName]", + }, + } as unknown as TSurveyQuestion; + + const project: TProject = { + id: "project-id", + name: "Test Project", + organizationId: "org-id", + } as unknown as TProject; + + // Mock for headline and subheader with correct return values + vi.mocked(getLocalizedValue).mockReturnValueOnce("Test Question"); + vi.mocked(getLocalizedValue).mockReturnValueOnce("Subheader for $[projectName]"); + + const result = replaceQuestionPresetPlaceholders(question, project); + + expect(vi.mocked(getLocalizedValue)).toHaveBeenCalledTimes(2); + expect(result.subheader?.default).toBe("Subheader for Test Project"); + }); + + test("handles missing headline and subheader", () => { + const question: TSurveyQuestion = { + id: "test-id", + type: TSurveyQuestionTypeEnum.OpenText, + } as unknown as TSurveyQuestion; + + const project: TProject = { + id: "project-id", + name: "Test Project", + organizationId: "org-id", + } as unknown as TProject; + + const result = replaceQuestionPresetPlaceholders(question, project); + + expect(structuredClone).toHaveBeenCalledWith(question); + expect(result).toEqual(question); + expect(getLocalizedValue).not.toHaveBeenCalled(); + }); + }); + + describe("replacePresetPlaceholders", () => { + test("replaces projectName placeholder in template name and questions", () => { + const template: TTemplate = { + id: "template-1", + name: "Test Template", + description: "Template Description", + preset: { + name: "$[projectName] Feedback", + questions: [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { + default: "How do you like $[projectName]?", + }, + }, + { + id: "q2", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { + default: "Another question", + }, + subheader: { + default: "About $[projectName]", + }, + }, + ], + }, + category: "product", + } as unknown as TTemplate; + + const project = { + name: "Awesome App", + }; + + // Mock getLocalizedValue to return the original strings with placeholders + vi.mocked(getLocalizedValue) + .mockReturnValueOnce("How do you like $[projectName]?") + .mockReturnValueOnce("Another question") + .mockReturnValueOnce("About $[projectName]"); + + const result = replacePresetPlaceholders(template, project); + + expect(result.preset.name).toBe("Awesome App Feedback"); + expect(structuredClone).toHaveBeenCalledWith(template.preset); + + // Verify that replaceQuestionPresetPlaceholders was applied to both questions + expect(vi.mocked(getLocalizedValue)).toHaveBeenCalledTimes(3); + expect(result.preset.questions[0].headline?.default).toBe("How do you like Awesome App?"); + expect(result.preset.questions[1].subheader?.default).toBe("About Awesome App"); + }); + + test("maintains other template properties", () => { + const template: TTemplate = { + id: "template-1", + name: "Test Template", + description: "Template Description", + preset: { + name: "$[projectName] Feedback", + questions: [], + }, + category: "product", + } as unknown as TTemplate; + + const project = { + name: "Awesome App", + }; + + const result = replacePresetPlaceholders(template, project) as unknown as { + name: string; + description: string; + }; + + expect(result.name).toBe(template.name); + expect(result.description).toBe(template.description); + }); + }); +}); diff --git a/packages/lib/utils/templates.ts b/apps/web/lib/utils/templates.ts similarity index 91% rename from packages/lib/utils/templates.ts rename to apps/web/lib/utils/templates.ts index cd4763e156b4..3506caf35852 100644 --- a/packages/lib/utils/templates.ts +++ b/apps/web/lib/utils/templates.ts @@ -1,8 +1,8 @@ +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { structuredClone } from "@/lib/pollyfills/structuredClone"; import { TProject } from "@formbricks/types/project"; import { TSurveyQuestion } from "@formbricks/types/surveys/types"; import { TTemplate } from "@formbricks/types/templates"; -import { getLocalizedValue } from "../i18n/utils"; -import { structuredClone } from "../pollyfills/structuredClone"; export const replaceQuestionPresetPlaceholders = ( question: TSurveyQuestion, diff --git a/apps/web/lib/utils/url.test.ts b/apps/web/lib/utils/url.test.ts new file mode 100644 index 000000000000..d1be4512950c --- /dev/null +++ b/apps/web/lib/utils/url.test.ts @@ -0,0 +1,103 @@ +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { TActionClassPageUrlRule } from "@formbricks/types/action-classes"; +import { isStringUrl, isValidCallbackUrl, testURLmatch } from "./url"; + +afterEach(() => { + cleanup(); +}); + +describe("testURLmatch", () => { + // Mock translation function + const mockT = (key: string): string => { + const translations: Record = { + "environments.actions.invalid_regex": "Please use a valid regular expression.", + "environments.actions.invalid_match_type": "The option selected is not available.", + }; + return translations[key] || key; + }; + + const testCases: [string, string, TActionClassPageUrlRule, boolean][] = [ + ["https://example.com", "https://example.com", "exactMatch", true], + ["https://example.com", "https://different.com", "exactMatch", false], + ["https://example.com/page", "example.com", "contains", true], + ["https://example.com", "different.com", "contains", false], + ["https://example.com/page", "https://example.com", "startsWith", true], + ["https://example.com", "https://different.com", "startsWith", false], + ["https://example.com/page", "page", "endsWith", true], + ["https://example.com/page", "different", "endsWith", false], + ["https://example.com", "https://different.com", "notMatch", true], + ["https://example.com", "https://example.com", "notMatch", false], + ["https://example.com", "different", "notContains", true], + ["https://example.com", "example", "notContains", false], + ]; + + test.each(testCases)("returns %s for %s with rule %s", (testUrl, pageUrlValue, pageUrlRule, expected) => { + expect(testURLmatch(testUrl, pageUrlValue, pageUrlRule, mockT)).toBe(expected); + }); + + describe("matchesRegex rule", () => { + test("returns true when URL matches regex pattern", () => { + expect(testURLmatch("https://example.com/user/123", "user/\\d+", "matchesRegex", mockT)).toBe(true); + expect(testURLmatch("https://example.com/dashboard", "dashboard$", "matchesRegex", mockT)).toBe(true); + expect(testURLmatch("https://app.example.com", "^https://app", "matchesRegex", mockT)).toBe(true); + }); + + test("returns false when URL does not match regex pattern", () => { + expect(testURLmatch("https://example.com/user/abc", "user/\\d+", "matchesRegex", mockT)).toBe(false); + expect(testURLmatch("https://example.com/settings", "dashboard$", "matchesRegex", mockT)).toBe(false); + expect(testURLmatch("https://api.example.com", "^https://app", "matchesRegex", mockT)).toBe(false); + }); + + test("throws error for invalid regex pattern", () => { + expect(() => testURLmatch("https://example.com", "[invalid-regex", "matchesRegex", mockT)).toThrow( + "Please use a valid regular expression." + ); + + expect(() => testURLmatch("https://example.com", "*invalid", "matchesRegex", mockT)).toThrow( + "Please use a valid regular expression." + ); + }); + }); + + test("throws an error for invalid match type", () => { + expect(() => + testURLmatch( + "https://example.com", + "https://example.com", + "invalidRule" as TActionClassPageUrlRule, + mockT + ) + ).toThrow("The option selected is not available."); + }); +}); + +describe("isValidCallbackUrl", () => { + const WEBAPP_URL = "https://webapp.example.com"; + + test("returns true for valid callback URL", () => { + expect(isValidCallbackUrl("https://webapp.example.com/callback", WEBAPP_URL)).toBe(true); + }); + + test("returns false for invalid scheme", () => { + expect(isValidCallbackUrl("ftp://webapp.example.com/callback", WEBAPP_URL)).toBe(false); + }); + + test("returns false for invalid domain", () => { + expect(isValidCallbackUrl("https://malicious.com/callback", WEBAPP_URL)).toBe(false); + }); + + test("returns false for malformed URL", () => { + expect(isValidCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBe(false); + }); +}); + +describe("isStringUrl", () => { + test("returns true for valid URL", () => { + expect(isStringUrl("https://example.com")).toBe(true); + }); + + test("returns false for invalid URL", () => { + expect(isStringUrl("not-a-valid-url")).toBe(false); + }); +}); diff --git a/apps/web/lib/utils/url.ts b/apps/web/lib/utils/url.ts new file mode 100644 index 000000000000..be4608a18f97 --- /dev/null +++ b/apps/web/lib/utils/url.ts @@ -0,0 +1,60 @@ +import { TActionClassPageUrlRule } from "@formbricks/types/action-classes"; + +export const testURLmatch = ( + testUrl: string, + pageUrlValue: string, + pageUrlRule: TActionClassPageUrlRule, + t: (key: string) => string +): boolean => { + let regex: RegExp; + + switch (pageUrlRule) { + case "exactMatch": + return testUrl === pageUrlValue; + case "contains": + return testUrl.includes(pageUrlValue); + case "startsWith": + return testUrl.startsWith(pageUrlValue); + case "endsWith": + return testUrl.endsWith(pageUrlValue); + case "notMatch": + return testUrl !== pageUrlValue; + case "notContains": + return !testUrl.includes(pageUrlValue); + case "matchesRegex": + try { + regex = new RegExp(pageUrlValue); + } catch { + throw new Error(t("environments.actions.invalid_regex")); + } + + return regex.test(testUrl); + default: + throw new Error(t("environments.actions.invalid_match_type")); + } +}; + +// Helper function to validate callback URLs +export const isValidCallbackUrl = (url: string, WEBAPP_URL: string): boolean => { + try { + const parsedUrl = new URL(url); + const allowedSchemes = ["https:", "http:"]; + + // Extract the domain from WEBAPP_URL + const parsedWebAppUrl = new URL(WEBAPP_URL); + const allowedDomains = [parsedWebAppUrl.hostname]; + + return allowedSchemes.includes(parsedUrl.protocol) && allowedDomains.includes(parsedUrl.hostname); + } catch (err) { + return false; + } +}; + +export const isStringUrl = (url: string): boolean => { + try { + new URL(url); + return true; + } catch { + return false; + } +}; diff --git a/apps/web/lib/utils/validate.test.ts b/apps/web/lib/utils/validate.test.ts new file mode 100644 index 000000000000..737779476cf9 --- /dev/null +++ b/apps/web/lib/utils/validate.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { z } from "zod"; +import { logger } from "@formbricks/logger"; +import { ValidationError } from "@formbricks/types/errors"; +import { validateInputs } from "./validate"; + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("validateInputs", () => { + test("validates inputs successfully", () => { + const schema = z.string(); + const result = validateInputs(["valid", schema]); + + expect(result).toEqual(["valid"]); + }); + + test("throws ValidationError for invalid inputs", () => { + const schema = z.string(); + + expect(() => validateInputs([123, schema])).toThrow(ValidationError); + expect(logger.error).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("Validation failed") + ); + }); + + test("validates multiple inputs successfully", () => { + const stringSchema = z.string(); + const numberSchema = z.number(); + + const result = validateInputs(["valid", stringSchema], [42, numberSchema]); + + expect(result).toEqual(["valid", 42]); + }); + + test("throws ValidationError for one of multiple invalid inputs", () => { + const stringSchema = z.string(); + const numberSchema = z.number(); + + expect(() => validateInputs(["valid", stringSchema], ["invalid", numberSchema])).toThrow(ValidationError); + expect(logger.error).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("Validation failed") + ); + }); +}); diff --git a/packages/lib/utils/validate.ts b/apps/web/lib/utils/validate.ts similarity index 100% rename from packages/lib/utils/validate.ts rename to apps/web/lib/utils/validate.ts diff --git a/apps/web/lib/utils/video-upload.test.ts b/apps/web/lib/utils/video-upload.test.ts new file mode 100644 index 000000000000..61cce8f629e2 --- /dev/null +++ b/apps/web/lib/utils/video-upload.test.ts @@ -0,0 +1,131 @@ +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { + checkForLoomUrl, + checkForVimeoUrl, + checkForYoutubeUrl, + convertToEmbedUrl, + extractLoomId, + extractVimeoId, + extractYoutubeId, +} from "./video-upload"; + +afterEach(() => { + cleanup(); +}); + +describe("checkForYoutubeUrl", () => { + test("returns true for valid YouTube URLs", () => { + expect(checkForYoutubeUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true); + expect(checkForYoutubeUrl("https://youtu.be/dQw4w9WgXcQ")).toBe(true); + expect(checkForYoutubeUrl("https://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true); + expect(checkForYoutubeUrl("https://youtube-nocookie.com/embed/dQw4w9WgXcQ")).toBe(true); + expect(checkForYoutubeUrl("https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ")).toBe(true); + expect(checkForYoutubeUrl("https://www.youtu.be/dQw4w9WgXcQ")).toBe(true); + }); + + test("returns false for invalid YouTube URLs", () => { + expect(checkForYoutubeUrl("https://www.invalid.com/watch?v=dQw4w9WgXcQ")).toBe(false); + expect(checkForYoutubeUrl("invalid-url")).toBe(false); + expect(checkForYoutubeUrl("http://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(false); // Non-HTTPS protocol + }); +}); + +describe("extractYoutubeId", () => { + test("extracts video ID from YouTube URLs", () => { + expect(extractYoutubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ"); + expect(extractYoutubeId("https://youtu.be/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ"); + expect(extractYoutubeId("https://youtube.com/embed/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ"); + expect(extractYoutubeId("https://youtube-nocookie.com/embed/dQw4w9WgXcQ")).toBe("dQw4w9WgXcQ"); + }); + + test("returns null for invalid YouTube URLs", () => { + expect(extractYoutubeId("https://www.invalid.com/watch?v=dQw4w9WgXcQ")).toBeNull(); + expect(extractYoutubeId("invalid-url")).toBeNull(); + expect(extractYoutubeId("https://youtube.com/notavalidpath")).toBeNull(); + }); +}); + +describe("convertToEmbedUrl", () => { + test("converts YouTube URL to embed URL", () => { + expect(convertToEmbedUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe( + "https://www.youtube.com/embed/dQw4w9WgXcQ" + ); + expect(convertToEmbedUrl("https://youtu.be/dQw4w9WgXcQ")).toBe( + "https://www.youtube.com/embed/dQw4w9WgXcQ" + ); + }); + + test("converts Vimeo URL to embed URL", () => { + expect(convertToEmbedUrl("https://vimeo.com/123456789")).toBe("https://player.vimeo.com/video/123456789"); + expect(convertToEmbedUrl("https://www.vimeo.com/123456789")).toBe( + "https://player.vimeo.com/video/123456789" + ); + }); + + test("converts Loom URL to embed URL", () => { + expect(convertToEmbedUrl("https://www.loom.com/share/abcdef123456")).toBe( + "https://www.loom.com/embed/abcdef123456" + ); + expect(convertToEmbedUrl("https://loom.com/share/abcdef123456")).toBe( + "https://www.loom.com/embed/abcdef123456" + ); + }); + + test("returns undefined for unsupported URLs", () => { + expect(convertToEmbedUrl("https://www.invalid.com/watch?v=dQw4w9WgXcQ")).toBeUndefined(); + expect(convertToEmbedUrl("invalid-url")).toBeUndefined(); + }); +}); + +// Testing private functions by importing them through the module system +describe("checkForVimeoUrl", () => { + test("returns true for valid Vimeo URLs", () => { + expect(checkForVimeoUrl("https://vimeo.com/123456789")).toBe(true); + expect(checkForVimeoUrl("https://www.vimeo.com/123456789")).toBe(true); + }); + + test("returns false for invalid Vimeo URLs", () => { + expect(checkForVimeoUrl("https://www.invalid.com/123456789")).toBe(false); + expect(checkForVimeoUrl("invalid-url")).toBe(false); + expect(checkForVimeoUrl("http://vimeo.com/123456789")).toBe(false); // Non-HTTPS protocol + }); +}); + +describe("checkForLoomUrl", () => { + test("returns true for valid Loom URLs", () => { + expect(checkForLoomUrl("https://loom.com/share/abcdef123456")).toBe(true); + expect(checkForLoomUrl("https://www.loom.com/share/abcdef123456")).toBe(true); + }); + + test("returns false for invalid Loom URLs", () => { + expect(checkForLoomUrl("https://www.invalid.com/share/abcdef123456")).toBe(false); + expect(checkForLoomUrl("invalid-url")).toBe(false); + expect(checkForLoomUrl("http://loom.com/share/abcdef123456")).toBe(false); // Non-HTTPS protocol + }); +}); + +describe("extractVimeoId", () => { + test("extracts video ID from Vimeo URLs", () => { + expect(extractVimeoId("https://vimeo.com/123456789")).toBe("123456789"); + expect(extractVimeoId("https://www.vimeo.com/123456789")).toBe("123456789"); + }); + + test("returns null for invalid Vimeo URLs", () => { + expect(extractVimeoId("https://www.invalid.com/123456789")).toBeNull(); + expect(extractVimeoId("invalid-url")).toBeNull(); + }); +}); + +describe("extractLoomId", () => { + test("extracts video ID from Loom URLs", () => { + expect(extractLoomId("https://loom.com/share/abcdef123456")).toBe("abcdef123456"); + expect(extractLoomId("https://www.loom.com/share/abcdef123456")).toBe("abcdef123456"); + }); + + test("returns null for invalid Loom URLs", async () => { + expect(extractLoomId("https://www.invalid.com/share/abcdef123456")).toBeNull(); + expect(extractLoomId("invalid-url")).toBeNull(); + expect(extractLoomId("https://loom.com/invalid/abcdef123456")).toBeNull(); + }); +}); diff --git a/apps/web/lib/utils/video-upload.ts b/apps/web/lib/utils/video-upload.ts new file mode 100644 index 000000000000..74ddddfc0373 --- /dev/null +++ b/apps/web/lib/utils/video-upload.ts @@ -0,0 +1,126 @@ +export const checkForYoutubeUrl = (url: string): boolean => { + try { + const youtubeUrl = new URL(url); + + if (youtubeUrl.protocol !== "https:") return false; + + const youtubeDomains = [ + "www.youtube.com", + "www.youtu.be", + "www.youtube-nocookie.com", + "youtube.com", + "youtu.be", + "youtube-nocookie.com", + ]; + const hostname = youtubeUrl.hostname; + + return youtubeDomains.includes(hostname); + } catch { + return false; + } +}; + +export const checkForVimeoUrl = (url: string): boolean => { + try { + const vimeoUrl = new URL(url); + + if (vimeoUrl.protocol !== "https:") return false; + + const vimeoDomains = ["www.vimeo.com", "vimeo.com"]; + const hostname = vimeoUrl.hostname; + + return vimeoDomains.includes(hostname); + } catch { + return false; + } +}; + +export const checkForLoomUrl = (url: string): boolean => { + try { + const loomUrl = new URL(url); + + if (loomUrl.protocol !== "https:") return false; + + const loomDomains = ["www.loom.com", "loom.com"]; + const hostname = loomUrl.hostname; + + return loomDomains.includes(hostname); + } catch { + return false; + } +}; + +export const extractYoutubeId = (url: string): string | null => { + let id = ""; + + // Regular expressions for various YouTube URL formats + const regExpList = [ + /youtu\.be\/([a-zA-Z0-9_-]+)/, // youtu.be/ + /youtube\.com.*v=([a-zA-Z0-9_-]+)/, // youtube.com/watch?v= + /youtube\.com.*embed\/([a-zA-Z0-9_-]+)/, // youtube.com/embed/ + /youtube-nocookie\.com\/embed\/([a-zA-Z0-9_-]+)/, // youtube-nocookie.com/embed/ + ]; + + regExpList.some((regExp) => { + const match = regExp.exec(url); + if (match?.[1]) { + id = match[1]; + return true; + } + return false; + }); + + return id || null; +}; + +export const extractVimeoId = (url: string): string | null => { + const regExp = /vimeo\.com\/(\d+)/; + const match = regExp.exec(url); + + if (match?.[1]) { + return match[1]; + } + + return null; +}; + +export const extractLoomId = (url: string): string | null => { + const regExp = /loom\.com\/share\/([a-zA-Z0-9]+)/; + const match = regExp.exec(url); + + if (match?.[1]) { + return match[1]; + } + + return null; +}; + +// Always convert a given URL into its embed form if supported. +export const convertToEmbedUrl = (url: string): string | undefined => { + // YouTube + if (checkForYoutubeUrl(url)) { + const videoId = extractYoutubeId(url); + if (videoId) { + return `https://www.youtube.com/embed/${videoId}`; + } + } + + // Vimeo + if (checkForVimeoUrl(url)) { + const videoId = extractVimeoId(url); + if (videoId) { + return `https://player.vimeo.com/video/${videoId}`; + } + } + + // Loom + if (checkForLoomUrl(url)) { + const videoId = extractLoomId(url); + if (videoId) { + return `https://www.loom.com/embed/${videoId}`; + } + } + + // If no supported platform found, return undefined + return undefined; +}; diff --git a/packages/lib/messages/de-DE.json b/apps/web/locales/de-DE.json similarity index 90% rename from packages/lib/messages/de-DE.json rename to apps/web/locales/de-DE.json index 55ac579e595b..43409a54df69 100644 --- a/packages/lib/messages/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -1,12 +1,23 @@ { "auth": { - "continue_with_azure": "Login mit Azure", + "continue_with_azure": "Weiter mit Microsoft", "continue_with_email": "Login mit E-Mail", "continue_with_github": "Login mit GitHub", "continue_with_google": "Login mit Google", "continue_with_oidc": "Weiter mit {oidcDisplayName}", "continue_with_openid": "Login mit OpenID", "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_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" + }, "forgot-password": { "back_to_login": "Zurück zum Login", "email-sent": { @@ -23,7 +34,8 @@ "text": "Du kannst Dich jetzt mit deinem neuen Passwort einloggen" } }, - "reset_password": "Passwort zurücksetzen" + "reset_password": "Passwort zurücksetzen", + "reset_password_description": "Du wirst abgemeldet, um dein Passwort zurückzusetzen." }, "invite": { "create_account": "Konto erstellen", @@ -68,7 +80,7 @@ }, "signup_without_verification_success": { "user_successfully_created": "Benutzer erfolgreich erstellt", - "user_successfully_created_description": "Dein neuer Benutzer wurde erfolgreich erstellt. Bitte klicke auf den untenstehenden Button und melde Dich in deinem Konto an." + "user_successfully_created_info": "Wir haben nach einem Konto gesucht, das mit {email} verknüpft ist. Wenn keines existierte, haben wir eines für Dich erstellt. Wenn bereits ein Konto existierte, wurden keine Änderungen vorgenommen. Bitte melde Dich unten an, um fortzufahren." }, "testimonial_1": "Als open-source Firma ist uns Datenschutz extrem wichtig! Formbricks bietet die perfekte Mischung aus modernster Technologie und solidem Datenschutz.", "testimonial_all_features_included": "Alle Funktionen enthalten", @@ -78,12 +90,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 erfolgreich gesendet. Bitte überprüfe dein Postfach.", - "we_sent_an_email_to": "Wir haben eine E-Mail an {email} gesendet", + "verification_email_resent_successfully": "Bestätigungs-E-Mail gesendet! Bitte überprüfe dein Postfach.", + "verification_email_successfully_sent_info": "Wenn ein Konto mit {email} verknüpft ist, haben wir einen Bestätigungslink an diese Adresse gesendet. Bitte überprüfe dein Postfach, um die Anmeldung abzuschließen.", "you_didnt_receive_an_email_or_your_link_expired": "Hast Du keine E-Mail erhalten oder ist dein Link abgelaufen?" }, "verify": { @@ -96,6 +108,10 @@ "thanks_for_upgrading": "Vielen Dank, dass Du dein Formbricks-Abonnement aktualisiert hast.", "upgrade_successful": "Upgrade erfolgreich" }, + "c": { + "link_expired": "Dein Link ist abgelaufen.", + "link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig." + }, "common": { "accepted": "Akzeptiert", "account": "Konto", @@ -108,6 +124,7 @@ "add_action": "Aktion hinzufügen", "add_filter": "Filter hinzufügen", "add_logo": "Logo hinzufügen", + "add_member": "Mitglied hinzufügen", "add_project": "Projekt hinzufügen", "add_to_team": "Zum Team hinzufügen", "all": "Alle", @@ -123,7 +140,6 @@ "app_survey": "App-Umfrage", "apply_filters": "Filter anwenden", "are_you_sure": "Bist Du sicher?", - "are_you_sure_this_action_cannot_be_undone": "Bist Du sicher? Diese Aktion kann nicht rückgängig gemacht werden.", "attributes": "Attribute", "avatar": "Avatar", "back": "Zurück", @@ -149,11 +165,13 @@ "connect_formbricks": "Formbricks verbinden", "connected": "Verbunden", "contacts": "Kontakte", + "copied": "Kopiert", "copied_to_clipboard": "In die Zwischenablage kopiert", "copy": "Kopieren", "copy_code": "Code kopieren", "copy_link": "Link kopieren", "create_new_organization": "Neue Organisation erstellen", + "create_project": "Projekt erstellen", "create_segment": "Segment erstellen", "create_survey": "Umfrage erstellen", "created": "Erstellt", @@ -180,13 +198,10 @@ "e_commerce": "E-Commerce", "edit": "Bearbeiten", "email": "E-Mail", - "embed": "Einbetten", "enterprise_license": "Enterprise Lizenz", "environment_not_found": "Umgebung nicht gefunden", "environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.", "error": "Fehler", - "error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.", - "error_component_title": "Fehler beim Laden der Ressourcen", "expand_rows": "Zeilen erweitern", "finish": "Fertigstellen", "follow_these": "Folge diesen", @@ -209,7 +224,6 @@ "in_progress": "Im Gange", "inactive_surveys": "Inaktive Umfragen", "input_type": "Eingabetyp", - "insights": "Einblicke", "integration": "Integration", "integrations": "Integrationen", "invalid_date": "Ungültiges Datum", @@ -225,7 +239,6 @@ "limits_reached": "Limits erreicht", "link": "Link", "link_and_email": "Link & E-Mail", - "link_copied": "Link in die Zwischenablage kopiert!", "link_survey": "Link-Umfrage", "link_surveys": "Umfragen verknüpfen", "load_more": "Mehr laden", @@ -246,8 +259,6 @@ "move_up": "Nach oben bewegen", "multiple_languages": "Mehrsprachigkeit", "name": "Name", - "negative": "Negativ", - "neutral": "Neutral", "new": "Neu", "new_survey": "Neue Umfrage", "new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!", @@ -269,6 +280,8 @@ "on": "An", "only_one_file_allowed": "Es ist nur eine Datei erlaubt", "only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.", + "option_id": "Option-ID", + "option_ids": "Option-IDs", "or": "oder", "organization": "Organisation", "organization_id": "Organisations-ID", @@ -285,32 +298,34 @@ "phone": "Handy", "photo_by": "Foto von", "pick_a_date": "Wähl ein Datum", + "picture": "Bild", "placeholder": "Platzhalter", "please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus", "please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus", "please_upgrade_your_plan": "Bitte upgrade deinen Plan.", - "positive": "Positiv", "preview": "Vorschau", "preview_survey": "Umfragevorschau", "privacy": "Datenschutz", - "privacy_policy": "Datenschutzerklärung", "product_manager": "Produktmanager", "profile": "Profil", - "project": "Projekt", + "profile_id": "Profil-ID", "project_configuration": "Projektkonfiguration", + "project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.", "project_id": "Projekt-ID", "project_name": "Projektname", + "project_name_placeholder": "z.B. Formbricks", "project_not_found": "Projekt nicht gefunden", "project_permission_not_found": "Projekt-Berechtigung nicht gefunden", "projects": "Projekte", - "projects_limit_reached": "Projektlimit erreicht", "question": "Frage", "question_id": "Frage-ID", "questions": "Fragen", "read_docs": "Dokumentation lesen", + "recipients": "Empfänger", "remove": "Entfernen", "reorder_and_hide_columns": "Spalten neu anordnen und ausblenden", "report_survey": "Umfrage melden", + "request_pricing": "Preise anfragen", "request_trial_license": "Testlizenz anfordern", "reset_to_default": "Auf Standard zurücksetzen", "response": "Antwort", @@ -330,6 +345,7 @@ "select": "Auswählen", "select_all": "Alles auswählen", "select_survey": "Umfrage auswählen", + "select_teams": "Teams auswählen", "selected": "Ausgewählt", "selected_questions": "Ausgewählte Fragen", "selection": "Auswahl", @@ -346,6 +362,7 @@ "skipped": "Übersprungen", "skips": "Übersprungen", "some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden", + "something_went_wrong": "Etwas ist schiefgelaufen", "something_went_wrong_please_try_again": "Etwas ist schiefgelaufen. Bitte versuche es noch einmal.", "sort_by": "Sortieren nach", "start_free_trial": "Kostenlos starten", @@ -372,6 +389,7 @@ "targeting": "Targeting", "team": "Team", "team_access": "Teamzugriff", + "team_id": "Team-ID", "team_name": "Teamname", "teams": "Zugriffskontrolle", "teams_not_found": "Teams nicht gefunden", @@ -404,9 +422,7 @@ "website_and_app_connection": "Website & App Verbindung", "website_app_survey": "Website- & App-Umfrage", "website_survey": "Website-Umfrage", - "weekly_summary": "Wöchentliche Zusammenfassung", "welcome_card": "Willkommenskarte", - "yes": "Ja", "you": "Du", "you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.", "you_are_not_authorised_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion auszuführen.", @@ -446,41 +462,13 @@ "invite_email_text_par1": "Dein Kollege", "invite_email_text_par2": "hat Dich eingeladen, Formbricks zu nutzen. Um die Einladung anzunehmen, klicke bitte auf den untenstehenden Link:", "invite_member_email_subject": "Du wurdest eingeladen, Formbricks zu nutzen!", - "live_survey_notification_completed": "Abgeschlossen", - "live_survey_notification_draft": "Entwurf", - "live_survey_notification_in_progress": "In Bearbeitung", - "live_survey_notification_no_new_response": "Diese Woche keine neue Antwort erhalten \uD83D\uDD75️", - "live_survey_notification_no_responses_yet": "Noch keine Antworten!", - "live_survey_notification_paused": "Pausiert", - "live_survey_notification_scheduled": "Geplant", - "live_survey_notification_view_more_responses": "Zeige {responseCount} weitere Antworten", - "live_survey_notification_view_previous_responses": "Vorherige Antworten anzeigen", - "live_survey_notification_view_response": "Antwort anzeigen", - "notification_footer_all_the_best": "Alles Gute,", - "notification_footer_in_your_settings": "in deinen Einstellungen \uD83D\uDE4F", - "notification_footer_please_turn_them_off": "Bitte ausstellen", - "notification_footer_the_formbricks_team": "Dein Formbricks Team \uD83E\uDD0D", - "notification_footer_to_halt_weekly_updates": "Um wöchentliche Updates zu stoppen,", - "notification_header_hey": "Hey \uD83D\uDC4B", - "notification_header_weekly_report_for": "Wöchentlicher Bericht für", - "notification_insight_completed": "Abgeschlossen", - "notification_insight_completion_rate": "Completion Rate %", - "notification_insight_displays": "Displays", - "notification_insight_responses": "Antworten", - "notification_insight_surveys": "Umfragen", - "onboarding_invite_email_button_label": "Tritt {inviterName}s Organisation bei", - "onboarding_invite_email_connect_formbricks": "Verbinde Formbricks in nur wenigen Minuten über ein HTML-Snippet oder via NPM mit deiner App oder Website.", - "onboarding_invite_email_create_account": "Erstelle ein Konto, um {inviterName}s Organisation beizutreten.", - "onboarding_invite_email_done": "Erledigt ✅", - "onboarding_invite_email_get_started_in_minutes": "Dauert nur wenige Minuten", - "onboarding_invite_email_heading": "Hey ", - "onboarding_invite_email_subject": "{inviterName} braucht Hilfe bei Formbricks. Kannst Du ihm helfen?", + "new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:", "password_changed_email_heading": "Passwort geändert", "password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.", "password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert", - "powered_by_formbricks": "Unterstützt von Formbricks", "privacy_policy": "Datenschutzerklärung", "reject": "Ablehnen", + "render_email_response_value_file_upload_response_link_not_included": "Link zur hochgeladenen Datei ist aus Datenschutzgründen nicht enthalten", "response_finished_email_subject": "Eine Antwort für {surveyName} wurde abgeschlossen ✅", "response_finished_email_subject_with_email": "{personEmail} hat deine Umfrage {surveyName} abgeschlossen ✅", "schedule_your_meeting": "Termin planen", @@ -505,14 +493,9 @@ "verification_email_thanks": "Danke, dass Du deine E-Mail bestätigt hast!", "verification_email_to_fill_survey": "Um die Umfrage auszufüllen, klicke bitte auf den untenstehenden Button:", "verification_email_verify_email": "E-Mail bestätigen", - "verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen.", - "weekly_summary_create_reminder_notification_body_cal_slot": "Wähle einen 15-minütigen Termin im Kalender unseres Gründers aus.", - "weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Lass keine Woche vergehen, ohne etwas über deine Nutzer zu lernen:", - "weekly_summary_create_reminder_notification_body_need_help": "Brauchst Du Hilfe, die richtige Umfrage für dein Produkt zu finden?", - "weekly_summary_create_reminder_notification_body_reply_email": "oder antworte auf diese E-Mail :)", - "weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Neue Umfrage einrichten", - "weekly_summary_create_reminder_notification_body_text": "Wir würden dir gerne eine wöchentliche Zusammenfassung schicken, aber momentan laufen keine Umfragen für {projectName}.", - "weekly_summary_email_subject": "{projectName} Nutzer-Insights – Letzte Woche von Formbricks" + "verification_new_email_subject": "E-Mail-Änderungsbestätigung", + "verification_security_notice": "Wenn du diese E-Mail-Änderung nicht angefordert hast, ignoriere bitte diese E-Mail oder kontaktiere sofort den Support.", + "verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen." }, "environments": { "actions": { @@ -525,21 +508,21 @@ "action_with_key_already_exists": "Aktion mit dem Schlüssel {key} existiert bereits", "action_with_name_already_exists": "Aktion mit dem Namen {name} existiert bereits", "add_css_class_or_id": "CSS-Klasse oder ID hinzufügen", + "add_regular_expression_here": "Fügen Sie hier einen regulären Ausdruck hinzu", "add_url": "URL hinzufügen", "click": "Klicken", "contains": "enthält", "create_action": "Aktion erstellen", "css_selector": "CSS-Selektor", "delete_action_text": "Bist Du sicher, dass Du diese Aktion löschen möchtest? Dadurch wird diese Aktion auch als Auslöser aus all deinen Umfragen entfernt.", - "display_name": "Anzeigename", "does_not_contain": "Enthält nicht", "does_not_exactly_match": "Stimmt nicht genau überein", "eg_clicked_download": "z.B. 'Herunterladen' geklickt", "eg_download_cta_click_on_home": "z.B. Download-CTA-Klick auf der Startseite", "eg_install_app": "z.B. App installieren", - "eg_user_clicked_download_button": "z.B. Benutzer hat auf 'Herunterladen' geklickt", "ends_with": "endet mit", "enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Teste eine URL, um zu sehen, ob der Nutzer deine Umfrage sehen würde.", + "enter_url": "z.B. https://app.com/dashboard", "exactly_matches": "Stimmt exakt überein", "exit_intent": "Will Seite verlassen", "fifty_percent_scroll": "50% Scroll", @@ -548,9 +531,14 @@ "if_a_user_clicks_a_button_with_a_specific_text": "Wenn ein Benutzer auf einen Button mit einem bestimmten Text klickt", "in_your_code_read_more_in_our": "in deinem Code. Lies mehr in unserem", "inner_text": "Innerer Text", + "invalid_action_type_code": "Ungültiger Aktionstyp für Code-Aktion", + "invalid_action_type_no_code": "Ungültiger Aktionstyp für NoCode-Aktion", "invalid_css_selector": "Ungültiger CSS-Selektor", + "invalid_match_type": "Die ausgewählte Option ist nicht verfügbar.", + "invalid_regex": "Bitte verwenden Sie einen gültigen regulären Ausdruck.", "limit_the_pages_on_which_this_action_gets_captured": "Begrenze die Seiten, auf denen diese Aktion erfasst wird", "limit_to_specific_pages": "Auf bestimmte Seiten beschränken", + "matches_regex": "Entspricht Regex", "on_all_pages": "Auf allen Seiten", "page_filter": "Seitenfilter", "page_view": "Seitenansicht", @@ -570,7 +558,9 @@ "user_clicked_download_button": "Benutzer hat auf 'Herunterladen' geklickt", "what_did_your_user_do": "Was hat dein Nutzer gemacht?", "what_is_the_user_doing": "Was macht der Nutzer?", - "you_can_track_code_action_anywhere_in_your_app_using": "Du kannst Code-Aktionen überall in deiner App tracken mit" + "you_can_track_code_action_anywhere_in_your_app_using": "Du kannst Code-Aktionen überall in deiner App tracken mit", + "your_survey_would_be_shown_on_this_url": "Ihre Umfrage wäre unter dieser URL angezeigt.", + "your_survey_would_not_be_shown": "Ihre Umfrage wäre nicht angezeigt." }, "connect": { "congrats": "Glückwunsch!", @@ -587,8 +577,8 @@ "contact_deleted_successfully": "Kontakt erfolgreich gelöscht", "contact_not_found": "Kein solcher Kontakt gefunden", "contacts_table_refresh": "Kontakte aktualisieren", - "contacts_table_refresh_error": "Beim Aktualisieren der Kontakte ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", "contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert", + "delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.", "first_name": "Vorname", "last_name": "Nachname", "no_responses_found": "Keine Antworten gefunden", @@ -616,33 +606,6 @@ "upload_contacts_modal_preview": "Hier ist eine Vorschau deiner Daten.", "upload_contacts_modal_upload_btn": "Kontakte hochladen" }, - "experience": { - "all": "Alle", - "all_time": "Gesamt", - "analysed_feedbacks": "Analysierte Rückmeldungen", - "category": "Kategorie", - "category_updated_successfully": "Kategorie erfolgreich aktualisiert!", - "complaint": "Beschwerde", - "did_you_find_this_insight_helpful": "War diese Erkenntnis hilfreich?", - "failed_to_update_category": "Kategorie konnte nicht aktualisiert werden", - "feature_request": "Anfrage", - "good_afternoon": "\uD83C\uDF24️ Guten Nachmittag", - "good_evening": "\uD83C\uDF19 Guten Abend", - "good_morning": "☀️ Guten Morgen", - "insights_description": "Erkenntnisse, die aus den Antworten aller Umfragen gewonnen wurden", - "insights_for_project": "Einblicke für {projectName}", - "new_responses": "Neue Antworten", - "no_insights_for_this_filter": "Keine Erkenntnisse für diesen Filter", - "no_insights_found": "Keine Erkenntnisse gefunden. Sammle mehr Umfrageantworten oder aktiviere Erkenntnisse für deine bestehenden Umfragen, um loszulegen.", - "praise": "Lob", - "sentiment_score": "Stimmungswert", - "templates_card_description": "Wähle deine Vorlage oder starte von Grund auf neu", - "templates_card_title": "Miss die Kundenerfahrung", - "this_month": "Dieser Monat", - "this_quarter": "Dieses Quartal", - "this_week": "Diese Woche", - "today": "Heute" - }, "formbricks_logo": "Formbricks-Logo", "integrations": { "activepieces_integration_description": "Verbinde Formbricks sofort mit beliebten Apps, um Aufgaben ohne Programmierung zu automatisieren.", @@ -652,6 +615,7 @@ "airtable_integration": "Airtable Integration", "airtable_integration_description": "Synchronisiere Antworten direkt mit Airtable.", "airtable_integration_is_not_configured": "Airtable Integration ist nicht konfiguriert", + "airtable_logo": "Airtable-Logo", "connect_with_airtable": "Mit Airtable verbinden", "link_airtable_table": "Airtable Tabelle verknüpfen", "link_new_table": "Neue Tabelle verknüpfen", @@ -719,7 +683,6 @@ "select_a_database": "Datenbank auswählen", "select_a_field_to_map": "Wähle ein Feld zum Zuordnen aus", "select_a_survey_question": "Wähle eine Umfragefrage aus", - "sync_responses_with_a_notion_database": "Antworten mit einer Datenbank in Notion synchronisieren", "update_connection": "Notion erneut verbinden", "update_connection_tooltip": "Verbinde die Integration erneut, um neu hinzugefügte Datenbanken einzuschließen. Deine bestehenden Integrationen bleiben erhalten." }, @@ -741,6 +704,7 @@ "slack_integration": "Slack Integration", "slack_integration_description": "Sende Antworten direkt an Slack.", "slack_integration_is_not_configured": "Slack Integration ist in deiner Instanz von Formbricks nicht konfiguriert.", + "slack_logo": "Slack-Logo", "slack_reconnect_button": "Erneut verbinden", "slack_reconnect_button_description": "Hinweis: Wir haben kürzlich unsere Slack-Integration geändert, um auch private Kanäle zu unterstützen. Bitte verbinden Sie Ihren Slack-Workspace erneut." }, @@ -777,6 +741,7 @@ }, "project": { "api_keys": { + "access_control": "Zugriffskontrolle", "add_api_key": "API-Schlüssel hinzufügen", "api_key": "API-Schlüssel", "api_key_copied_to_clipboard": "API-Schlüssel in die Zwischenablage kopiert", @@ -784,9 +749,12 @@ "api_key_deleted": "API-Schlüssel gelöscht", "api_key_label": "API-Schlüssel Label", "api_key_security_warning": "Aus Sicherheitsgründen wird der API-Schlüssel nur einmal nach der Erstellung angezeigt. Bitte kopiere ihn sofort an einen sicheren Ort.", + "api_key_updated": "API-Schlüssel aktualisiert", "duplicate_access": "Doppelter Projektzugriff nicht erlaubt", "no_api_keys_yet": "Du hast noch keine API-Schlüssel", + "no_env_permissions_found": "Keine Umgebungsberechtigungen gefunden", "organization_access": "Organisationszugang", + "organization_access_description": "Wähle Lese- oder Schreibrechte für organisationsweite Ressourcen aus.", "permissions": "Berechtigungen", "project_access": "Projektzugriff", "secret": "Geheimnis", @@ -796,6 +764,8 @@ "api_host_description": "Dies ist die URL deines Formbricks Backends.", "app_connection": "App-Verbindung", "app_connection_description": "Verbinde deine App mit Formbricks.", + "cache_update_delay_description": "Wenn du Aktualisierungen an Umfragen, Kontakten, Aktionen oder anderen Daten vornimmst, kann es bis zu 5 Minuten dauern, bis diese Änderungen in deiner lokalen App, die das Formbricks SDK verwendet, angezeigt werden. Diese Verzögerung ist auf eine Einschränkung unseres aktuellen Caching-Systems zurückzuführen. Wir arbeiten aktiv an einer Überarbeitung des Cache und werden in Formbricks 4.0 eine Lösung veröffentlichen.", + "cache_update_delay_title": "Änderungen werden aufgrund von Caching nach 5 Minuten angezeigt", "check_out_the_docs": "Schau dir die Docs an.", "dive_into_the_docs": "Tauch in die Docs ein.", "does_your_widget_work": "Funktioniert dein Widget?", @@ -921,8 +891,7 @@ "tag_already_exists": "Tag existiert bereits", "tag_deleted": "Tag gelöscht", "tag_updated": "Tag aktualisiert", - "tags_merged": "Tags zusammengeführt", - "unique_constraint_failed_on_the_fields": "Eindeutige Einschränkung für die Felder fehlgeschlagen" + "tags_merged": "Tags zusammengeführt" }, "teams": { "manage_teams": "Teams verwalten", @@ -970,6 +939,7 @@ "save_your_filters_as_a_segment_to_use_it_in_other_surveys": "Speichere deine Filter als Segment, um sie in anderen Umfragen zu verwenden", "segment_created_successfully": "Segment erfolgreich erstellt", "segment_deleted_successfully": "Segment erfolgreich gelöscht", + "segment_id": "Segment-ID", "segment_saved_successfully": "Segment erfolgreich gespeichert", "segment_updated_successfully": "Segment erfolgreich aktualisiert", "segments_help_you_target_users_with_same_characteristics_easily": "Segmente helfen dir, Nutzer mit denselben Merkmalen zu erreichen", @@ -991,67 +961,56 @@ "api_keys": { "add_api_key": "API-Schlüssel hinzufügen", "add_permission": "Berechtigung hinzufügen", - "api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen", - "only_organization_owners_and_managers_can_manage_api_keys": "Nur Organisationsinhaber und -manager können API-Schlüssel verwalten" + "api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen" }, "billing": { - "10000_monthly_responses": "10,000 monatliche Antworten", - "1500_monthly_responses": "1,500 monatliche Antworten", - "2000_monthly_identified_users": "2,000 monatlich identifizierte Nutzer", - "30000_monthly_identified_users": "30,000 monatlich identifizierte Nutzer", + "1000_monthly_responses": "1,000 monatliche Antworten", + "1_project": "1 Projekt", + "2000_contacts": "2,000 Kontakte", "3_projects": "3 Projekte", "5000_monthly_responses": "5,000 monatliche Antworten", - "5_projects": "5 Projekte", - "7500_monthly_identified_users": "7,500 monatlich identifizierte Nutzer", - "advanced_targeting": "Erweitertes Targeting", + "7500_contacts": "7,500 Kontakte", "all_integrations": "Alle Integrationen", - "all_surveying_features": "Alle Umfragefunktionen", "annually": "Jährlich", "api_webhooks": "API & Webhooks", "app_surveys": "In-app Umfragen", - "contact_us": "Kontaktiere uns", + "attribute_based_targeting": "Attributbasiertes Targeting", "current": "aktuell", "current_plan": "Aktueller Plan", "current_tier_limit": "Aktuelles Limit", - "custom_miu_limit": "Benutzerdefiniertes MIU-Limit", + "custom": "Benutzerdefiniert & Skalierung", + "custom_contacts_limit": "Benutzerdefiniertes Kontaktlimit", "custom_project_limit": "Benutzerdefiniertes Projektlimit", - "customer_success_manager": "Customer Success Manager", + "custom_response_limit": "Benutzerdefiniertes Antwortlimit", "email_embedded_surveys": "Eingebettete Umfragen in E-Mails", - "email_support": "E-Mail-Support", - "enterprise": "Enterprise", + "email_follow_ups": "E-Mail Follow-ups", "enterprise_description": "Premium-Support und benutzerdefinierte Limits.", "everybody_has_the_free_plan_by_default": "Jeder hat standardmäßig den kostenlosen Plan!", "everything_in_free": "Alles in 'Free''", - "everything_in_scale": "Alles in 'Scale''", "everything_in_startup": "Alles in 'Startup''", "free": "Kostenlos", "free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.", "get_2_months_free": "2 Monate gratis", "get_in_touch": "Kontaktiere uns", + "hosted_in_frankfurt": "Gehostet in Frankfurt", + "ios_android_sdks": "iOS & Android SDK für mobile Umfragen", "link_surveys": "Umfragen verlinken (teilbar)", "logic_jumps_hidden_fields_recurring_surveys": "Logik, versteckte Felder, wiederkehrende Umfragen, usw.", "manage_card_details": "Karteninformationen verwalten", "manage_subscription": "Abonnement verwalten", "monthly": "Monatlich", "monthly_identified_users": "Monatlich identifizierte Nutzer", - "multi_language_surveys": "Mehrsprachige Umfragen", "per_month": "pro Monat", "per_year": "pro Jahr", "plan_upgraded_successfully": "Plan erfolgreich aktualisiert", "premium_support_with_slas": "Premium-Support mit SLAs", - "priority_support": "Priorisierter Support", "remove_branding": "Branding entfernen", - "say_hi": "Sag Hi!", - "scale": "Scale", - "scale_description": "Erweiterte Funktionen für größere Unternehmen.", "startup": "Start-up", "startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.", "switch_plan": "Plan wechseln", "switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.", "team_access_roles": "Rollen für Teammitglieder", - "technical_onboarding": "Technische Einführung", "unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden", - "unlimited_apps_websites": "Unbegrenzte Apps & Websites", "unlimited_miu": "Unbegrenzte MIU", "unlimited_projects": "Unbegrenzte Projekte", "unlimited_responses": "Unbegrenzte Antworten", @@ -1062,7 +1021,6 @@ "website_surveys": "Website-Umfragen" }, "enterprise": { - "ai": "KI-Analyse", "audit_logs": "Audit Logs", "coming_soon": "Kommt bald", "contacts_and_segments": "Kontaktverwaltung & Segmente", @@ -1091,6 +1049,7 @@ "create_new_organization": "Neue Organisation erstellen", "create_new_organization_description": "Erstelle eine neue Organisation, um weitere Projekte zu verwalten.", "customize_email_with_a_higher_plan": "E-Mail-Anpassung mit einem höheren Plan", + "delete_member_confirmation": "Gelöschte Mitglieder verlieren den Zugriff auf alle Projekte und Umfragen deiner Organisation.", "delete_organization": "Organisation löschen", "delete_organization_description": "Organisation mit allen Projekten einschließlich aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen", "delete_organization_warning": "Bevor Du mit dem Löschen dieser Organisation fortfährst, sei dir bitte der folgenden Konsequenzen bewusst:", @@ -1100,13 +1059,7 @@ "eliminate_branding_with_whitelabel": "Entferne Formbricks Branding und aktiviere zusätzliche White-Label-Anpassungsoptionen.", "email_customization_preview_email_heading": "Hey {userName}", "email_customization_preview_email_text": "Dies ist eine E-Mail-Vorschau, um dir zu zeigen, welches Logo in den E-Mails gerendert wird.", - "enable_formbricks_ai": "Formbricks KI aktivieren", "error_deleting_organization_please_try_again": "Fehler beim Löschen der Organisation. Bitte versuche es erneut.", - "formbricks_ai": "Formbricks KI", - "formbricks_ai_description": "Erhalte personalisierte Einblicke aus deinen Umfrageantworten mit Formbricks KI", - "formbricks_ai_disable_success_message": "Formbricks KI wurde erfolgreich deaktiviert.", - "formbricks_ai_enable_success_message": "Formbricks KI erfolgreich aktiviert.", - "formbricks_ai_privacy_policy_text": "Durch die Aktivierung von Formbricks KI stimmst Du den aktualisierten", "from_your_organization": "von deiner Organisation", "invitation_sent_once_more": "Einladung nochmal gesendet.", "invite_deleted_successfully": "Einladung erfolgreich gelöscht", @@ -1153,10 +1106,8 @@ "need_slack_or_discord_notifications": "Brauchst Du Slack- oder Discord-Benachrichtigungen", "notification_settings_updated": "Benachrichtigungseinstellungen aktualisiert", "set_up_an_alert_to_get_an_email_on_new_responses": "Richte eine Benachrichtigung ein, um eine E-Mail bei neuen Antworten zu erhalten", - "stay_up_to_date_with_a_Weekly_every_Monday": "Bleib auf dem Laufenden mit einem wöchentlichen Update jeden Montag", "use_the_integration": "Integration nutzen", "want_to_loop_in_organization_mates": "Willst Du die Organisationskollegen einbeziehen?", - "weekly_summary_projects": "Wöchentliche Zusammenfassung (Projekte)", "you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Du wirst nicht mehr automatisch zu den Umfragen dieser Organisation angemeldet!", "you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Du wirst keine weiteren E-Mails für Antworten auf diese Umfrage erhalten!" }, @@ -1172,6 +1123,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.", @@ -1252,8 +1204,9 @@ "copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung", "copy_survey_error": "Kopieren der Umfrage fehlgeschlagen", "copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren", + "copy_survey_partially_success": "{success} Umfragen erfolgreich kopiert, {error} fehlgeschlagen.", "copy_survey_success": "Umfrage erfolgreich kopiert!", - "delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.", + "delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?", "edit": { "1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:", "2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:", @@ -1273,6 +1226,8 @@ "add_description": "Beschreibung hinzufügen", "add_ending": "Abschluss hinzufügen", "add_ending_below": "Abschluss unten hinzufügen", + "add_fallback": "Hinzufügen", + "add_fallback_placeholder": "Hinzufügen eines Platzhalters, der angezeigt wird, wenn die Frage übersprungen wird:", "add_hidden_field_id": "Verstecktes Feld ID hinzufügen", "add_highlight_border": "Rahmen hinzufügen", "add_highlight_border_description": "Füge deiner Umfragekarte einen äußeren Rahmen hinzu.", @@ -1311,8 +1266,6 @@ "automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Umfrage automatisch zu Beginn des Tages (UTC) freigeben.", "back_button_label": "Zurück\"- Button ", "background_styling": "Hintergründe", - "blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Blockiert die Umfrage, wenn bereits eine Antwort mit der Single Use Id (suId) existiert.", - "blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Blockiert Umfrage, wenn die Umfrage-URL keine Single-Use-ID (suId) hat.", "brand_color": "Markenfarbe", "brightness": "Helligkeit", "button_label": "Beschriftung", @@ -1326,14 +1279,21 @@ "card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen", "card_background_color": "Hintergrundfarbe der Karte", "card_border_color": "Farbe des Kartenrandes", - "card_shadow_color": "Farbton des Kartenschattens", "card_styling": "Kartenstil", "casual": "Lässig", + "caution_edit_duplicate": "Duplizieren & bearbeiten", + "caution_edit_published_survey": "Eine veröffentlichte Umfrage bearbeiten?", + "caution_explanation_intro": "Wir verstehen, dass du vielleicht noch Änderungen vornehmen möchtest. Hier erfährst du, was passiert, wenn du das tust:", + "caution_explanation_new_responses_separated": "Antworten vor der Änderung werden möglicherweise nicht oder nur teilweise in der Umfragezusammenfassung berücksichtigt.", + "caution_explanation_only_new_responses_in_summary": "Alle Daten, einschließlich früherer Antworten, bleiben auf der Umfrageübersichtsseite als Download verfügbar.", + "caution_explanation_responses_are_safe": "Ältere und neuere Antworten vermischen sich, was zu irreführenden Datensummen führen kann.", + "caution_recommendation": "Dies kann im Umfrageübersicht zu Dateninkonsistenzen führen. Wir empfehlen stattdessen, die Umfrage zu duplizieren.", "caution_text": "Änderungen werden zu Inkonsistenzen führen", "centered_modal_overlay_color": "Zentrierte modale Überlagerungsfarbe", "change_anyway": "Trotzdem ändern", "change_background": "Hintergrund ändern", "change_question_type": "Fragetyp ändern", + "change_survey_type": "Die Änderung des Umfragetypen kann vorhandenen Zugriff beeinträchtigen", "change_the_background_color_of_the_card": "Hintergrundfarbe der Karte ändern.", "change_the_background_color_of_the_input_fields": "Hintergrundfarbe der Eingabefelder ändern.", "change_the_background_to_a_color_image_or_animation": "Hintergrund zu einer Farbe, einem Bild oder einer Animation ändern.", @@ -1343,8 +1303,8 @@ "change_the_brand_color_of_the_survey": "Markenfarbe der Umfrage ändern.", "change_the_placement_of_this_survey": "Platzierung dieser Umfrage ändern.", "change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.", - "change_the_shadow_color_of_the_card": "Schattenfarbe der Karte ändern.", "changes_saved": "Änderungen gespeichert.", + "changing_survey_type_will_remove_existing_distribution_channels": "\"Das Ändern des Umfragetypen beeinflusst, wie er geteilt werden kann. Wenn Teilnehmer bereits Zugriffslinks für den aktuellen Typ haben, könnten sie das Zugriffsrecht nach dem Wechsel verlieren.\"", "character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.", "character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu", "checkbox_label": "Checkbox-Beschriftung", @@ -1354,10 +1314,11 @@ "close_survey_on_date": "Umfrage am Datum schließen", "close_survey_on_response_limit": "Umfrage bei Erreichen des Antwortlimits schließen", "color": "Farbe", + "column_used_in_logic_error": "Diese Spalte wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.", "columns": "Spalten", "company": "Firma", "company_logo": "Firmenlogo", - "completed_responses": "abgeschlossene Antworten", + "completed_responses": "unvollständige oder vollständige Antworten.", "concat": "Verketten +", "conditional_logic": "Bedingte Logik", "confirm_default_language": "Standardsprache bestätigen", @@ -1391,8 +1352,9 @@ "does_not_start_with": "Fängt nicht an mit", "edit_recall": "Erinnerung bearbeiten", "edit_translations": "{lang} -Übersetzungen bearbeiten", - "enable_encryption_of_single_use_id_suid_in_survey_url": "Single Use Id (suId) in der Umfrage-URL verschlüsseln.", "enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.", + "enable_recaptcha_to_protect_your_survey_from_spam": "Spamschutz verwendet reCAPTCHA v3, um Spam-Antworten herauszufiltern.", + "enable_spam_protection": "Spamschutz", "end_screen_card": "Abschluss-Karte", "ending_card": "Abschluss-Karte", "ending_card_used_in_logic": "Diese Abschlusskarte wird in der Logik der Frage {questionIndex} verwendet.", @@ -1403,6 +1365,7 @@ "error_saving_changes": "Fehler beim Speichern der Änderungen", "even_after_they_submitted_a_response_e_g_feedback_box": "Sogar nachdem sie eine Antwort eingereicht haben (z.B. Feedback-Box)", "everyone": "Jeder", + "fallback_for": "Ersatz für", "fallback_missing": "Fehlender Fallback", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.", "field_name_eg_score_price": "Feldname z.B. Punktzahl, Preis", @@ -1420,6 +1383,8 @@ "follow_ups_item_issue_detected_tag": "Problem erkannt", "follow_ups_item_response_tag": "Jede Antwort", "follow_ups_item_send_email_tag": "E-Mail senden", + "follow_ups_modal_action_attach_response_data_description": "Füge die Daten der Umfrageantwort zur Nachverfolgung hinzu", + "follow_ups_modal_action_attach_response_data_label": "Antwortdaten anhängen", "follow_ups_modal_action_body_label": "Inhalt", "follow_ups_modal_action_body_placeholder": "Inhalt der E-Mail", "follow_ups_modal_action_email_content": "E-Mail Inhalt", @@ -1450,9 +1415,6 @@ "follow_ups_new": "Neues Follow-up", "follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren", "form_styling": "Umfrage Styling", - "formbricks_ai_description": "Beschreibe deine Umfrage und lass Formbricks KI die Umfrage für Dich erstellen", - "formbricks_ai_generate": "erzeugen", - "formbricks_ai_prompt_placeholder": "Gib Umfrageinformationen ein (z.B. wichtige Themen, die abgedeckt werden sollen)", "formbricks_sdk_is_not_connected": "Formbricks SDK ist nicht verbunden", "four_points": "4 Punkte", "heading": "Überschrift", @@ -1465,7 +1427,6 @@ "hide_the_logo_in_this_specific_survey": "Logo in dieser speziellen Umfrage verstecken", "hostname": "Hostname", "how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein", - "how_it_works": "Wie es funktioniert", "if_you_need_more_please": "Wenn Du mehr brauchst, bitte", "if_you_really_want_that_answer_ask_until_you_get_it": "Wenn Du diese Antwort brauchst, frag so lange, bis Du sie bekommst.", "ignore_waiting_time_between_surveys": "Wartezeit zwischen Umfragen ignorieren", @@ -1481,10 +1442,13 @@ "invalid_youtube_url": "Ungültige YouTube-URL", "is_accepted": "Ist akzeptiert", "is_after": "Ist nach", + "is_any_of": "Ist eine von", "is_before": "Ist vor", "is_booked": "Ist gebucht", "is_clicked": "Wird geklickt", "is_completely_submitted": "Vollständig eingereicht", + "is_empty": "Ist leer", + "is_not_empty": "Ist nicht leer", "is_not_set": "Ist nicht festgelegt", "is_partially_submitted": "Teilweise eingereicht", "is_set": "Ist festgelegt", @@ -1500,7 +1464,6 @@ "limit_the_maximum_file_size": "Maximale Dateigröße begrenzen", "limit_upload_file_size_to": "Maximale Dateigröße für Uploads", "link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.", - "link_used_message": "Link verwendet", "load_segment": "Segment laden", "logic_error_warning": "Änderungen werden zu Logikfehlern führen", "logic_error_warning_text": "Das Ändern des Fragetypen entfernt die Logikbedingungen von dieser Frage", @@ -1516,6 +1479,7 @@ "no_hidden_fields_yet_add_first_one_below": "Noch keine versteckten Felder. Füge das erste unten hinzu.", "no_images_found_for": "Keine Bilder gefunden für ''{query}\"", "no_languages_found_add_first_one_to_get_started": "Keine Sprachen gefunden. Füge die erste hinzu, um loszulegen.", + "no_option_found": "Keine Option gefunden", "no_variables_yet_add_first_one_below": "Noch keine Variablen. Füge die erste hinzu.", "number": "Nummer", "once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Sobald die Standardsprache für diese Umfrage festgelegt ist, kann sie nur geändert werden, indem die Mehrsprachigkeitsoption deaktiviert und alle Übersetzungen gelöscht werden.", @@ -1567,6 +1531,7 @@ "response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.", "response_options": "Antwortoptionen", "roundness": "Rundheit", + "row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.", "rows": "Zeilen", "save_and_close": "Speichern & Schließen", "scale": "Scale", @@ -1590,10 +1555,12 @@ "show_survey_to_users": "Umfrage % der Nutzer anzeigen", "show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer", "simple": "Einfach", - "single_use_survey_links": "Einmalige Umfragelinks", - "single_use_survey_links_description": "Erlaube nur eine Antwort pro Umfragelink.", + "six_points": "6 Punkte", "skip_button_label": "Überspringen-Button-Beschriftung", "smiley": "Smiley", + "spam_protection_note": "Spamschutz funktioniert nicht für Umfragen, die mit den iOS-, React Native- und Android-SDKs angezeigt werden. Es wird die Umfrage unterbrechen.", + "spam_protection_threshold_description": "Wert zwischen 0 und 1 festlegen, Antworten unter diesem Wert werden abgelehnt.", + "spam_protection_threshold_heading": "Antwortschwelle", "star": "Stern", "starts_with": "Fängt an mit", "state": "Bundesland", @@ -1604,8 +1571,6 @@ "subheading": "Zwischenüberschrift", "subtract": "Subtrahieren -", "suggest_colors": "Farben vorschlagen", - "survey_already_answered_heading": "Die Umfrage wurde bereits beantwortet.", - "survey_already_answered_subheading": "Du kannst diesen Link nur einmal verwenden.", "survey_completed_heading": "Umfrage abgeschlossen", "survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen", "survey_display_settings": "Einstellungen zur Anzeige der Umfrage", @@ -1636,7 +1601,6 @@ "upload": "Hochladen", "upload_at_least_2_images": "Lade mindestens 2 Bilder hoch", "upper_label": "Oberes Label", - "url_encryption": "URL-Verschlüsselung", "url_filters": "URL-Filter", "url_not_supported": "URL nicht unterstützt", "use_with_caution": "Mit Vorsicht verwenden", @@ -1649,7 +1613,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Warte ein paar Sekunden nach dem Auslöser, bevor Du die Umfrage anzeigst", "waiting_period": "Wartezeit", "welcome_message": "Willkommensnachricht", - "when": "Wenn", "when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Wenn die Bedingungen übereinstimmen, wird die Wartezeit ignoriert und die Umfrage angezeigt.", "without_a_filter_all_of_your_users_can_be_surveyed": "Ohne Filter können alle deine Nutzer befragt werden.", "you_have_not_created_a_segment_yet": "Du hast noch keinen Segment erstellt.", @@ -1660,9 +1623,11 @@ "zip": "Postleitzahl" }, "error_deleting_survey": "Beim Löschen der Umfrage ist ein Fehler aufgetreten", - "failed_to_copy_link_to_results": "Kopieren des Links zu den Ergebnissen fehlgeschlagen", - "failed_to_copy_url": "Kopieren der URL fehlgeschlagen: nicht in einer Browserumgebung.", - "new_single_use_link_generated": "Neuer Einmal-Link erstellt", + "filter": { + "complete_and_partial_responses": "Vollständige und Teilantworten", + "complete_responses": "Vollständige Antworten", + "partial_responses": "Teilantworten" + }, "new_survey": "Neue Umfrage", "no_surveys_created_yet": "Noch keine Umfragen erstellt", "open_options": "Optionen öffnen", @@ -1681,9 +1646,11 @@ "company": "Firma", "completed": "Erledigt ✅", "country": "Land", + "delete_response_confirmation": "Dies wird die Umfrageantwort einschließlich aller Antworten, Tags, angehängter Dokumente und Antwort-Metadaten löschen.", "device": "Gerät", "device_info": "Geräteinfo", "email": "E-Mail", + "error_downloading_responses": "Beim Herunterladen der Antworten ist ein Fehler aufgetreten", "first_name": "Vorname", "how_to_identify_users": "Wie man Benutzer identifiziert", "last_name": "Nachname", @@ -1702,8 +1669,91 @@ "this_response_is_in_progress": "Diese Antwort ist in Bearbeitung.", "zip_post_code": "PLZ / Postleitzahl" }, - "results_unpublished_successfully": "Ergebnisse wurden nicht erfolgreich veröffentlicht.", "search_by_survey_name": "Nach Umfragenamen suchen", + "share": { + "anonymous_links": { + "custom_single_use_id_description": "Wenn Sie eine Einmal-ID nicht verschlüsseln, funktioniert jeder Wert für „suid=...“ für eine Antwort.", + "custom_single_use_id_title": "Sie können im URL beliebige Werte als Einmal-ID festlegen.", + "custom_start_point": "Benutzerdefinierter Startpunkt", + "data_prefilling": "Daten-Prefilling", + "description": "Antworten, die von diesen Links kommen, werden anonym", + "disable_multi_use_link_modal_button": "Mehrfach verwendeten Link deaktivieren", + "disable_multi_use_link_modal_description": "Das Deaktivieren des Mehrfachnutzungslinks verhindert, dass jemand mithilfe des Links eine Antwort einreichen kann.", + "disable_multi_use_link_modal_description_subtext": "Dies wird auch alle aktiven Einbettungen auf Websites, E-Mails, sozialen Medien und QR-Codes stören, die diesen Mehrfachnutzungslink verwenden.", + "disable_multi_use_link_modal_title": "Bist du sicher? Dies könnte aktive Einbettungen stören", + "disable_single_use_link_modal_button": "Einmalige Links deaktivieren", + "disable_single_use_link_modal_description": "Wenn Sie Einweglinks geteilt haben, können die Teilnehmer nicht mehr auf die Umfrage antworten.", + "generate_and_download_links": "Links generieren und herunterladen", + "generate_links_error": "Einmalige Verlinkungen konnten nicht generiert werden. Bitte arbeiten Sie direkt mit der API.", + "multi_use_link": "Mehrfach verwendet", + "multi_use_link_description": "Sammle mehrere Antworten von anonymen Teilnehmern mit einem Link", + "multi_use_powers_other_channels_description": "Wenn du es deaktivierst, werden auch diese anderen Vertriebskanäle deaktiviert.", + "multi_use_powers_other_channels_title": "Dieser Link ermöglicht Einbettungen auf Websites, Einbettungen in E-Mails, Teilen in sozialen Medien und QR-Codes", + "nav_title": "Anonyme Links", + "number_of_links_label": "Anzahl der Links (1 - 5.000)", + "single_use_link": "Einmalige Links", + "single_use_link_description": "Erlaube nur eine Antwort pro Umfragelink.", + "single_use_links": "Einmalige Links", + "source_tracking": "Quellenverfolgung", + "url_encryption_description": "Nur deaktivieren, wenn Sie eine benutzerdefinierte Einmal-ID setzen müssen.", + "url_encryption_label": "Verschlüsselung der URL für einmalige Nutzung ID" + }, + "dynamic_popup": { + "alert_button": "Umfrage bearbeiten", + "alert_description": "Diese Umfrage ist derzeit als Link-Umfrage konfiguriert, die dynamische Pop-ups nicht unterstützt. Sie können dies im Tab ‚Einstellungen‘ im Umfrage-Editor ändern.", + "alert_title": "Umfragen-Typ in In-App ändern", + "attribute_based_targeting": "Attributbasiertes Targeting", + "code_no_code_triggers": "Code- und No-Code-Auslöser", + "description": "Formbricks Umfragen können als Pop-up eingebettet werden, basierend auf der Benutzerinteraktion.", + "nav_title": "Dynamisch (Pop-up)", + "recontact_options": "Optionen zur erneuten Kontaktaufnahme" + }, + "embed_on_website": { + "description": "Formbricks-Umfragen können als statisches Element eingebettet werden.", + "embed_code_copied_to_clipboard": "Einbettungscode in die Zwischenablage kopiert!", + "embed_mode": "Einbettungsmodus", + "embed_mode_description": "Bette deine Umfrage mit einem minimalistischen Design ein, ohne Karten und Hintergrund.", + "nav_title": "Auf Website einbetten" + }, + "personal_links": { + "create_and_manage_segments": "Erstellen und verwalten Sie Ihre Segmente unter Kontakte > Segmente", + "description": "Erstellen Sie persönliche Links für ein Segment und ordnen Sie Umfrageantworten jedem Kontakt zu.", + "expiry_date_description": "Sobald der Link abläuft, kann der Empfänger nicht mehr auf die Umfrage antworten.", + "expiry_date_optional": "Ablaufdatum (optional)", + "generate_and_download_links": "Links generieren und herunterladen", + "generating_links": "Links werden generiert", + "generating_links_toast": "Links werden generiert, der Download startet in Kürze…", + "links_generated_success_toast": "Links erfolgreich generiert, Ihr Download beginnt in Kürze.", + "nav_title": "Persönliche Links", + "no_segments_available": "Keine Segmente verfügbar", + "select_segment": "Segment auswählen", + "upgrade_prompt_description": "Erstellen Sie persönliche Links für ein Segment und verknüpfen Sie Umfrageantworten mit jedem Kontakt.", + "upgrade_prompt_title": "Verwende persönliche Links mit einem höheren Plan", + "work_with_segments": "Persönliche Links funktionieren mit Segmenten." + }, + "send_email": { + "copy_embed_code": "Einbettungscode kopieren", + "description": "Binden Sie Ihre Umfrage in eine E-Mail ein, um Antworten von Ihrem Publikum zu erhalten.", + "email_preview_tab": "E-Mail Vorschau", + "email_sent": "E-Mail gesendet!", + "email_subject_label": "Betreff", + "email_to_label": "An", + "embed_code_copied_to_clipboard": "Einbettungscode in die Zwischenablage kopiert!", + "embed_code_copied_to_clipboard_failed": "Kopieren fehlgeschlagen, bitte versuche es erneut", + "embed_code_tab": "Einbettungscode", + "formbricks_email_survey_preview": "Formbricks E-Mail-Umfrage Vorschau", + "nav_title": "E-Mail-Einbettung", + "send_preview": "Vorschau senden", + "send_preview_email": "Vorschau-E-Mail senden" + }, + "share_view_title": "Teilen über", + "social_media": { + "description": "Erhalte Rückmeldungen von deinen Kontakten auf verschiedenen sozialen Medien.", + "source_tracking_enabled": "Quellenverfolgung aktiviert", + "source_tracking_enabled_alert_description": "Wenn Sie aus diesem Dialogfenster teilen, wird das soziale Netzwerk an den Umfragelink angehängt, sodass Sie wissen, welche Antworten über welches Netzwerk eingegangen sind.", + "title": "Soziale Medien" + } + }, "summary": { "added_filter_for_responses_where_answer_to_question": "Filter hinzugefügt für Antworten, bei denen die Antwort auf Frage {questionIdx} {filterComboBoxValue} - {filterValue} ist", "added_filter_for_responses_where_answer_to_question_is_skipped": "Filter hinzugefügt für Antworten, bei denen die Frage {questionIdx} übersprungen wurde", @@ -1717,52 +1767,50 @@ "configure_alerts": "Benachrichtigungen konfigurieren", "congrats": "Glückwunsch! Deine Umfrage ist jetzt live.", "connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.", - "copy_link_to_public_results": "Link zu öffentlichen Ergebnissen kopieren", - "create_single_use_links": "Single-Use Links erstellen", - "create_single_use_links_description": "Akzeptiere nur eine Antwort pro Link. So geht's.", - "current_selection_csv": "Aktuelle Auswahl (CSV)", - "current_selection_excel": "Aktuelle Auswahl (Excel)", "custom_range": "Benutzerdefinierter Bereich...", - "data_prefilling": "Daten-Prefilling", - "data_prefilling_description": "Du möchtest einige Felder in der Umfrage vorausfüllen? So geht's.", - "define_when_and_where_the_survey_should_pop_up": "Definiere, wann und wo die Umfrage erscheinen soll", + "delete_all_existing_responses_and_displays": "Alle bestehenden Antworten und Anzeigen löschen", + "download_qr_code": "QR Code herunterladen", "drop_offs": "Drop-Off Rate", "drop_offs_tooltip": "So oft wurde die Umfrage gestartet, aber nicht abgeschlossen.", - "dynamic_popup": "Dynamisch (Pop-up)", - "email_sent": "E-Mail gesendet!", - "embed_code_copied_to_clipboard": "Einbettungscode in die Zwischenablage kopiert!", - "embed_in_an_email": "In eine E-Mail einbetten", - "embed_in_app": "In App einbetten", - "embed_mode": "Einbettungsmodus", - "embed_mode_description": "Bette deine Umfrage mit einem minimalistischen Design ein, ohne Karten und Hintergrund.", - "embed_on_website": "Auf Website einbetten", - "embed_pop_up_survey_title": "Wie man eine Pop-up-Umfrage auf seiner Website einbindet", - "embed_survey": "Umfrage einbetten", - "enable_ai_insights_banner_button": "Insights aktivieren", - "enable_ai_insights_banner_description": "Du kannst die neue Insights-Funktion für die Umfrage aktivieren, um KI-basierte Insights für deine Freitextantworten zu erhalten.", - "enable_ai_insights_banner_success": "Erzeuge Insights für diese Umfrage. Bitte in ein paar Minuten die Seite neu laden.", - "enable_ai_insights_banner_title": "Bereit, KI-Insights zu testen?", - "enable_ai_insights_banner_tooltip": "Das sind ganz schön viele Freitextantworten! Kontaktiere uns bitte unter hola@formbricks.com, um Insights für diese Umfrage zu erhalten.", "failed_to_copy_link": "Kopieren des Links fehlgeschlagen", "filter_added_successfully": "Filter erfolgreich hinzugefügt", "filter_updated_successfully": "Filter erfolgreich aktualisiert", - "formbricks_email_survey_preview": "Formbricks E-Mail-Umfrage Vorschau", + "filtered_responses_csv": "Gefilterte Antworten (CSV)", + "filtered_responses_excel": "Gefilterte Antworten (Excel)", "go_to_setup_checklist": "Gehe zur Einrichtungs-Checkliste \uD83D\uDC49", - "hide_embed_code": "Einbettungscode ausblenden", - "how_to_create_a_panel": "Wie man ein Panel erstellt", - "how_to_create_a_panel_step_1": "Schritt 1: Erstelle ein Konto bei Prolific", - "how_to_create_a_panel_step_1_description": "Erstelle ein Konto bei Prolific und bestätige deine E-Mail-Adresse.", - "how_to_create_a_panel_step_2": "Schritt 2: Eine Studie erstellen", - "how_to_create_a_panel_step_2_description": "Bei Prolific erstellst Du eine neue Studie, bei der Du dein bevorzugtes Publikum basierend auf Hunderten von Merkmalen auswählen kannst.", - "how_to_create_a_panel_step_3": "Schritt 3: Verbinde deine Umfrage", - "how_to_create_a_panel_step_3_description": "Richte in deiner Formbricks-Umfrage versteckte Felder ein, um nachzuverfolgen, welcher Teilnehmer welche Antwort gegeben hat.", - "how_to_create_a_panel_step_4": "Schritt 4: Starte deine Studie", - "how_to_create_a_panel_step_4_description": "Sobald alles eingerichtet ist, kannst Du deine Studie starten. Innerhalb weniger Stunden wirst Du die ersten Antworten erhalten.", "impressions": "Eindrücke", "impressions_tooltip": "Anzahl der Aufrufe der Umfrage.", + "in_app": { + "connection_description": "Die Umfrage wird den Nutzern Ihrer Website angezeigt, die den unten aufgeführten Kriterien entsprechen", + "connection_title": "Formbricks SDK ist verbunden", + "description": "Formbricks Umfragen können als Pop-up eingebettet werden, basierend auf der Benutzerinteraktion.", + "display_criteria": "Anzeigekriterien", + "display_criteria.audience_description": "Zielgruppe", + "display_criteria.code_trigger": "Code Aktion", + "display_criteria.everyone": "Jeder", + "display_criteria.no_code_trigger": "Kein Code", + "display_criteria.overwritten": "Überschrieben", + "display_criteria.randomizer": "{percentage}% Zufallsgenerator", + "display_criteria.randomizer_description": "Nur {percentage}% der Personen, die die Aktion ausführen, könnten befragt werden.", + "display_criteria.recontact_description": "Optionen zur erneuten Kontaktaufnahme", + "display_criteria.targeted": "Gezielt", + "display_criteria.time_based_always": "Umfrage immer anzeigen", + "display_criteria.time_based_day": "Tag", + "display_criteria.time_based_days": "Tage", + "display_criteria.time_based_description": "Globale Wartezeit", + "display_criteria.trigger_description": "Umfrageauslöser", + "documentation_title": "Unterbrechungsumfragen auf allen Plattformen verteilen", + "html_embed": "HTML-Einbettung im ", + "ios_sdk": "iOS SDK für Apple-Apps", + "javascript_sdk": "JavaScript SDK", + "kotlin_sdk": "Kotlin SDK für Android-Apps", + "no_connection_description": "Verbinde deine Website oder App mit Formbricks, um Abfangumfragen zu veröffentlichen.", + "no_connection_title": "Du bist noch nicht verbunden!", + "react_native_sdk": "React Native SDK für RN-Apps.", + "title": "Feedback-Befragungseinstellungen" + }, "includes_all": "Beinhaltet alles", "includes_either": "Beinhaltet entweder", - "insights_disabled": "Insights deaktiviert", "install_widget": "Formbricks Widget installieren", "is_equal_to": "Ist gleich", "is_less_than": "ist weniger als", @@ -1772,60 +1820,34 @@ "last_month": "Letztes Monat", "last_quarter": "Letztes Quartal", "last_year": "Letztes Jahr", - "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", - "only_completed": "Nur vollständige Antworten", + "no_responses_found": "Keine Antworten gefunden", "other_values_found": "Andere Werte gefunden", "overall": "Insgesamt", - "publish_to_web": "Im Web veröffentlichen", - "publish_to_web_warning": "Du bist dabei, diese Umfrageergebnisse öffentlich zugänglich zu machen.", - "publish_to_web_warning_description": "Deine Umfrageergebnisse werden öffentlich sein. Jeder außerhalb deiner Organisation kann darauf zugreifen, wenn er den Link hat.", - "quickstart_mobile_apps": "Schnellstart: Mobile-Apps", - "quickstart_mobile_apps_description": "Um mit Umfragen in mobilen Apps zu beginnen, folge bitte der Schnellstartanleitung:", - "quickstart_web_apps": "Schnellstart: Web-Apps", - "quickstart_web_apps_description": "Bitte folge der Schnellstartanleitung, um loszulegen:", - "results_are_public": "Ergebnisse sind öffentlich", - "send_preview": "Vorschau senden", - "send_to_panel": "An das Panel senden", - "setup_instructions": "Einrichtung", + "qr_code": "QR-Code", + "qr_code_description": "Antworten, die per QR-Code gesammelt werden, sind anonym.", + "qr_code_download_failed": "QR-Code-Download fehlgeschlagen", + "qr_code_download_with_start_soon": "QR Code-Download startet bald", + "qr_code_generation_failed": "Es gab ein Problem beim Laden des QR-Codes für die Umfrage. Bitte versuchen Sie es erneut.", + "reset_survey": "Umfrage zurücksetzen", + "reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.", + "selected_responses_csv": "Ausgewählte Antworten (CSV)", + "selected_responses_excel": "Ausgewählte Antworten (Excel)", "setup_integrations": "Integrationen einrichten", - "share_results": "Ergebnisse teilen", - "share_the_link": "Teile den Link", - "share_the_link_to_get_responses": "Teile den Link, um Antworten einzusammeln", + "share_survey": "Umfrage teilen", "show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen", "show_all_responses_where": "Zeige alle Antworten, bei denen...", - "single_use_links": "Single-Use Links", - "source_tracking": "Quellenverfolgung", - "source_tracking_description": "Führe DSGVO- und CCPA-konformes Quell-Tracking ohne zusätzliche Tools durch.", "starts": "Startet", "starts_tooltip": "So oft wurde die Umfrage gestartet.", - "static_iframe": "Statisch (iframe)", - "survey_results_are_public": "Deine Umfrageergebnisse sind öffentlich", - "survey_results_are_shared_with_anyone_who_has_the_link": "Deine Umfrageergebnisse stehen allen zur Verfügung, die den Link haben. Die Ergebnisse werden nicht von Suchmaschinen indexiert.", + "survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.", "this_month": "Dieser Monat", "this_quarter": "Dieses Quartal", "this_year": "Dieses Jahr", "time_to_complete": "Zeit zur Fertigstellung", - "to_connect_your_website_with_formbricks": "deine Website mit Formbricks zu verbinden", "ttc_tooltip": "Durchschnittliche Zeit bis zum Abschluss der Umfrage.", "unknown_question_type": "Unbekannter Fragetyp", - "unpublish_from_web": "Aus dem Web entfernen", - "unsupported_video_tag_warning": "Dein Browser unterstützt das Video-Tag nicht.", - "view_embed_code": "Einbettungscode anzeigen", - "view_embed_code_for_email": "Einbettungscode für E-Mail anzeigen", - "view_site": "Seite ansehen", + "use_personal_links": "Nutze persönliche Links", "waiting_for_response": "Warte auf eine Antwort \uD83E\uDDD8‍♂️", - "web_app": "Web-App", - "what_is_a_panel": "Was ist ein Panel?", - "what_is_a_panel_answer": "Ein Panel ist eine Gruppe von Teilnehmern, die basierend auf Merkmalen wie Alter, Beruf, Geschlecht usw. ausgewählt werden.", - "what_is_prolific": "Was ist Prolific?", - "what_is_prolific_answer": "Wir arbeiten mit Prolific zusammen, um dir Zugang zu einem Pool von über 200.000 geprüften Teilnehmern zu geben.", "whats_next": "Was kommt als Nächstes?", - "when_do_i_need_it": "Wann brauche ich das?", - "when_do_i_need_it_answer": "Wenn Du keinen Zugang zu genügend Leuten hast, die deiner Zielgruppe entsprechen, macht es Sinn, für ein Panel zu bezahlen.", - "you_can_do_a_lot_more_with_links_surveys": "Mit Links-Umfragen kannst Du viel mehr machen \uD83D\uDCA1", "your_survey_is_public": "Deine Umfrage ist öffentlich", "youre_not_plugged_in_yet": "Du bist noch nicht verbunden!" }, @@ -1954,11 +1976,6 @@ "this_user_has_all_the_power": "Dieser Benutzer hat alle Rechte." } }, - "share": { - "back_to_home": "Zurück zur Startseite", - "page_not_found": "Seite nicht gefunden", - "page_not_found_description": "Entschuldigung, wir konnten die gesuchten Antworten mit der geteilten ID nicht finden." - }, "templates": { "address": "Adresse", "address_description": "Frag nach einer Adresse", @@ -1969,7 +1986,6 @@ "alignment_and_engagement_survey_question_1_upper_label": "Vollständiges Verständnis", "alignment_and_engagement_survey_question_2_headline": "Ich fühle, dass meine Werte mit der Mission und Kultur des Unternehmens übereinstimmen.", "alignment_and_engagement_survey_question_2_lower_label": "Keine Übereinstimmung", - "alignment_and_engagement_survey_question_2_upper_label": "Vollständige Übereinstimmung", "alignment_and_engagement_survey_question_3_headline": "Ich arbeite effektiv mit meinem Team zusammen, um unsere Ziele zu erreichen.", "alignment_and_engagement_survey_question_3_lower_label": "Schlechte Zusammenarbeit", "alignment_and_engagement_survey_question_3_upper_label": "Ausgezeichnete Zusammenarbeit", @@ -1979,7 +1995,6 @@ "book_interview": "Interview buchen", "build_product_roadmap_description": "Finde die EINE Sache heraus, die deine Nutzer am meisten wollen, und baue sie.", "build_product_roadmap_name": "Produkt Roadmap erstellen", - "build_product_roadmap_name_with_project_name": "$[projectName] Roadmap Ideen", "build_product_roadmap_question_1_headline": "Wie zufrieden bist Du mit den Funktionen und der Benutzerfreundlichkeit von $[projectName]?", "build_product_roadmap_question_1_lower_label": "Überhaupt nicht zufrieden", "build_product_roadmap_question_1_upper_label": "Extrem zufrieden", @@ -2162,7 +2177,6 @@ "csat_question_7_choice_3": "Etwas schnell", "csat_question_7_choice_4": "Nicht so schnell", "csat_question_7_choice_5": "Überhaupt nicht schnell", - "csat_question_7_choice_6": "Nicht zutreffend", "csat_question_7_headline": "Wie schnell haben wir auf deine Fragen zu unseren Dienstleistungen reagiert?", "csat_question_7_subheader": "Bitte wähle eine aus:", "csat_question_8_choice_1": "Das ist mein erster Kauf", @@ -2170,7 +2184,6 @@ "csat_question_8_choice_3": "Sechs Monate bis ein Jahr", "csat_question_8_choice_4": "1 - 2 Jahre", "csat_question_8_choice_5": "3 oder mehr Jahre", - "csat_question_8_choice_6": "Ich habe noch keinen Kauf getätigt", "csat_question_8_headline": "Wie lange bist Du schon Kunde von $[projectName]?", "csat_question_8_subheader": "Bitte wähle eine aus:", "csat_question_9_choice_1": "Sehr wahrscheinlich", @@ -2385,7 +2398,6 @@ "identify_sign_up_barriers_question_9_dismiss_button_label": "Erstmal überspringen", "identify_sign_up_barriers_question_9_headline": "Danke! Hier ist dein Code: SIGNUPNOW10", "identify_sign_up_barriers_question_9_html": "Vielen Dank, dass Du dir die Zeit genommen hast, Feedback zu geben \uD83D\uDE4F", - "identify_sign_up_barriers_with_project_name": "Anmeldebarrieren für $[projectName]", "identify_upsell_opportunities_description": "Finde heraus, wie viel Zeit dein Produkt deinem Nutzer spart. Nutze dies, um mehr zu verkaufen.", "identify_upsell_opportunities_name": "Upsell-Möglichkeiten identifizieren", "identify_upsell_opportunities_question_1_choice_1": "Weniger als 1 Stunde", @@ -2597,7 +2609,7 @@ "preview_survey_question_2_back_button_label": "Zurück", "preview_survey_question_2_choice_1_label": "Ja, halte mich auf dem Laufenden.", "preview_survey_question_2_choice_2_label": "Nein, danke!", - "preview_survey_question_2_headline": "Willst du auf dem Laufenden bleiben?", + "preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?", "preview_survey_welcome_card_headline": "Willkommen!", "preview_survey_welcome_card_html": "Danke für dein Feedback - los geht's!", "prioritize_features_description": "Identifiziere die Funktionen, die deine Nutzer am meisten und am wenigsten brauchen.", @@ -2750,7 +2762,6 @@ "site_abandonment_survey_question_6_choice_3": "Mehr Produktvielfalt", "site_abandonment_survey_question_6_choice_4": "Verbesserte Seitengestaltung", "site_abandonment_survey_question_6_choice_5": "Mehr Kundenbewertungen", - "site_abandonment_survey_question_6_choice_6": "Andere", "site_abandonment_survey_question_6_headline": "Welche Verbesserungen würden Dich dazu ermutigen, länger auf unserer Seite zu bleiben?", "site_abandonment_survey_question_6_subheader": "Bitte wähle alle zutreffenden Optionen aus:", "site_abandonment_survey_question_7_headline": "Möchtest Du Updates über neue Produkte und Aktionen erhalten?", @@ -2781,6 +2792,8 @@ "star_rating_survey_question_3_placeholder": "Schreib hier deine Antwort...", "star_rating_survey_question_3_subheader": "Hilf uns, deine Erfahrung zu verbessern.", "statement_call_to_action": "Aussage (Call-to-Action)", + "strongly_agree": "Stimme voll und ganz zu", + "strongly_disagree": "Stimme überhaupt nicht zu", "supportive_work_culture_survey_description": "Bewerte die Wahrnehmung der Mitarbeiter bezüglich Führungsunterstützung, Kommunikation und des gesamten Arbeitsumfelds.", "supportive_work_culture_survey_name": "Unterstützende Arbeitskultur", "supportive_work_culture_survey_question_1_headline": "Mein Vorgesetzter bietet mir die Unterstützung, die ich zur Erledigung meiner Arbeit benötige.", @@ -2836,6 +2849,18 @@ "understand_purchase_intention_question_2_headline": "Verstanden. Was ist dein Hauptgrund für den heutigen Besuch?", "understand_purchase_intention_question_2_placeholder": "Tippe deine Antwort hier...", "understand_purchase_intention_question_3_headline": "Was, wenn überhaupt, hält Dich heute davon ab, einen Kauf zu tätigen?", - "understand_purchase_intention_question_3_placeholder": "Tippe deine Antwort hier..." + "understand_purchase_intention_question_3_placeholder": "Tippe deine Antwort hier...", + "usability_question_10_headline": "Ich musste viel lernen, bevor ich das System richtig benutzen konnte.", + "usability_question_1_headline": "Ich würde dieses System wahrscheinlich häufig verwenden.", + "usability_question_2_headline": "Das System wirkte komplizierter als nötig.", + "usability_question_3_headline": "Das System war leicht zu verstehen.", + "usability_question_4_headline": "Ich glaube, ich bräuchte Unterstützung von einem Technik-Experten, um dieses System zu nutzen.", + "usability_question_5_headline": "Alles im System schien gut zusammenzuarbeiten.", + "usability_question_6_headline": "Das System fühlte sich inkonsistent an, wie die Dinge funktionierten.", + "usability_question_7_headline": "Ich glaube, die meisten Menschen könnten schnell lernen, dieses System zu benutzen.", + "usability_question_8_headline": "Die Nutzung des Systems fühlte sich wie eine Belastung an.", + "usability_question_9_headline": "Ich fühlte mich beim Benutzen des Systems sicher.", + "usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.", + "usability_score_name": "System Usability Score Survey (SUS)" } } diff --git a/packages/lib/messages/en-US.json b/apps/web/locales/en-US.json similarity index 90% rename from packages/lib/messages/en-US.json rename to apps/web/locales/en-US.json index ef6558c643e0..7991be079d80 100644 --- a/packages/lib/messages/en-US.json +++ b/apps/web/locales/en-US.json @@ -1,12 +1,23 @@ { "auth": { - "continue_with_azure": "Continue with Azure", + "continue_with_azure": "Continue with Microsoft", "continue_with_email": "Continue with Email", "continue_with_github": "Continue with GitHub", "continue_with_google": "Continue with Google", "continue_with_oidc": "Continue with {oidcDisplayName}", "continue_with_openid": "Continue with OpenID", "continue_with_saml": "Continue with SAML SSO", + "email-change": { + "confirm_password_description": "Please confirm your password before changing your email address", + "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" + }, "forgot-password": { "back_to_login": "Back to login", "email-sent": { @@ -23,7 +34,8 @@ "text": "You can now log in with your new password" } }, - "reset_password": "Reset password" + "reset_password": "Reset password", + "reset_password_description": "You will be logged out to reset your password." }, "invite": { "create_account": "Create an account", @@ -68,7 +80,7 @@ }, "signup_without_verification_success": { "user_successfully_created": "User successfully created", - "user_successfully_created_description": "Your new user has been created successfully. Please click the button below and sign in to your account." + "user_successfully_created_info": "We’ve checked for an account associated with {email}. If none existed, we’ve created one for you. If an account already existed, no changes were made. Please log in below to continue." }, "testimonial_1": "We measure the clarity of our docs and learn from churn all on one platform. Great product, very responsive team!", "testimonial_all_features_included": "All features included", @@ -78,12 +90,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 successfully sent. Please check your inbox.", - "we_sent_an_email_to": "We sent an email to {email}. ", + "verification_email_resent_successfully": "Verification email sent! Please check your inbox.", + "verification_email_successfully_sent_info": "If there’s an account associated with {email}, we’ve sent a verification link to that address. Please check your inbox to complete the sign-up.", "you_didnt_receive_an_email_or_your_link_expired": "You didn't receive an email or your link expired?" }, "verify": { @@ -96,6 +108,10 @@ "thanks_for_upgrading": "Thanks a lot for upgrading your Formbricks subscription.", "upgrade_successful": "Upgrade successful" }, + "c": { + "link_expired": "Your link is expired.", + "link_expired_description": "The link you used is no longer valid." + }, "common": { "accepted": "Accepted", "account": "Account", @@ -108,6 +124,7 @@ "add_action": "Add action", "add_filter": "Add filter", "add_logo": "Add logo", + "add_member": "Add member", "add_project": "Add project", "add_to_team": "Add to team", "all": "All", @@ -123,7 +140,6 @@ "app_survey": "App Survey", "apply_filters": "Apply filters", "are_you_sure": "Are you sure?", - "are_you_sure_this_action_cannot_be_undone": "Are you sure? This action cannot be undone.", "attributes": "Attributes", "avatar": "Avatar", "back": "Back", @@ -149,11 +165,13 @@ "connect_formbricks": "Connect Formbricks", "connected": "Connected", "contacts": "Contacts", + "copied": "Copied", "copied_to_clipboard": "Copied to clipboard", "copy": "Copy", "copy_code": "Copy code", "copy_link": "Copy Link", "create_new_organization": "Create new organization", + "create_project": "Create project", "create_segment": "Create segment", "create_survey": "Create survey", "created": "Created", @@ -180,13 +198,10 @@ "e_commerce": "E-Commerce", "edit": "Edit", "email": "Email", - "embed": "Embed", "enterprise_license": "Enterprise License", "environment_not_found": "Environment not found", "environment_notice": "You're currently in the {environment} environment.", "error": "Error", - "error_component_description": "This resource doesn't exist or you don't have the necessary rights to access it.", - "error_component_title": "Error loading resources", "expand_rows": "Expand rows", "finish": "Finish", "follow_these": "Follow these", @@ -209,7 +224,6 @@ "in_progress": "In Progress", "inactive_surveys": "Inactive surveys", "input_type": "Input type", - "insights": "Insights", "integration": "integration", "integrations": "Integrations", "invalid_date": "Invalid date", @@ -225,7 +239,6 @@ "limits_reached": "Limits Reached", "link": "Link", "link_and_email": "Link & Email", - "link_copied": "Link copied to clipboard!", "link_survey": "Link Survey", "link_surveys": "Link Surveys", "load_more": "Load more", @@ -246,8 +259,6 @@ "move_up": "Move up", "multiple_languages": "Multiple languages", "name": "Name", - "negative": "Negative", - "neutral": "Neutral", "new": "New", "new_survey": "New Survey", "new_version_available": "Formbricks {version} is here. Upgrade now!", @@ -269,6 +280,8 @@ "on": "On", "only_one_file_allowed": "Only one file is allowed", "only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.", + "option_id": "Option ID", + "option_ids": "Option IDs", "or": "or", "organization": "Organization", "organization_id": "Organization ID", @@ -285,32 +298,34 @@ "phone": "Phone", "photo_by": "Photo by", "pick_a_date": "Pick a date", + "picture": "Picture", "placeholder": "Placeholder", "please_select_at_least_one_survey": "Please select at least one survey", "please_select_at_least_one_trigger": "Please select at least one trigger", "please_upgrade_your_plan": "Please upgrade your plan.", - "positive": "Positive", "preview": "Preview", "preview_survey": "Preview Survey", "privacy": "Privacy Policy", - "privacy_policy": "Privacy Policy", "product_manager": "Product Manager", "profile": "Profile", - "project": "Project", + "profile_id": "Profile ID", "project_configuration": "Project's Configuration", + "project_creation_description": "Organize surveys in projects for better access control.", "project_id": "Project ID", "project_name": "Project Name", + "project_name_placeholder": "e.g. Formbricks", "project_not_found": "Project not found", "project_permission_not_found": "Project permission not found", "projects": "Projects", - "projects_limit_reached": "Projects limit reached", "question": "Question", "question_id": "Question ID", "questions": "Questions", "read_docs": "Read Docs", + "recipients": "Recipients", "remove": "Remove", "reorder_and_hide_columns": "Reorder and hide columns", "report_survey": "Report Survey", + "request_pricing": "Request Pricing", "request_trial_license": "Request trial license", "reset_to_default": "Reset to default", "response": "Response", @@ -330,6 +345,7 @@ "select": "Select", "select_all": "Select all", "select_survey": "Select Survey", + "select_teams": "Select teams", "selected": "Selected", "selected_questions": "Selected questions", "selection": "Selection", @@ -346,6 +362,7 @@ "skipped": "Skipped", "skips": "Skips", "some_files_failed_to_upload": "Some files failed to upload", + "something_went_wrong": "Something went wrong", "something_went_wrong_please_try_again": "Something went wrong. Please try again.", "sort_by": "Sort by", "start_free_trial": "Start Free Trial", @@ -372,6 +389,7 @@ "targeting": "Targeting", "team": "Team", "team_access": "Team Access", + "team_id": "Team ID", "team_name": "Team name", "teams": "Access Control", "teams_not_found": "Teams not found", @@ -404,9 +422,7 @@ "website_and_app_connection": "Website & App Connection", "website_app_survey": "Website & App Survey", "website_survey": "Website Survey", - "weekly_summary": "Weekly summary", "welcome_card": "Welcome card", - "yes": "Yes", "you": "You", "you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.", "you_are_not_authorised_to_perform_this_action": "You are not authorised to perform this action.", @@ -446,41 +462,13 @@ "invite_email_text_par1": "Your colleague", "invite_email_text_par2": "invited you to join them at Formbricks. To accept the invitation, please click the link below:", "invite_member_email_subject": "You're invited to collaborate on Formbricks!", - "live_survey_notification_completed": "Completed", - "live_survey_notification_draft": "Draft", - "live_survey_notification_in_progress": "In Progress", - "live_survey_notification_no_new_response": "No new response received this week \uD83D\uDD75️", - "live_survey_notification_no_responses_yet": "No Responses yet!", - "live_survey_notification_paused": "Paused", - "live_survey_notification_scheduled": "Scheduled", - "live_survey_notification_view_more_responses": "View {responseCount} more Responses", - "live_survey_notification_view_previous_responses": "View previous responses", - "live_survey_notification_view_response": "View Response", - "notification_footer_all_the_best": "All the best,", - "notification_footer_in_your_settings": "in your settings \uD83D\uDE4F", - "notification_footer_please_turn_them_off": "please turn them off", - "notification_footer_the_formbricks_team": "The Formbricks Team \uD83E\uDD0D", - "notification_footer_to_halt_weekly_updates": "To halt Weekly Updates,", - "notification_header_hey": "Hey \uD83D\uDC4B", - "notification_header_weekly_report_for": "Weekly Report for", - "notification_insight_completed": "Completed", - "notification_insight_completion_rate": "Completion %", - "notification_insight_displays": "Displays", - "notification_insight_responses": "Responses", - "notification_insight_surveys": "Surveys", - "onboarding_invite_email_button_label": "Join {inviterName}'s organization", - "onboarding_invite_email_connect_formbricks": "Connect Formbricks to your app or website via HTML Snippet or NPM in just a few minutes.", - "onboarding_invite_email_create_account": "Create an account to join {inviterName}'s organization.", - "onboarding_invite_email_done": "Done ✅", - "onboarding_invite_email_get_started_in_minutes": "Get Started in Minutes", - "onboarding_invite_email_heading": "Hey ", - "onboarding_invite_email_subject": "{inviterName} needs a hand setting up Formbricks. Can you help out?", + "new_email_verification_text": "To verify your new email address, please click the button below:", "password_changed_email_heading": "Password changed", "password_changed_email_text": "Your password has been changed successfully.", "password_reset_notify_email_subject": "Your Formbricks password has been changed", - "powered_by_formbricks": "Powered by Formbricks", "privacy_policy": "Privacy Policy", "reject": "Reject", + "render_email_response_value_file_upload_response_link_not_included": "Link to uploaded file is not included for data privacy reasons", "response_finished_email_subject": "A response for {surveyName} was completed ✅", "response_finished_email_subject_with_email": "{personEmail} just completed your {surveyName} survey ✅", "schedule_your_meeting": "Schedule your meeting", @@ -505,14 +493,9 @@ "verification_email_thanks": "Thanks for validating your email!", "verification_email_to_fill_survey": "To fill out the survey please click on the button below:", "verification_email_verify_email": "Verify email", - "verified_link_survey_email_subject": "Your survey is ready to be filled out.", - "weekly_summary_create_reminder_notification_body_cal_slot": "Pick a 15-minute slot in our CEOs calendar", - "weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Don't let a week pass without learning about your users:", - "weekly_summary_create_reminder_notification_body_need_help": "Need help finding the right survey for your product?", - "weekly_summary_create_reminder_notification_body_reply_email": "or reply to this email :)", - "weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Setup a new survey", - "weekly_summary_create_reminder_notification_body_text": "We'd love to send you a Weekly Summary, but currently there are no surveys running for {projectName}.", - "weekly_summary_email_subject": "{projectName} User Insights - Last Week by Formbricks" + "verification_new_email_subject": "Email change verification", + "verification_security_notice": "If you did not request this email change, please ignore this email or contact support immediately.", + "verified_link_survey_email_subject": "Your survey is ready to be filled out." }, "environments": { "actions": { @@ -525,21 +508,21 @@ "action_with_key_already_exists": "Action with key {key} already exists", "action_with_name_already_exists": "Action with name {name} already exists", "add_css_class_or_id": "Add CSS class or id", + "add_regular_expression_here": "Add a regular expression here", "add_url": "Add URL", "click": "Click", "contains": "Contains", "create_action": "Create action", "css_selector": "CSS Selector", "delete_action_text": "Are you sure you want to delete this action? This also removes this action as a trigger from all your surveys.", - "display_name": "Display name", "does_not_contain": "Does not contain", "does_not_exactly_match": "Does not exactly match", "eg_clicked_download": "E.g. Clicked Download", "eg_download_cta_click_on_home": "e.g. download_cta_click_on_home", "eg_install_app": "E.g. Install App", - "eg_user_clicked_download_button": "E.g. User clicked Download Button", "ends_with": "Ends with", "enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Enter a URL to see if a user visiting it would be tracked.", + "enter_url": "e.g. https://app.com/dashboard", "exactly_matches": "Exactly matches", "exit_intent": "Exit Intent", "fifty_percent_scroll": "50% Scroll", @@ -548,9 +531,14 @@ "if_a_user_clicks_a_button_with_a_specific_text": "If a user clicks a button with a specific text", "in_your_code_read_more_in_our": "in your code. Read more in our", "inner_text": "Inner Text", + "invalid_action_type_code": "Invalid action type for code action.", + "invalid_action_type_no_code": "Invalid action type for noCode action.", "invalid_css_selector": "Invalid CSS Selector", + "invalid_match_type": "The option selected is not available.", + "invalid_regex": "Please use a valid regular expression.", "limit_the_pages_on_which_this_action_gets_captured": "Limit the pages on which this action gets captured", "limit_to_specific_pages": "Limit to specific pages", + "matches_regex": "Matches regex", "on_all_pages": "On all pages", "page_filter": "Page filter", "page_view": "Page View", @@ -570,7 +558,9 @@ "user_clicked_download_button": "User clicked Download Button", "what_did_your_user_do": "What did your user do?", "what_is_the_user_doing": "What is the user doing?", - "you_can_track_code_action_anywhere_in_your_app_using": "You can track code action anywhere in your app using" + "you_can_track_code_action_anywhere_in_your_app_using": "You can track code action anywhere in your app using", + "your_survey_would_be_shown_on_this_url": "Your survey would be shown on this URL.", + "your_survey_would_not_be_shown": "Your survey would not be shown." }, "connect": { "congrats": "Congrats!", @@ -587,8 +577,8 @@ "contact_deleted_successfully": "Contact deleted successfully", "contact_not_found": "No such contact found", "contacts_table_refresh": "Refresh contacts", - "contacts_table_refresh_error": "Something went wrong while refreshing contacts, please try again", "contacts_table_refresh_success": "Contacts refreshed successfully", + "delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.", "first_name": "First Name", "last_name": "Last Name", "no_responses_found": "No responses found", @@ -616,33 +606,6 @@ "upload_contacts_modal_preview": "Here's a preview of your data.", "upload_contacts_modal_upload_btn": "Upload contacts" }, - "experience": { - "all": "All", - "all_time": "All time", - "analysed_feedbacks": "Analysed Free Text Answers", - "category": "Category", - "category_updated_successfully": "Category updated successfully!", - "complaint": "Complaint", - "did_you_find_this_insight_helpful": "Did you find this insight helpful?", - "failed_to_update_category": "Failed to update category", - "feature_request": "Request", - "good_afternoon": "\uD83C\uDF24️ Good afternoon", - "good_evening": "\uD83C\uDF19 Good evening", - "good_morning": "☀️ Good morning", - "insights_description": "All insights generated from responses across all your surveys", - "insights_for_project": "Insights for {projectName}", - "new_responses": "Responses", - "no_insights_for_this_filter": "No insights for this filter", - "no_insights_found": "No insights found. Collect more survey responses or enable insights for your existing surveys to get started.", - "praise": "Praise", - "sentiment_score": "Sentiment Score", - "templates_card_description": "Choose a template or start from scratch", - "templates_card_title": "Measure your customer experience", - "this_month": "This month", - "this_quarter": "This quarter", - "this_week": "This week", - "today": "Today" - }, "formbricks_logo": "Formbricks Logo", "integrations": { "activepieces_integration_description": "Instantly connect Formbricks with popular apps to automate tasks without coding.", @@ -652,6 +615,7 @@ "airtable_integration": "Airtable Integration", "airtable_integration_description": "Sync responses directly with Airtable.", "airtable_integration_is_not_configured": "Airtable Integration is not configured", + "airtable_logo": "Airtable logo", "connect_with_airtable": "Connect with Airtable", "link_airtable_table": "Link Airtable Table", "link_new_table": "Link new table", @@ -719,7 +683,6 @@ "select_a_database": "Select Database", "select_a_field_to_map": "Select a field to map", "select_a_survey_question": "Select a survey question", - "sync_responses_with_a_notion_database": "Sync responses with a Notion Database", "update_connection": "Reconnect Notion", "update_connection_tooltip": "Reconnect the integration to include newly added databases. Your existing integrations will remain intact." }, @@ -741,6 +704,7 @@ "slack_integration": "Slack Integration", "slack_integration_description": "Send responses directly to Slack.", "slack_integration_is_not_configured": "Slack Integration is not configured in your instance of Formbricks.", + "slack_logo": "Slack logo", "slack_reconnect_button": "Reconnect", "slack_reconnect_button_description": "Note: We recently changed our Slack integration to also support private channels. Please reconnect your Slack workspace." }, @@ -777,6 +741,7 @@ }, "project": { "api_keys": { + "access_control": "Access Control", "add_api_key": "Add API Key", "api_key": "API Key", "api_key_copied_to_clipboard": "API key copied to clipboard", @@ -784,9 +749,12 @@ "api_key_deleted": "API Key deleted", "api_key_label": "API Key Label", "api_key_security_warning": "For security reasons, the API key will only be shown once after creation. Please copy it to your destination right away.", + "api_key_updated": "API Key updated", "duplicate_access": "Duplicate project access not allowed", "no_api_keys_yet": "You don't have any API keys yet", + "no_env_permissions_found": "No environment permissions found", "organization_access": "Organization Access", + "organization_access_description": "Select read or write privileges for organization-wide resources.", "permissions": "Permissions", "project_access": "Project Access", "secret": "Secret", @@ -796,6 +764,8 @@ "api_host_description": "This is the URL of your Formbricks backend.", "app_connection": "App Connection", "app_connection_description": "Connect your app to Formbricks.", + "cache_update_delay_description": "When you make updates to surveys, contacts, actions, or other data, it can take up to 5 minutes for those changes to appear in your local app running the Formbricks SDK. This delay is due to a limitation in our current caching system. We’re actively reworking the cache and will release a fix in Formbricks 4.0.", + "cache_update_delay_title": "Changes will be reflected after 5 minutes due to caching", "check_out_the_docs": "Check out the docs.", "dive_into_the_docs": "Dive into the docs.", "does_your_widget_work": "Does your widget work?", @@ -921,8 +891,7 @@ "tag_already_exists": "Tag already exists", "tag_deleted": "Tag deleted", "tag_updated": "Tag updated", - "tags_merged": "Tags merged", - "unique_constraint_failed_on_the_fields": "Unique constraint failed on the fields" + "tags_merged": "Tags merged" }, "teams": { "manage_teams": "Manage teams", @@ -970,6 +939,7 @@ "save_your_filters_as_a_segment_to_use_it_in_other_surveys": "Save your filters as a Segment to use it in other surveys", "segment_created_successfully": "Segment created successfully!", "segment_deleted_successfully": "Segment deleted successfully!", + "segment_id": "Segment ID", "segment_saved_successfully": "Segment saved successfully", "segment_updated_successfully": "Segment updated successfully!", "segments_help_you_target_users_with_same_characteristics_easily": "Segments help you target users with the same characteristics easily", @@ -991,67 +961,56 @@ "api_keys": { "add_api_key": "Add API key", "add_permission": "Add permission", - "api_keys_description": "Manage API keys to access Formbricks management APIs", - "only_organization_owners_and_managers_can_manage_api_keys": "Only organization owners and managers can manage API keys" + "api_keys_description": "Manage API keys to access Formbricks management APIs" }, "billing": { - "10000_monthly_responses": "10000 Monthly Responses", - "1500_monthly_responses": "1500 Monthly Responses", - "2000_monthly_identified_users": "2000 Monthly Identified Users", - "30000_monthly_identified_users": "30000 Monthly Identified Users", + "1000_monthly_responses": "Monthly 1,000 Responses", + "1_project": "1 Project", + "2000_contacts": "2,000 Contacts", "3_projects": "3 Projects", - "5000_monthly_responses": "5000 Monthly Responses", - "5_projects": "5 Projects", - "7500_monthly_identified_users": "7500 Monthly Identified Users", - "advanced_targeting": "Advanced Targeting", + "5000_monthly_responses": "5,000 Monthly Responses", + "7500_contacts": "7,500 Contacts", "all_integrations": "All Integrations", - "all_surveying_features": "All surveying features", "annually": "Annually", "api_webhooks": "API & Webhooks", "app_surveys": "App Surveys", - "contact_us": "Contact Us", + "attribute_based_targeting": "Attribute-based Targeting", "current": "Current", "current_plan": "Current Plan", "current_tier_limit": "Current Tier Limit", - "custom_miu_limit": "Custom MIU limit", + "custom": "Custom & Scale", + "custom_contacts_limit": "Custom Contacts Limit", "custom_project_limit": "Custom Project Limit", - "customer_success_manager": "Customer Success Manager", + "custom_response_limit": "Custom Response Limit", "email_embedded_surveys": "Email Embedded Surveys", - "email_support": "Email Support", - "enterprise": "Enterprise", + "email_follow_ups": "Email Follow-ups", "enterprise_description": "Premium support and custom limits.", "everybody_has_the_free_plan_by_default": "Everybody has the free plan by default!", "everything_in_free": "Everything in Free", - "everything_in_scale": "Everything in Scale", "everything_in_startup": "Everything in Startup", "free": "Free", "free_description": "Unlimited Surveys, Team Members, and more.", "get_2_months_free": "Get 2 months free", "get_in_touch": "Get in touch", + "hosted_in_frankfurt": "Hosted in Frankfurt", + "ios_android_sdks": "iOS & Android SDK for mobile surveys", "link_surveys": "Link Surveys (Shareable)", "logic_jumps_hidden_fields_recurring_surveys": "Logic Jumps, Hidden Fields, Recurring Surveys, etc.", "manage_card_details": "Manage Card Details", "manage_subscription": "Manage Subscription", "monthly": "Monthly", "monthly_identified_users": "Monthly Identified Users", - "multi_language_surveys": "Multi-Language Surveys", "per_month": "per month", "per_year": "per year", "plan_upgraded_successfully": "Plan upgraded successfully", "premium_support_with_slas": "Premium support with SLAs", - "priority_support": "Priority Support", "remove_branding": "Remove Branding", - "say_hi": "Say Hi!", - "scale": "Scale", - "scale_description": "Advanced features for scaling your business.", "startup": "Startup", "startup_description": "Everything in Free with additional features.", "switch_plan": "Switch Plan", "switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.", "team_access_roles": "Team Access Roles", - "technical_onboarding": "Technical Onboarding", "unable_to_upgrade_plan": "Unable to upgrade plan", - "unlimited_apps_websites": "Unlimited Apps & Websites", "unlimited_miu": "Unlimited MIU", "unlimited_projects": "Unlimited Projects", "unlimited_responses": "Unlimited Responses", @@ -1062,7 +1021,6 @@ "website_surveys": "Website Surveys" }, "enterprise": { - "ai": "AI Analysis", "audit_logs": "Audit Logs", "coming_soon": "Coming soon", "contacts_and_segments": "Contact management & segments", @@ -1091,6 +1049,7 @@ "create_new_organization": "Create new organization", "create_new_organization_description": "Create a new organization to handle a different set of projects.", "customize_email_with_a_higher_plan": "Customize email with a higher plan", + "delete_member_confirmation": "Deleted members will lose access to all projects and surveys of your organization.", "delete_organization": "Delete Organization", "delete_organization_description": "Delete organization with all its projects including all surveys, responses, people, actions and attributes", "delete_organization_warning": "Before you proceed with deleting this organization, please be aware of the following consequences:", @@ -1100,13 +1059,7 @@ "eliminate_branding_with_whitelabel": "Eliminate Formbricks branding and enable additional white-label customization options.", "email_customization_preview_email_heading": "Hey {userName}", "email_customization_preview_email_text": "This is an email preview to show you which logo will be rendered in the emails.", - "enable_formbricks_ai": "Enable Formbricks AI", "error_deleting_organization_please_try_again": "Error deleting organization. Please try again.", - "formbricks_ai": "Formbricks AI", - "formbricks_ai_description": "Get personalised insights from your survey responses with Formbricks AI", - "formbricks_ai_disable_success_message": "Formbricks AI disabled successfully.", - "formbricks_ai_enable_success_message": "Formbricks AI enabled successfully.", - "formbricks_ai_privacy_policy_text": "By activating Formbricks AI, you agree to the updated", "from_your_organization": "from your organization", "invitation_sent_once_more": "Invitation sent once more.", "invite_deleted_successfully": "Invite deleted successfully", @@ -1153,10 +1106,8 @@ "need_slack_or_discord_notifications": "Need Slack or Discord notifications", "notification_settings_updated": "Notification settings updated", "set_up_an_alert_to_get_an_email_on_new_responses": "Set up an alert to get an email on new responses", - "stay_up_to_date_with_a_Weekly_every_Monday": "Stay up-to-date with a Weekly every Monday", "use_the_integration": "Use the integration", "want_to_loop_in_organization_mates": "Want to loop in organization mates", - "weekly_summary_projects": "Weekly summary (Projects)", "you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "You will not be auto-subscribed to this organization's surveys anymore!", "you_will_not_receive_any_more_emails_for_responses_on_this_survey": "You will not receive any more emails for responses on this survey!" }, @@ -1172,6 +1123,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.", @@ -1252,8 +1204,9 @@ "copy_survey_description": "Copy this survey to another environment", "copy_survey_error": "Failed to copy survey", "copy_survey_link_to_clipboard": "Copy survey link to clipboard", + "copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.", "copy_survey_success": "Survey copied successfully!", - "delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses? This action cannot be undone.", + "delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?", "edit": { "1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:", "2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:", @@ -1273,6 +1226,8 @@ "add_description": "Add description", "add_ending": "Add ending", "add_ending_below": "Add ending below", + "add_fallback": "Add", + "add_fallback_placeholder": "Add a placeholder to show if the question gets skipped:", "add_hidden_field_id": "Add hidden field ID", "add_highlight_border": "Add highlight border", "add_highlight_border_description": "Add an outer border to your survey card.", @@ -1311,8 +1266,6 @@ "automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Automatically release the survey at the beginning of the day (UTC).", "back_button_label": "\"Back\" Button Label", "background_styling": "Background Styling", - "blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Blocks survey if a submission with the Single Use Id (suId) exists already.", - "blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Blocks survey if the survey URL has no Single Use Id (suId).", "brand_color": "Brand color", "brightness": "Brightness", "button_label": "Button Label", @@ -1326,14 +1279,21 @@ "card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys", "card_background_color": "Card background color", "card_border_color": "Card border color", - "card_shadow_color": "Card shadow color", "card_styling": "Card Styling", "casual": "Casual", + "caution_edit_duplicate": "Duplicate & edit", + "caution_edit_published_survey": "Edit a published survey?", + "caution_explanation_intro": "We understand you might still want to make changes. Here’s what happens if you do: ", + "caution_explanation_new_responses_separated": "Responses before the change may not or only partially be included in the survey summary.", + "caution_explanation_only_new_responses_in_summary": "All data, including past responses, remain available as download on the survey summary page.", + "caution_explanation_responses_are_safe": "Older and newer responses get mixed which can lead to misleading data summaries.", + "caution_recommendation": "This may cause data inconsistencies in the survey summary. We recommend duplicating the survey instead.", "caution_text": "Changes will lead to inconsistencies", "centered_modal_overlay_color": "Centered modal overlay color", "change_anyway": "Change anyway", "change_background": "Change background", "change_question_type": "Change question type", + "change_survey_type": "Switching survey type affects existing access", "change_the_background_color_of_the_card": "Change the background color of the card.", "change_the_background_color_of_the_input_fields": "Change the background color of the input fields.", "change_the_background_to_a_color_image_or_animation": "Change the background to a color, image or animation.", @@ -1343,8 +1303,8 @@ "change_the_brand_color_of_the_survey": "Change the brand color of the survey.", "change_the_placement_of_this_survey": "Change the placement of this survey.", "change_the_question_color_of_the_survey": "Change the question color of the survey.", - "change_the_shadow_color_of_the_card": "Change the shadow color of the card.", "changes_saved": "Changes saved.", + "changing_survey_type_will_remove_existing_distribution_channels": "Changing the survey type will affect how it can be shared. If respondents already have access links for the current type, they may lose access after the switch.", "character_limit_toggle_description": "Limit how short or long an answer can be.", "character_limit_toggle_title": "Add character limits", "checkbox_label": "Checkbox Label", @@ -1354,10 +1314,11 @@ "close_survey_on_date": "Close survey on date", "close_survey_on_response_limit": "Close survey on response limit", "color": "Color", + "column_used_in_logic_error": "This column is used in logic of question {questionIndex}. Please remove it from logic first.", "columns": "Columns", "company": "Company", "company_logo": "Company logo", - "completed_responses": "completed responses.", + "completed_responses": "partial or completed responses.", "concat": "Concat +", "conditional_logic": "Conditional Logic", "confirm_default_language": "Confirm default language", @@ -1391,8 +1352,9 @@ "does_not_start_with": "Does not start with", "edit_recall": "Edit Recall", "edit_translations": "Edit {lang} translations", - "enable_encryption_of_single_use_id_suid_in_survey_url": "Enable encryption of Single Use Id (suId) in survey URL.", "enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.", + "enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.", + "enable_spam_protection": "Spam protection", "end_screen_card": "End screen card", "ending_card": "Ending card", "ending_card_used_in_logic": "This ending card is used in logic of question {questionIndex}.", @@ -1403,6 +1365,7 @@ "error_saving_changes": "Error saving changes", "even_after_they_submitted_a_response_e_g_feedback_box": "Even after they submitted a response (e.g. Feedback Box)", "everyone": "Everyone", + "fallback_for": "Fallback for ", "fallback_missing": "Fallback missing", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.", "field_name_eg_score_price": "Field name e.g, score, price", @@ -1420,6 +1383,8 @@ "follow_ups_item_issue_detected_tag": "Issue detected", "follow_ups_item_response_tag": "Any response", "follow_ups_item_send_email_tag": "Send email", + "follow_ups_modal_action_attach_response_data_description": "Add the data of the survey response to the follow-up", + "follow_ups_modal_action_attach_response_data_label": "Attach response data", "follow_ups_modal_action_body_label": "Body", "follow_ups_modal_action_body_placeholder": "Body of the email", "follow_ups_modal_action_email_content": "Email content", @@ -1450,9 +1415,6 @@ "follow_ups_new": "New follow-up", "follow_ups_upgrade_button_text": "Upgrade to enable follow-ups", "form_styling": "Form styling", - "formbricks_ai_description": "Describe your survey and let Formbricks AI create the survey for you", - "formbricks_ai_generate": "Generate", - "formbricks_ai_prompt_placeholder": "Enter survey information (e.g. key topics to cover)", "formbricks_sdk_is_not_connected": "Formbricks SDK is not connected", "four_points": "4 points", "heading": "Heading", @@ -1465,7 +1427,6 @@ "hide_the_logo_in_this_specific_survey": "Hide the logo in this specific survey", "hostname": "Hostname", "how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "How funky do you want your cards in {surveyTypeDerived} Surveys", - "how_it_works": "How it works", "if_you_need_more_please": "If you need more, please", "if_you_really_want_that_answer_ask_until_you_get_it": "If you really want that answer, ask until you get it.", "ignore_waiting_time_between_surveys": "Ignore waiting time between surveys", @@ -1481,10 +1442,13 @@ "invalid_youtube_url": "Invalid YouTube URL", "is_accepted": "Is accepted", "is_after": "Is after", + "is_any_of": "Is any of", "is_before": "Is before", "is_booked": "Is booked", "is_clicked": "Is clicked", "is_completely_submitted": "Is completely submitted", + "is_empty": "Is empty", + "is_not_empty": "Is not empty", "is_not_set": "Is not set", "is_partially_submitted": "Is partially submitted", "is_set": "Is set", @@ -1500,7 +1464,6 @@ "limit_the_maximum_file_size": "Limit the maximum file size", "limit_upload_file_size_to": "Limit upload file size to", "link_survey_description": "Share a link to a survey page or embed it in a web page or email.", - "link_used_message": "Link Used", "load_segment": "Load segment", "logic_error_warning": "Changing will cause logic errors", "logic_error_warning_text": "Changing the question type will remove the logic conditions from this question", @@ -1516,6 +1479,7 @@ "no_hidden_fields_yet_add_first_one_below": "No hidden fields yet. Add the first one below.", "no_images_found_for": "No images found for ''{query}\"", "no_languages_found_add_first_one_to_get_started": "No languages found. Add the first one to get started.", + "no_option_found": "No option found", "no_variables_yet_add_first_one_below": "No variables yet. Add the first one below.", "number": "Number", "once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Once set, the default language for this survey can only be changed by disabling the multi-language option and deleting all translations.", @@ -1567,6 +1531,7 @@ "response_limits_redirections_and_more": "Response limits, redirections and more.", "response_options": "Response Options", "roundness": "Roundness", + "row_used_in_logic_error": "This row is used in logic of question {questionIndex}. Please remove it from logic first.", "rows": "Rows", "save_and_close": "Save & Close", "scale": "Scale", @@ -1590,10 +1555,12 @@ "show_survey_to_users": "Show survey to % of users", "show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users", "simple": "Simple", - "single_use_survey_links": "Single-use survey links", - "single_use_survey_links_description": "Allow only 1 response per survey link.", + "six_points": "6 points", "skip_button_label": "Skip Button Label", "smiley": "Smiley", + "spam_protection_note": "Spam protection does not work for surveys displayed with the iOS, React Native, and Android SDKs. It will break the survey.", + "spam_protection_threshold_description": "Set value between 0 and 1, responses below this value will be rejected.", + "spam_protection_threshold_heading": "Response threshold", "star": "Star", "starts_with": "Starts with", "state": "State", @@ -1604,8 +1571,6 @@ "subheading": "Subheading", "subtract": "Subtract -", "suggest_colors": "Suggest colors", - "survey_already_answered_heading": "The survey has already been answered.", - "survey_already_answered_subheading": "You can only use this link once.", "survey_completed_heading": "Survey Completed", "survey_completed_subheading": "This free & open-source survey has been closed", "survey_display_settings": "Survey Display Settings", @@ -1636,7 +1601,6 @@ "upload": "Upload", "upload_at_least_2_images": "Upload at least 2 images", "upper_label": "Upper Label", - "url_encryption": "URL Encryption", "url_filters": "URL Filters", "url_not_supported": "URL not supported", "use_with_caution": "Use with caution", @@ -1649,7 +1613,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Wait a few seconds after the trigger before showing the survey", "waiting_period": "waiting period", "welcome_message": "Welcome message", - "when": "When", "when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "When conditions match, waiting time will be ignored and survey shown.", "without_a_filter_all_of_your_users_can_be_surveyed": "Without a filter, all of your users can be surveyed.", "you_have_not_created_a_segment_yet": "You have not created a segment yet", @@ -1660,9 +1623,11 @@ "zip": "Zip" }, "error_deleting_survey": "An error occured while deleting survey", - "failed_to_copy_link_to_results": "Failed to copy link to results", - "failed_to_copy_url": "Failed to copy URL: not in a browser environment.", - "new_single_use_link_generated": "New single use link generated", + "filter": { + "complete_and_partial_responses": "Complete and partial responses", + "complete_responses": "Complete responses", + "partial_responses": "Partial responses" + }, "new_survey": "New Survey", "no_surveys_created_yet": "No surveys created yet", "open_options": "Open options", @@ -1681,9 +1646,11 @@ "company": "Company", "completed": "Completed ✅", "country": "Country", + "delete_response_confirmation": "This will delete the survey response, including all answers, tags, attached documents, and response metadata.", "device": "Device", "device_info": "Device info", "email": "Email", + "error_downloading_responses": "An error occured while downloading responses", "first_name": "First Name", "how_to_identify_users": "How to identify users", "last_name": "Last Name", @@ -1702,8 +1669,91 @@ "this_response_is_in_progress": "This response is in progress.", "zip_post_code": "ZIP / Post code" }, - "results_unpublished_successfully": "Results unpublished successfully.", "search_by_survey_name": "Search by survey name", + "share": { + "anonymous_links": { + "custom_single_use_id_description": "If you don’t encrypt single-use ID’s, any value for “suid=...” works for one response.", + "custom_single_use_id_title": "You can set any value as single-use ID in the URL.", + "custom_start_point": "Custom start point", + "data_prefilling": "Data prefilling", + "description": "Responses coming from these links will be anonymous", + "disable_multi_use_link_modal_button": "Disable multi-use link", + "disable_multi_use_link_modal_description": "Disabling the multi-use link will prevent anyone to submit a response via the link.", + "disable_multi_use_link_modal_description_subtext": "This will also break any active embeds on Websites, Emails, Social Media and QR codes that use this multi-use link.", + "disable_multi_use_link_modal_title": "Are you sure? This can break active embeddings", + "disable_single_use_link_modal_button": "Disable single-use links", + "disable_single_use_link_modal_description": "If you shared single-use links, participants will not be able to respond to the survey any longer.", + "generate_and_download_links": "Generate & download links", + "generate_links_error": "Single use links could not get generated. Please work directly with the API", + "multi_use_link": "Multi-use link", + "multi_use_link_description": "Collect multiple responses from anonymous respondents with one link.", + "multi_use_powers_other_channels_description": "If you disable it, these other distribution channels will also get disabled.", + "multi_use_powers_other_channels_title": "This link powers Website embeds, Email embeds, Social media sharing and QR codes.", + "nav_title": "Anonymous links", + "number_of_links_label": "Number of links (1 - 5,000)", + "single_use_link": "Single-use links", + "single_use_link_description": "Allow only one response per survey link.", + "single_use_links": "Single-use links", + "source_tracking": "Source tracking", + "url_encryption_description": "Only disable if you need to set a custom single-use ID.", + "url_encryption_label": "URL encryption of single-use ID" + }, + "dynamic_popup": { + "alert_button": "Edit survey", + "alert_description": "This survey is currently configured as a link survey, which does not support dynamic pop-ups. You can change this in the settings tab of the survey editor.", + "alert_title": "Change survey type to in-app", + "attribute_based_targeting": "Attribute-based targeting", + "code_no_code_triggers": "Code and no code triggers", + "description": "Formbricks surveys can be embedded as a pop up, based on user interaction.", + "nav_title": "Dynamic (Pop-up)", + "recontact_options": "Recontact options" + }, + "embed_on_website": { + "description": "Formbricks surveys can be embedded as a static element.", + "embed_code_copied_to_clipboard": "Embed code copied to clipboard!", + "embed_mode": "Embed Mode", + "embed_mode_description": "Embed your survey with a minimalist design, discarding padding and background.", + "nav_title": "Website embed" + }, + "personal_links": { + "create_and_manage_segments": "Create and manage your Segments under Contacts > Segments", + "description": "Generate personal links for a segment and match survey responses to each contact.", + "expiry_date_description": "Once the link expires, the recipient cannot respond to survey any longer.", + "expiry_date_optional": "Expiry date (optional)", + "generate_and_download_links": "Generate & download links", + "generating_links": "Generating links", + "generating_links_toast": "Generating links, download will start soon…", + "links_generated_success_toast": "Links generated successfully, your download will start soon.", + "nav_title": "Personal links", + "no_segments_available": "No segments available", + "select_segment": "Select segment", + "upgrade_prompt_description": "Generate personal links for a segment and link survey responses to each contact.", + "upgrade_prompt_title": "Use personal links with a higher plan", + "work_with_segments": "Personal links work with segments." + }, + "send_email": { + "copy_embed_code": "Copy embed code", + "description": "Embed your survey in an email to get responses from your audience.", + "email_preview_tab": "Email Preview", + "email_sent": "Email sent!", + "email_subject_label": "Subject", + "email_to_label": "To", + "embed_code_copied_to_clipboard": "Embed code copied to clipboard!", + "embed_code_copied_to_clipboard_failed": "Copy failed, please try again", + "embed_code_tab": "Embed Code", + "formbricks_email_survey_preview": "Formbricks Email Survey Preview", + "nav_title": "Email embed", + "send_preview": "Send preview", + "send_preview_email": "Send preview email" + }, + "share_view_title": "Share via", + "social_media": { + "description": "Get responses from your contacts on various social media networks.", + "source_tracking_enabled": "Source tracking enabled", + "source_tracking_enabled_alert_description": "When sharing from this dialog, the social media network will be appended to the survey link so you know which responses came via each network.", + "title": "Social media" + } + }, "summary": { "added_filter_for_responses_where_answer_to_question": "Added filter for responses where answer to question {questionIdx} is {filterComboBoxValue} - {filterValue} ", "added_filter_for_responses_where_answer_to_question_is_skipped": "Added filter for responses where answer to question {questionIdx} is skipped", @@ -1717,52 +1767,50 @@ "configure_alerts": "Configure alerts", "congrats": "Congrats! Your survey is live.", "connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.", - "copy_link_to_public_results": "Copy link to public results", - "create_single_use_links": "Create single-use links", - "create_single_use_links_description": "Accept only one submission per link. Here is how.", - "current_selection_csv": "Current selection (CSV)", - "current_selection_excel": "Current selection (Excel)", "custom_range": "Custom range...", - "data_prefilling": "Data prefilling", - "data_prefilling_description": "You want to prefill some fields in the survey? Here is how.", - "define_when_and_where_the_survey_should_pop_up": "Define when and where the survey should pop up", + "delete_all_existing_responses_and_displays": "Delete all existing responses and displays", + "download_qr_code": "Download QR code", "drop_offs": "Drop-Offs", "drop_offs_tooltip": "Number of times the survey has been started but not completed.", - "dynamic_popup": "Dynamic (Pop-up)", - "email_sent": "Email sent!", - "embed_code_copied_to_clipboard": "Embed code copied to clipboard!", - "embed_in_an_email": "Embed in an email", - "embed_in_app": "Embed in app", - "embed_mode": "Embed Mode", - "embed_mode_description": "Embed your survey with a minimalist design, discarding padding and background.", - "embed_on_website": "Embed on website", - "embed_pop_up_survey_title": "How to embed a pop-up survey on your website", - "embed_survey": "Embed survey", - "enable_ai_insights_banner_button": "Enable insights", - "enable_ai_insights_banner_description": "You can enable the new insights feature for the survey to get AI-based insights for your open-text responses.", - "enable_ai_insights_banner_success": "Generating insights for this survey. Please check back in a few minutes.", - "enable_ai_insights_banner_title": "Ready to test AI insights?", - "enable_ai_insights_banner_tooltip": "Kindly contact us at hola@formbricks.com to generate insights for this survey", "failed_to_copy_link": "Failed to copy link", "filter_added_successfully": "Filter added successfully", "filter_updated_successfully": "Filter updated successfully", - "formbricks_email_survey_preview": "Formbricks Email Survey Preview", + "filtered_responses_csv": "Filtered responses (CSV)", + "filtered_responses_excel": "Filtered responses (Excel)", "go_to_setup_checklist": "Go to Setup Checklist \uD83D\uDC49", - "hide_embed_code": "Hide embed code", - "how_to_create_a_panel": "How to create a panel", - "how_to_create_a_panel_step_1": "Step 1: Create an account with Prolific", - "how_to_create_a_panel_step_1_description": "Create an account with Prolific and verify your email address.", - "how_to_create_a_panel_step_2": "Step 2: Create a study", - "how_to_create_a_panel_step_2_description": "At Prolific, you create a new study where you can pick your preferred audience based on hundreds of characteristics.", - "how_to_create_a_panel_step_3": "Step 3: Connect your survey", - "how_to_create_a_panel_step_3_description": "Set up hidden fields in your Formbricks survey to track which participant provided which answer.", - "how_to_create_a_panel_step_4": "Step 4: Launch your study", - "how_to_create_a_panel_step_4_description": "Once everything is setup, you can launch your study. Within a few hours you’ll receive the first responses.", "impressions": "Impressions", "impressions_tooltip": "Number of times the survey has been viewed.", + "in_app": { + "connection_description": "The survey will be shown to users of your website, that match the criteria listed below", + "connection_title": "Formbricks SDK is connected", + "description": "Formbricks surveys can be embedded as a pop-up, based on user interaction.", + "display_criteria": "Display criteria", + "display_criteria.audience_description": "Target audience", + "display_criteria.code_trigger": "Code Action", + "display_criteria.everyone": "Everyone", + "display_criteria.no_code_trigger": "No-Code", + "display_criteria.overwritten": "Overwritten", + "display_criteria.randomizer": "{percentage}% Randomizer", + "display_criteria.randomizer_description": "Only {percentage}% of people who perform the action might get surveyed.", + "display_criteria.recontact_description": "Recontact options", + "display_criteria.targeted": "Targeted", + "display_criteria.time_based_always": "Always show survey", + "display_criteria.time_based_day": "Day", + "display_criteria.time_based_days": "Days", + "display_criteria.time_based_description": "Global waiting time", + "display_criteria.trigger_description": "Survey trigger", + "documentation_title": "Distribute intercept surveys on all platforms", + "html_embed": "HTML embed in ", + "ios_sdk": "iOS SDK for Apple apps", + "javascript_sdk": "JavaScript SDK", + "kotlin_sdk": "Kotlin SDK for Android apps", + "no_connection_description": "Connect your website or app with Formbricks to publish intercept surveys.", + "no_connection_title": "You're not plugged in yet!", + "react_native_sdk": "React Native SDK for RN apps.", + "title": "Intercept survey settings" + }, "includes_all": "Includes all", "includes_either": "Includes either", - "insights_disabled": "Insights disabled", "install_widget": "Install Formbricks Widget", "is_equal_to": "Is equal to", "is_less_than": "Is less than", @@ -1772,60 +1820,34 @@ "last_month": "Last month", "last_quarter": "Last quarter", "last_year": "Last year", - "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", - "only_completed": "Only completed", + "no_responses_found": "No responses found", "other_values_found": "Other values found", "overall": "Overall", - "publish_to_web": "Publish to web", - "publish_to_web_warning": "You are about to release these survey results to the public.", - "publish_to_web_warning_description": "Your survey results will be public. Anyone outside your organization can access them if they have the link.", - "quickstart_mobile_apps": "Quickstart: Mobile apps", - "quickstart_mobile_apps_description": "To get started with surveys in mobile apps, please follow the Quickstart guide:", - "quickstart_web_apps": "Quickstart: Web apps", - "quickstart_web_apps_description": "Please follow the Quickstart guide to get started:", - "results_are_public": "Results are public", - "send_preview": "Send preview", - "send_to_panel": "Send to panel", - "setup_instructions": "Setup instructions", + "qr_code": "QR code", + "qr_code_description": "Responses collected via QR code are anonymous.", + "qr_code_download_failed": "QR code download failed", + "qr_code_download_with_start_soon": "QR code download will start soon", + "qr_code_generation_failed": "There was a problem, loading the survey QR Code. Please try again.", + "reset_survey": "Reset survey", + "reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.", + "selected_responses_csv": "Selected responses (CSV)", + "selected_responses_excel": "Selected responses (Excel)", "setup_integrations": "Setup integrations", - "share_results": "Share results", - "share_the_link": "Share the link", - "share_the_link_to_get_responses": "Share the link to get responses", + "share_survey": "Share survey", "show_all_responses_that_match": "Show all responses that match", "show_all_responses_where": "Show all responses where...", - "single_use_links": "Single use links", - "source_tracking": "Source tracking", - "source_tracking_description": "Run GDPR & CCPA compliant source tracking without extra tools.", "starts": "Starts", "starts_tooltip": "Number of times the survey has been started.", - "static_iframe": "Static (iframe)", - "survey_results_are_public": "Your survey results are public!", - "survey_results_are_shared_with_anyone_who_has_the_link": "Your survey results are shared with anyone who has the link. The results will not be indexed by search engines.", + "survey_reset_successfully": "Survey reset successfully! {responseCount} responses and {displayCount} displays were deleted.", "this_month": "This month", "this_quarter": "This quarter", "this_year": "This year", "time_to_complete": "Time to Complete", - "to_connect_your_website_with_formbricks": "to connect your website with Formbricks", "ttc_tooltip": "Average time to complete the survey.", "unknown_question_type": "Unknown Question Type", - "unpublish_from_web": "Unpublish from web", - "unsupported_video_tag_warning": "Your browser does not support the video tag.", - "view_embed_code": "View embed code", - "view_embed_code_for_email": "View embed code for email", - "view_site": "View site", + "use_personal_links": "Use personal links", "waiting_for_response": "Waiting for a response \uD83E\uDDD8‍♂️", - "web_app": "Web app", - "what_is_a_panel": "What is a panel?", - "what_is_a_panel_answer": "A panel is a group of participants selected based on characteristics such as age, profession, gender, etc.", - "what_is_prolific": "What is Prolific?", - "what_is_prolific_answer": "We're partnering with Prolific to give you access to a pool of over 200.000 vetted participants.", "whats_next": "What's next?", - "when_do_i_need_it": "When do I need it?", - "when_do_i_need_it_answer": "If you don’t have access to enough people who match your target audience, it makes sense to pay for access to a panel.", - "you_can_do_a_lot_more_with_links_surveys": "You can do a lot more with links surveys \uD83D\uDCA1", "your_survey_is_public": "Your survey is public", "youre_not_plugged_in_yet": "You're not plugged in yet!" }, @@ -1897,12 +1919,12 @@ }, "s": { "check_inbox_or_spam": "Please also check your spam folder if you don't see the email in your inbox.", - "completed": "This free & open-source survey has been closed.", - "create_your_own": "Create your own", + "completed": "This survey is closed.", + "create_your_own": "Create your own open-source survey", "enter_pin": "This survey is protected. Enter the PIN below", "just_curious": "Just curious?", "link_invalid": "This survey can only be taken by invitation.", - "paused": "This free & open-source survey is temporarily paused.", + "paused": "This survey is temporarily paused.", "please_try_again_with_the_original_link": "Please try again with the original link", "preview_survey_questions": "Preview survey questions.", "question_preview": "Question Preview", @@ -1954,11 +1976,6 @@ "this_user_has_all_the_power": "This user has all the power." } }, - "share": { - "back_to_home": "Back to home", - "page_not_found": "Page not found", - "page_not_found_description": "Sorry, we couldn't find the responses sharing ID you're looking for." - }, "templates": { "address": "Address", "address_description": "Ask for a mailing address", @@ -1969,7 +1986,6 @@ "alignment_and_engagement_survey_question_1_upper_label": "Complete understanding", "alignment_and_engagement_survey_question_2_headline": "I feel that my values align with the company’s mission and culture.", "alignment_and_engagement_survey_question_2_lower_label": "Not aligned", - "alignment_and_engagement_survey_question_2_upper_label": "Completely aligned", "alignment_and_engagement_survey_question_3_headline": "I collaborate effectively with my team to achieve our goals.", "alignment_and_engagement_survey_question_3_lower_label": "Poor collaboration", "alignment_and_engagement_survey_question_3_upper_label": "Excellent collaboration", @@ -1979,7 +1995,6 @@ "book_interview": "Book interview", "build_product_roadmap_description": "Identify the ONE thing your users want the most and build it.", "build_product_roadmap_name": "Build Product Roadmap", - "build_product_roadmap_name_with_project_name": "$[projectName] Roadmap Input", "build_product_roadmap_question_1_headline": "How satisfied are you with the features and functionality of $[projectName]?", "build_product_roadmap_question_1_lower_label": "Not at all satisfied", "build_product_roadmap_question_1_upper_label": "Extremely satisfied", @@ -2162,7 +2177,6 @@ "csat_question_7_choice_3": "Somewhat responsive", "csat_question_7_choice_4": "Not so responsive", "csat_question_7_choice_5": "Not at all responsive", - "csat_question_7_choice_6": "Not applicable", "csat_question_7_headline": "How responsive have we been to your questions about our services?", "csat_question_7_subheader": "Please select one:", "csat_question_8_choice_1": "This is my first purchase", @@ -2170,7 +2184,6 @@ "csat_question_8_choice_3": "Six months to a year", "csat_question_8_choice_4": "1 - 2 years", "csat_question_8_choice_5": "3 or more years", - "csat_question_8_choice_6": "I haven't made a purchase yet", "csat_question_8_headline": "How long have you been a customer of $[projectName]?", "csat_question_8_subheader": "Please select one:", "csat_question_9_choice_1": "Extremely likely", @@ -2385,7 +2398,6 @@ "identify_sign_up_barriers_question_9_dismiss_button_label": "Skip for now", "identify_sign_up_barriers_question_9_headline": "Thanks! Here is your code: SIGNUPNOW10", "identify_sign_up_barriers_question_9_html": "

Thanks a lot for taking the time to share feedback \uD83D\uDE4F

", - "identify_sign_up_barriers_with_project_name": "$[projectName] Sign Up Barriers", "identify_upsell_opportunities_description": "Find out how much time your product saves your user. Use it to upsell.", "identify_upsell_opportunities_name": "Identify Upsell Opportunities", "identify_upsell_opportunities_question_1_choice_1": "Less than 1 hour", @@ -2597,7 +2609,7 @@ "preview_survey_question_2_back_button_label": "Back", "preview_survey_question_2_choice_1_label": "Yes, keep me informed.", "preview_survey_question_2_choice_2_label": "No, thank you!", - "preview_survey_question_2_headline": "What to stay in the loop?", + "preview_survey_question_2_headline": "Want to stay in the loop?", "preview_survey_welcome_card_headline": "Welcome!", "preview_survey_welcome_card_html": "Thanks for providing your feedback - let's go!", "prioritize_features_description": "Identify features your users need most and least.", @@ -2750,7 +2762,6 @@ "site_abandonment_survey_question_6_choice_3": "More product variety", "site_abandonment_survey_question_6_choice_4": "Improved site design", "site_abandonment_survey_question_6_choice_5": "More customer reviews", - "site_abandonment_survey_question_6_choice_6": "Other", "site_abandonment_survey_question_6_headline": "What improvements would encourage you to stay longer on our site?", "site_abandonment_survey_question_6_subheader": "Please select all that apply:", "site_abandonment_survey_question_7_headline": "Would you like to receive updates about new products and promotions?", @@ -2781,6 +2792,8 @@ "star_rating_survey_question_3_placeholder": "Type your answer here...", "star_rating_survey_question_3_subheader": "Help us improve your experience.", "statement_call_to_action": "Statement (Call to Action)", + "strongly_agree": "Strongly Agree", + "strongly_disagree": "Strongly Disagree", "supportive_work_culture_survey_description": "Assess employee perceptions of leadership support, communication, and the overall work environment.", "supportive_work_culture_survey_name": "Supportive Work Culture", "supportive_work_culture_survey_question_1_headline": "My manager provides me with the support I need to complete my work.", @@ -2836,6 +2849,18 @@ "understand_purchase_intention_question_2_headline": "Got it. What's your primary reason for visiting today?", "understand_purchase_intention_question_2_placeholder": "Type your answer here...", "understand_purchase_intention_question_3_headline": "What, if anything, is holding you back from making a purchase today?", - "understand_purchase_intention_question_3_placeholder": "Type your answer here..." + "understand_purchase_intention_question_3_placeholder": "Type your answer here...", + "usability_question_10_headline": " I had to learn a lot before I could start using the system properly.", + "usability_question_1_headline": "I’d probably use this system often.", + "usability_question_2_headline": "The system felt more complicated than it needed to be.", + "usability_question_3_headline": "The system was easy to figure out.", + "usability_question_4_headline": "I think I’d need help from a tech expert to use this system.", + "usability_question_5_headline": "Everything in the system seemed to work well together.", + "usability_question_6_headline": "The system felt inconsistent in how things worked.", + "usability_question_7_headline": "I think most people could learn to use this system quickly.", + "usability_question_8_headline": "Using the system felt like a hassle.", + "usability_question_9_headline": "I felt confident while using the system.", + "usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.", + "usability_score_name": "System Usability Score (SUS)" } } diff --git a/packages/lib/messages/fr-FR.json b/apps/web/locales/fr-FR.json similarity index 90% rename from packages/lib/messages/fr-FR.json rename to apps/web/locales/fr-FR.json index 51cf1e0588c1..547ad7e594fc 100644 --- a/packages/lib/messages/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -1,12 +1,23 @@ { "auth": { - "continue_with_azure": "Continuer avec Azure", + "continue_with_azure": "Continuer avec Microsoft", "continue_with_email": "Continuer avec l'e-mail", "continue_with_github": "Continuer avec GitHub", "continue_with_google": "Continuer avec Google", "continue_with_oidc": "Continuer avec {oidcDisplayName}", "continue_with_openid": "Continuer avec OpenID", "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_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" + }, "forgot-password": { "back_to_login": "Retour à la connexion", "email-sent": { @@ -23,7 +34,8 @@ "text": "Vous pouvez maintenant vous connecter avec votre nouveau mot de passe." } }, - "reset_password": "Réinitialiser le mot de passe" + "reset_password": "Réinitialiser le mot de passe", + "reset_password_description": "Vous serez déconnecté pour réinitialiser votre mot de passe." }, "invite": { "create_account": "Créer un compte", @@ -68,7 +80,7 @@ }, "signup_without_verification_success": { "user_successfully_created": "Utilisateur créé avec succès", - "user_successfully_created_description": "Votre nouvel utilisateur a été créé avec succès. Veuillez cliquer sur le bouton ci-dessous et vous connecter à votre compte." + "user_successfully_created_info": "Nous avons vérifié s'il existait un compte associé à {email}. Si aucun n'existait, nous en avons créé un pour vous. Si un compte existait déjà, aucune modification n'a été apportée. Veuillez vous connecter ci-dessous pour continuer." }, "testimonial_1": "Nous mesurons la clarté de nos documents et apprenons des abandons, le tout sur une seule plateforme. Excellent produit, équipe très réactive !", "testimonial_all_features_included": "Toutes les fonctionnalités incluses", @@ -78,12 +90,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é avec succès. Veuillez vérifier votre boîte de réception.", - "we_sent_an_email_to": "Nous avons envoyé un email à {email}", + "verification_email_resent_successfully": "E-mail de vérification envoyé ! Veuillez vérifier votre boîte de réception.", + "verification_email_successfully_sent_info": "Si un compte est associé à {email}, nous avons envoyé un lien de vérification à cette adresse. Veuillez vérifier votre boîte de réception pour terminer l'inscription.", "you_didnt_receive_an_email_or_your_link_expired": "Vous n'avez pas reçu d'email ou votre lien a expiré ?" }, "verify": { @@ -96,6 +108,10 @@ "thanks_for_upgrading": "Merci beaucoup d'avoir mis à niveau votre abonnement Formbricks.", "upgrade_successful": "Mise à niveau réussie" }, + "c": { + "link_expired": "Votre lien est expiré.", + "link_expired_description": "Le lien que vous avez utilisé n'est plus valide." + }, "common": { "accepted": "Accepté", "account": "Compte", @@ -108,6 +124,7 @@ "add_action": "Ajouter une action", "add_filter": "Ajouter un filtre", "add_logo": "Ajouter un logo", + "add_member": "Ajouter un membre", "add_project": "Ajouter un projet", "add_to_team": "Ajouter à l'équipe", "all": "Tout", @@ -123,7 +140,6 @@ "app_survey": "Sondage d'application", "apply_filters": "Appliquer des filtres", "are_you_sure": "Es-tu sûr ?", - "are_you_sure_this_action_cannot_be_undone": "Êtes-vous sûr ? Cette action ne peut pas être annulée.", "attributes": "Attributs", "avatar": "Avatar", "back": "Retour", @@ -149,11 +165,13 @@ "connect_formbricks": "Connecter Formbricks", "connected": "Connecté", "contacts": "Contacts", + "copied": "Copié", "copied_to_clipboard": "Copié dans le presse-papiers", "copy": "Copier", "copy_code": "Copier le code", "copy_link": "Copier le lien", "create_new_organization": "Créer une nouvelle organisation", + "create_project": "Créer un projet", "create_segment": "Créer un segment", "create_survey": "Créer un sondage", "created": "Créé", @@ -180,13 +198,10 @@ "e_commerce": "E-commerce", "edit": "Modifier", "email": "Email", - "embed": "Intégrer", "enterprise_license": "Licence d'entreprise", "environment_not_found": "Environnement non trouvé", "environment_notice": "Vous êtes actuellement dans l'environnement {environment}.", "error": "Erreur", - "error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.", - "error_component_title": "Erreur de chargement des ressources", "expand_rows": "Développer les lignes", "finish": "Terminer", "follow_these": "Suivez ceci", @@ -209,7 +224,6 @@ "in_progress": "En cours", "inactive_surveys": "Sondages inactifs", "input_type": "Type d'entrée", - "insights": "Perspectives", "integration": "intégration", "integrations": "Intégrations", "invalid_date": "Date invalide", @@ -225,7 +239,6 @@ "limits_reached": "Limites atteints", "link": "Lien", "link_and_email": "Liens et e-mail", - "link_copied": " lien copié dans le presse-papiers !", "link_survey": "Enquête de lien", "link_surveys": "Sondages de lien", "load_more": "Charger plus", @@ -246,8 +259,6 @@ "move_up": "Déplacer vers le haut", "multiple_languages": "Plusieurs langues", "name": "Nom", - "negative": "Négatif", - "neutral": "Neutre", "new": "Nouveau", "new_survey": "Nouveau Sondage", "new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !", @@ -269,6 +280,8 @@ "on": "Sur", "only_one_file_allowed": "Un seul fichier est autorisé", "only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.", + "option_id": "Identifiant de l'option", + "option_ids": "Identifiants des options", "or": "ou", "organization": "Organisation", "organization_id": "ID de l'organisation", @@ -285,32 +298,34 @@ "phone": "Téléphone", "photo_by": "Photo par", "pick_a_date": "Choisissez une date", + "picture": "Photo", "placeholder": "Remplaçant", "please_select_at_least_one_survey": "Veuillez sélectionner au moins une enquête.", "please_select_at_least_one_trigger": "Veuillez sélectionner au moins un déclencheur.", "please_upgrade_your_plan": "Veuillez mettre à niveau votre plan.", - "positive": "Positif", "preview": "Aperçu", "preview_survey": "Aperçu de l'enquête", "privacy": "Politique de confidentialité", - "privacy_policy": "Politique de confidentialité", "product_manager": "Chef de produit", "profile": "Profil", - "project": "Projet", + "profile_id": "Identifiant de profil", "project_configuration": "Configuration du projet", + "project_creation_description": "Organisez les enquêtes en projets pour un meilleur contrôle d'accès.", "project_id": "ID de projet", "project_name": "Nom du projet", + "project_name_placeholder": "p.ex. Formbricks", "project_not_found": "Projet non trouvé", "project_permission_not_found": "Autorisation de projet non trouvée", "projects": "Projets", - "projects_limit_reached": "Limite de projets atteinte", "question": "Question", "question_id": "ID de la question", "questions": "Questions", "read_docs": "Lire les documents", + "recipients": "Destinataires", "remove": "Retirer", "reorder_and_hide_columns": "Réorganiser et masquer des colonnes", "report_survey": "Rapport d'enquête", + "request_pricing": "Demander la tarification", "request_trial_license": "Demander une licence d'essai", "reset_to_default": "Réinitialiser par défaut", "response": "Réponse", @@ -330,6 +345,7 @@ "select": "Sélectionner", "select_all": "Sélectionner tout", "select_survey": "Sélectionner l'enquête", + "select_teams": "Sélectionner les équipes", "selected": "Sélectionné", "selected_questions": "Questions sélectionnées", "selection": "Sélection", @@ -346,6 +362,7 @@ "skipped": "Passé", "skips": "Sauter", "some_files_failed_to_upload": "Certains fichiers n'ont pas pu être téléchargés", + "something_went_wrong": "Quelque chose s'est mal passé.", "something_went_wrong_please_try_again": "Une erreur s'est produite. Veuillez réessayer.", "sort_by": "Trier par", "start_free_trial": "Commencer l'essai gratuit", @@ -372,6 +389,7 @@ "targeting": "Ciblage", "team": "Équipe", "team_access": "Accès Équipe", + "team_id": "Équipe ID", "team_name": "Nom de l'équipe", "teams": "Contrôle d'accès", "teams_not_found": "Équipes non trouvées", @@ -404,9 +422,7 @@ "website_and_app_connection": "Connexion Site Web & Application", "website_app_survey": "Sondage sur le site Web et l'application", "website_survey": "Sondage de site web", - "weekly_summary": "Résumé hebdomadaire", "welcome_card": "Carte de bienvenue", - "yes": "Oui", "you": "Vous", "you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.", "you_are_not_authorised_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.", @@ -446,41 +462,13 @@ "invite_email_text_par1": "Votre collègue", "invite_email_text_par2": "vous a invité à les rejoindre sur Formbricks. Pour accepter l'invitation, veuillez cliquer sur le lien ci-dessous :", "invite_member_email_subject": "Vous avez été invité à collaborer sur Formbricks !", - "live_survey_notification_completed": "Terminé", - "live_survey_notification_draft": "Brouillon", - "live_survey_notification_in_progress": "En cours", - "live_survey_notification_no_new_response": "Aucune nouvelle réponse reçue cette semaine \uD83D\uDD75️", - "live_survey_notification_no_responses_yet": "Aucune réponse pour le moment !", - "live_survey_notification_paused": "En pause", - "live_survey_notification_scheduled": "Programmé", - "live_survey_notification_view_more_responses": "Voir {responseCount} réponses supplémentaires", - "live_survey_notification_view_previous_responses": "Voir les réponses précédentes", - "live_survey_notification_view_response": "Voir la réponse", - "notification_footer_all_the_best": "Tous mes vœux,", - "notification_footer_in_your_settings": "dans vos paramètres \uD83D\uDE4F", - "notification_footer_please_turn_them_off": "veuillez les éteindre", - "notification_footer_the_formbricks_team": "L'équipe Formbricks \uD83E\uDD0D", - "notification_footer_to_halt_weekly_updates": "Pour arrêter les mises à jour hebdomadaires,", - "notification_header_hey": "Salut \uD83D\uDC4B", - "notification_header_weekly_report_for": "Rapport hebdomadaire pour", - "notification_insight_completed": "Terminé", - "notification_insight_completion_rate": "Pourcentage d'achèvement", - "notification_insight_displays": "Affichages", - "notification_insight_responses": "Réponses", - "notification_insight_surveys": "Enquêtes", - "onboarding_invite_email_button_label": "Rejoins l'organisation de {inviterName}", - "onboarding_invite_email_connect_formbricks": "Connectez Formbricks à votre application ou site web via un extrait HTML ou NPM en quelques minutes seulement.", - "onboarding_invite_email_create_account": "Créez un compte pour rejoindre l'organisation de {inviterName}.", - "onboarding_invite_email_done": "Fait ✅", - "onboarding_invite_email_get_started_in_minutes": "Commencez en quelques minutes", - "onboarding_invite_email_heading": "Salut ", - "onboarding_invite_email_subject": "{inviterName} a besoin d'aide pour configurer Formbricks. Peux-tu l'aider ?", + "new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :", "password_changed_email_heading": "Mot de passe changé", "password_changed_email_text": "Votre mot de passe a été changé avec succès.", "password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé", - "powered_by_formbricks": "Propulsé par Formbricks", "privacy_policy": "Politique de confidentialité", "reject": "Rejeter", + "render_email_response_value_file_upload_response_link_not_included": "Le lien vers le fichier téléchargé n'est pas inclus pour des raisons de confidentialité des données", "response_finished_email_subject": "Une réponse pour {surveyName} a été complétée ✅", "response_finished_email_subject_with_email": "{personEmail} vient de compléter votre enquête {surveyName} ✅", "schedule_your_meeting": "Planifier votre rendez-vous", @@ -505,14 +493,9 @@ "verification_email_thanks": "Merci de valider votre email !", "verification_email_to_fill_survey": "Pour remplir le questionnaire, veuillez cliquer sur le bouton ci-dessous :", "verification_email_verify_email": "Vérifier l'email", - "verified_link_survey_email_subject": "Votre enquête est prête à être remplie.", - "weekly_summary_create_reminder_notification_body_cal_slot": "Choisissez un créneau de 15 minutes dans le calendrier de notre PDG.", - "weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Ne laissez pas une semaine passer sans en apprendre davantage sur vos utilisateurs :", - "weekly_summary_create_reminder_notification_body_need_help": "Besoin d'aide pour trouver le bon sondage pour votre produit ?", - "weekly_summary_create_reminder_notification_body_reply_email": "ou répondez à cet e-mail :)", - "weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurer une nouvelle enquête", - "weekly_summary_create_reminder_notification_body_text": "Nous aimerions vous envoyer un résumé hebdomadaire, mais actuellement, il n'y a pas d'enquêtes en cours pour {projectName}.", - "weekly_summary_email_subject": "Aperçu des utilisateurs de {projectName} – La semaine dernière par Formbricks" + "verification_new_email_subject": "Vérification du changement d'email", + "verification_security_notice": "Si vous n'avez pas demandé ce changement d'email, veuillez ignorer cet email ou contacter le support immédiatement.", + "verified_link_survey_email_subject": "Votre enquête est prête à être remplie." }, "environments": { "actions": { @@ -525,21 +508,21 @@ "action_with_key_already_exists": "L'action avec la clé '{'key'}' existe déjà", "action_with_name_already_exists": "L'action avec le nom '{'name'}' existe déjà", "add_css_class_or_id": "Ajouter une classe ou un identifiant CSS", + "add_regular_expression_here": "Ajoutez une expression régulière ici", "add_url": "Ajouter une URL", "click": "Cliquez", "contains": "Contient", "create_action": "Créer une action", "css_selector": "Sélecteur CSS", "delete_action_text": "Êtes-vous sûr de vouloir supprimer cette action ? Cela supprime également cette action en tant que déclencheur de toutes vos enquêtes.", - "display_name": "Nom d'affichage", "does_not_contain": "Ne contient pas", "does_not_exactly_match": "Ne correspond pas exactement", "eg_clicked_download": "Par exemple, cliqué sur Télécharger", "eg_download_cta_click_on_home": "Par exemple, cliquez sur le CTA de téléchargement sur la page d'accueil", "eg_install_app": "Par exemple, installer l'application", - "eg_user_clicked_download_button": "Par exemple, l'utilisateur a cliqué sur le bouton de téléchargement.", "ends_with": "Se termine par", "enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Saisissez une URL pour voir si un utilisateur la visitant serait suivi.", + "enter_url": "par exemple https://app.com/dashboard", "exactly_matches": "Correspondance exacte", "exit_intent": "Intention de sortie", "fifty_percent_scroll": "50% Défilement", @@ -548,9 +531,14 @@ "if_a_user_clicks_a_button_with_a_specific_text": "Si un utilisateur clique sur un bouton avec un texte spécifique", "in_your_code_read_more_in_our": "dans votre code. En savoir plus dans notre", "inner_text": "Texte interne", + "invalid_action_type_code": "Type d'action invalide pour action code", + "invalid_action_type_no_code": "Type d'action invalide pour action noCode", "invalid_css_selector": "Sélecteur CSS invalide", + "invalid_match_type": "L'option sélectionnée n'est pas disponible.", + "invalid_regex": "Veuillez utiliser une expression régulière valide.", "limit_the_pages_on_which_this_action_gets_captured": "Limiter les pages sur lesquelles cette action est capturée", "limit_to_specific_pages": "Limiter à des pages spécifiques", + "matches_regex": "Correspond à l'expression régulière", "on_all_pages": "Sur toutes les pages", "page_filter": "Filtre de page", "page_view": "Vue de page", @@ -570,7 +558,9 @@ "user_clicked_download_button": "L'utilisateur a cliqué sur le bouton de téléchargement", "what_did_your_user_do": "Que fait votre utilisateur ?", "what_is_the_user_doing": "Que fait l'utilisateur ?", - "you_can_track_code_action_anywhere_in_your_app_using": "Vous pouvez suivre l'action du code partout dans votre application en utilisant" + "you_can_track_code_action_anywhere_in_your_app_using": "Vous pouvez suivre l'action du code partout dans votre application en utilisant", + "your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.", + "your_survey_would_not_be_shown": "Votre enquête ne serait pas affichée." }, "connect": { "congrats": "Félicitations !", @@ -587,8 +577,8 @@ "contact_deleted_successfully": "Contact supprimé avec succès", "contact_not_found": "Aucun contact trouvé", "contacts_table_refresh": "Rafraîchir les contacts", - "contacts_table_refresh_error": "Une erreur s'est produite lors de la mise à jour des contacts. Veuillez réessayer.", "contacts_table_refresh_success": "Contacts rafraîchis avec succès", + "delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.", "first_name": "Prénom", "last_name": "Nom de famille", "no_responses_found": "Aucune réponse trouvée", @@ -616,33 +606,6 @@ "upload_contacts_modal_preview": "Voici un aperçu de vos données.", "upload_contacts_modal_upload_btn": "Importer des contacts" }, - "experience": { - "all": "Tout", - "all_time": "Tout le temps", - "analysed_feedbacks": "Réponses en texte libre analysées", - "category": "Catégorie", - "category_updated_successfully": "Catégorie mise à jour avec succès !", - "complaint": "Plainte", - "did_you_find_this_insight_helpful": "Avez-vous trouvé cette information utile ?", - "failed_to_update_category": "Échec de la mise à jour de la catégorie", - "feature_request": "Demande", - "good_afternoon": "\uD83C\uDF24️ Bon après-midi", - "good_evening": "\uD83C\uDF19 Bonsoir", - "good_morning": "☀️ Bonjour", - "insights_description": "Toutes les informations générées à partir des réponses de toutes vos enquêtes", - "insights_for_project": "Aperçus pour {projectName}", - "new_responses": "Réponses", - "no_insights_for_this_filter": "Aucune information pour ce filtre", - "no_insights_found": "Aucune information trouvée. Collectez plus de réponses à l'enquête ou activez les insights pour vos enquêtes existantes pour commencer.", - "praise": "Éloge", - "sentiment_score": "Score de sentiment", - "templates_card_description": "Choisissez un modèle ou commencez à partir de zéro", - "templates_card_title": "Mesurez l'expérience de vos clients", - "this_month": "Ce mois-ci", - "this_quarter": "Ce trimestre", - "this_week": "Cette semaine", - "today": "Aujourd'hui" - }, "formbricks_logo": "Logo Formbricks", "integrations": { "activepieces_integration_description": "Connectez instantanément Formbricks avec des applications populaires pour automatiser les tâches sans coder.", @@ -652,6 +615,7 @@ "airtable_integration": "Intégration Airtable", "airtable_integration_description": "Synchronisez les réponses directement avec Airtable.", "airtable_integration_is_not_configured": "L'intégration Airtable n'est pas configurée", + "airtable_logo": "Logo Airtable", "connect_with_airtable": "Se connecter à Airtable", "link_airtable_table": "Lier la table Airtable", "link_new_table": "Lier nouvelle table", @@ -719,7 +683,6 @@ "select_a_database": "Sélectionner la base de données", "select_a_field_to_map": "Sélectionnez un champ à mapper", "select_a_survey_question": "Sélectionnez une question d'enquête", - "sync_responses_with_a_notion_database": "Synchroniser les réponses avec une base de données Notion", "update_connection": "Reconnecter Notion", "update_connection_tooltip": "Reconnectez l'intégration pour inclure les nouvelles bases de données ajoutées. Vos intégrations existantes resteront intactes." }, @@ -741,6 +704,7 @@ "slack_integration": "Intégration Slack", "slack_integration_description": "Envoyez les réponses directement sur Slack.", "slack_integration_is_not_configured": "L'intégration Slack n'est pas configurée dans votre instance de Formbricks.", + "slack_logo": "logo Slack", "slack_reconnect_button": "Reconnecter", "slack_reconnect_button_description": "Remarque : Nous avons récemment modifié notre intégration Slack pour prendre en charge les canaux privés. Veuillez reconnecter votre espace de travail Slack." }, @@ -777,6 +741,7 @@ }, "project": { "api_keys": { + "access_control": "Contrôle d'accès", "add_api_key": "Ajouter une clé API", "api_key": "Clé API", "api_key_copied_to_clipboard": "Clé API copiée dans le presse-papiers", @@ -784,9 +749,12 @@ "api_key_deleted": "Clé API supprimée", "api_key_label": "Étiquette de clé API", "api_key_security_warning": "Pour des raisons de sécurité, la clé API ne sera affichée qu'une seule fois après sa création. Veuillez la copier immédiatement à votre destination.", + "api_key_updated": "Clé API mise à jour", "duplicate_access": "L'accès en double au projet n'est pas autorisé", "no_api_keys_yet": "Vous n'avez pas encore de clés API.", + "no_env_permissions_found": "Aucune autorisation d'environnement trouvée", "organization_access": "Accès à l'organisation", + "organization_access_description": "Sélectionnez les privilèges de lecture ou d'écriture pour les ressources de l'organisation.", "permissions": "Permissions", "project_access": "Accès au projet", "secret": "Secret", @@ -796,6 +764,8 @@ "api_host_description": "Ceci est l'URL de votre backend Formbricks.", "app_connection": "Connexion d'application", "app_connection_description": "Connectez votre application à Formbricks.", + "cache_update_delay_description": "Lorsque vous effectuez des mises à jour sur les sondages, contacts, actions ou autres données, cela peut prendre jusqu'à 5 minutes pour que ces modifications apparaissent dans votre application locale exécutant le SDK Formbricks. Ce délai est dû à une limitation de notre système de mise en cache actuel. Nous retravaillons activement le cache et publierons une correction dans Formbricks 4.0.", + "cache_update_delay_title": "Les modifications seront reflétées après 5 minutes en raison de la mise en cache", "check_out_the_docs": "Consultez la documentation.", "dive_into_the_docs": "Plongez dans la documentation.", "does_your_widget_work": "Votre widget fonctionne-t-il ?", @@ -921,8 +891,7 @@ "tag_already_exists": "Le tag existe déjà", "tag_deleted": "Tag supprimé", "tag_updated": "Étiquette mise à jour", - "tags_merged": "Étiquettes fusionnées", - "unique_constraint_failed_on_the_fields": "Échec de la contrainte unique sur les champs" + "tags_merged": "Étiquettes fusionnées" }, "teams": { "manage_teams": "Gérer les équipes", @@ -970,6 +939,7 @@ "save_your_filters_as_a_segment_to_use_it_in_other_surveys": "Enregistrez vos filtres en tant que segment pour les utiliser dans d'autres enquêtes.", "segment_created_successfully": "Segment créé avec succès !", "segment_deleted_successfully": "Segment supprimé avec succès !", + "segment_id": "ID de segment", "segment_saved_successfully": "Segment enregistré avec succès", "segment_updated_successfully": "Segment mis à jour avec succès !", "segments_help_you_target_users_with_same_characteristics_easily": "Les segments vous aident à cibler facilement les utilisateurs ayant les mêmes caractéristiques.", @@ -991,67 +961,56 @@ "api_keys": { "add_api_key": "Ajouter une clé API", "add_permission": "Ajouter une permission", - "api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks", - "only_organization_owners_and_managers_can_manage_api_keys": "Seuls les propriétaires et les gestionnaires de l'organisation peuvent gérer les clés API" + "api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks" }, "billing": { - "10000_monthly_responses": "10000 Réponses Mensuelles", - "1500_monthly_responses": "1500 Réponses Mensuelles", - "2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels", - "30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels", + "1000_monthly_responses": "1000 Réponses Mensuelles", + "1_project": "1 Projet", + "2000_contacts": "2 000 Contacts", "3_projects": "3 Projets", - "5000_monthly_responses": "5000 Réponses Mensuelles", - "5_projects": "5 Projets", - "7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels", - "advanced_targeting": "Ciblage Avancé", + "5000_monthly_responses": "5,000 Réponses Mensuelles", + "7500_contacts": "7 500 Contacts", "all_integrations": "Toutes les intégrations", - "all_surveying_features": "Tous les outils d'arpentage", "annually": "Annuellement", "api_webhooks": "API et Webhooks", "app_surveys": "Sondages d'application", - "contact_us": "Contactez-nous", + "attribute_based_targeting": "Ciblage basé sur les attributs", "current": "Actuel", "current_plan": "Plan actuel", "current_tier_limit": "Limite de niveau actuel", - "custom_miu_limit": "Limite MIU personnalisé", + "custom": "Personnalisé et Échelle", + "custom_contacts_limit": "Limite de contacts personnalisé", "custom_project_limit": "Limite de projet personnalisé", - "customer_success_manager": "Responsable de la réussite client", + "custom_response_limit": "Limite de réponse personnalisé", "email_embedded_surveys": "Sondages intégrés par e-mail", - "email_support": "Support par e-mail", - "enterprise": "Entreprise", + "email_follow_ups": "Relances par e-mail", "enterprise_description": "Soutien premium et limites personnalisées.", "everybody_has_the_free_plan_by_default": "Tout le monde a le plan gratuit par défaut !", "everything_in_free": "Tout est gratuit", - "everything_in_scale": "Tout à l'échelle", "everything_in_startup": "Tout dans le Startup", "free": "Gratuit", "free_description": "Sondages illimités, membres d'équipe, et plus encore.", "get_2_months_free": "Obtenez 2 mois gratuits", "get_in_touch": "Prenez contact", + "hosted_in_frankfurt": "Hébergé à Francfort", + "ios_android_sdks": "SDK iOS et Android pour les sondages mobiles", "link_surveys": "Sondages par lien (partageables)", "logic_jumps_hidden_fields_recurring_surveys": "Sauts logiques, champs cachés, enquêtes récurrentes, etc.", "manage_card_details": "Gérer les détails de la carte", "manage_subscription": "Gérer l'abonnement", "monthly": "Mensuel", "monthly_identified_users": "Utilisateurs Identifiés Mensuels", - "multi_language_surveys": "Sondages multilingues", "per_month": "par mois", "per_year": "par an", "plan_upgraded_successfully": "Plan mis à jour avec succès", "premium_support_with_slas": "Soutien premium avec SLA", - "priority_support": "Soutien Prioritaire", "remove_branding": "Supprimer la marque", - "say_hi": "Dis bonjour !", - "scale": "Échelle", - "scale_description": "Fonctionnalités avancées pour développer votre entreprise.", "startup": "Startup", "startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.", "switch_plan": "Changer de plan", "switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.", "team_access_roles": "Rôles d'accès d'équipe", - "technical_onboarding": "Intégration technique", "unable_to_upgrade_plan": "Impossible de mettre à niveau le plan", - "unlimited_apps_websites": "Applications et sites Web illimités", "unlimited_miu": "MIU Illimité", "unlimited_projects": "Projets illimités", "unlimited_responses": "Réponses illimitées", @@ -1062,7 +1021,6 @@ "website_surveys": "Sondages de site web" }, "enterprise": { - "ai": "Analyse IA", "audit_logs": "Journaux d'audit", "coming_soon": "À venir bientôt", "contacts_and_segments": "Gestion des contacts et des segments", @@ -1091,6 +1049,7 @@ "create_new_organization": "Créer une nouvelle organisation", "create_new_organization_description": "Créer une nouvelle organisation pour gérer un ensemble différent de projets.", "customize_email_with_a_higher_plan": "Personnalisez l'e-mail avec un plan supérieur", + "delete_member_confirmation": "Les membres supprimés perdront l'accès à tous les projets et enquêtes de votre organisation.", "delete_organization": "Supprimer l'organisation", "delete_organization_description": "Supprimer l'organisation avec tous ses projets, y compris toutes les enquêtes, réponses, personnes, actions et attributs.", "delete_organization_warning": "Avant de procéder à la suppression de cette organisation, veuillez prendre connaissance des conséquences suivantes :", @@ -1100,13 +1059,7 @@ "eliminate_branding_with_whitelabel": "Éliminez la marque Formbricks et activez des options de personnalisation supplémentaires.", "email_customization_preview_email_heading": "Salut {userName}", "email_customization_preview_email_text": "Cette est une prévisualisation d'e-mail pour vous montrer quel logo sera rendu dans les e-mails.", - "enable_formbricks_ai": "Activer Formbricks IA", "error_deleting_organization_please_try_again": "Erreur lors de la suppression de l'organisation. Veuillez réessayer.", - "formbricks_ai": "Formbricks IA", - "formbricks_ai_description": "Obtenez des insights personnalisés à partir de vos réponses au sondage avec Formbricks AI.", - "formbricks_ai_disable_success_message": "Formbricks AI désactivé avec succès.", - "formbricks_ai_enable_success_message": "Formbricks AI activé avec succès.", - "formbricks_ai_privacy_policy_text": "En activant Formbricks AI, vous acceptez les mises à jour", "from_your_organization": "de votre organisation", "invitation_sent_once_more": "Invitation envoyée une fois de plus.", "invite_deleted_successfully": "Invitation supprimée avec succès", @@ -1153,10 +1106,8 @@ "need_slack_or_discord_notifications": "Besoin de notifications Slack ou Discord", "notification_settings_updated": "Paramètres de notification mis à jour", "set_up_an_alert_to_get_an_email_on_new_responses": "Configurez une alerte pour recevoir un e-mail lors de nouvelles réponses.", - "stay_up_to_date_with_a_Weekly_every_Monday": "Restez à jour avec un hebdomadaire chaque lundi.", "use_the_integration": "Utilisez l'intégration", "want_to_loop_in_organization_mates": "Voulez-vous inclure des collègues de l'organisation ?", - "weekly_summary_projects": "Résumé hebdomadaire (Projets)", "you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Vous ne serez plus automatiquement abonné aux enquêtes de cette organisation !", "you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Vous ne recevrez plus d'e-mails concernant les réponses à cette enquête !" }, @@ -1172,6 +1123,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.", @@ -1252,11 +1204,12 @@ "copy_survey_description": "Copier cette enquête dans un autre environnement", "copy_survey_error": "Échec de la copie du sondage", "copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers", + "copy_survey_partially_success": "{success} enquêtes copiées avec succès, {error} échouées.", "copy_survey_success": "Enquête copiée avec succès !", - "delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses ? Cette action ne peut pas être annulée.", + "delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?", "edit": { "1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :", - "2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :", + "2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques:", "add": "Ajouter +", "add_a_delay_or_auto_close_the_survey": "Ajouter un délai ou fermer automatiquement l'enquête", "add_a_four_digit_pin": "Ajoutez un code PIN à quatre chiffres.", @@ -1273,6 +1226,8 @@ "add_description": "Ajouter une description", "add_ending": "Ajouter une fin", "add_ending_below": "Ajouter une fin ci-dessous", + "add_fallback": "Ajouter", + "add_fallback_placeholder": "Ajouter un espace réservé pour montrer si la question est ignorée :", "add_hidden_field_id": "Ajouter un champ caché ID", "add_highlight_border": "Ajouter une bordure de surlignage", "add_highlight_border_description": "Ajoutez une bordure extérieure à votre carte d'enquête.", @@ -1311,8 +1266,6 @@ "automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Libérer automatiquement l'enquête au début de la journée (UTC).", "back_button_label": "Label du bouton \"Retour''", "background_styling": "Style de fond", - "blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloque les enquêtes si une soumission avec l'Identifiant à Usage Unique (suId) existe déjà.", - "blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloque les enquêtes si l'URL de l'enquête n'a pas d'Identifiant d'Utilisation Unique (suId).", "brand_color": "Couleur de marque", "brightness": "Luminosité", "button_label": "Label du bouton", @@ -1326,14 +1279,21 @@ "card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}", "card_background_color": "Couleur de fond de la carte", "card_border_color": "Couleur de la bordure de la carte", - "card_shadow_color": "Couleur de l'ombre de la carte", "card_styling": "Style de carte", "casual": "Décontracté", + "caution_edit_duplicate": "Dupliquer et modifier", + "caution_edit_published_survey": "Modifier un sondage publié ?", + "caution_explanation_intro": "Nous comprenons que vous souhaitiez encore apporter des modifications. Voici ce qui se passe si vous le faites : ", + "caution_explanation_new_responses_separated": "Les réponses avant le changement peuvent ne pas être ou ne faire partie que partiellement du résumé de l'enquête.", + "caution_explanation_only_new_responses_in_summary": "Toutes les données, y compris les réponses passées, restent disponibles en téléchargement sur la page de résumé de l'enquête.", + "caution_explanation_responses_are_safe": "Les réponses anciennes et nouvelles se mélangent, ce qui peut entraîner des résumés de données trompeurs.", + "caution_recommendation": "Cela peut entraîner des incohérences de données dans le résumé du sondage. Nous recommandons de dupliquer le sondage à la place.", "caution_text": "Les changements entraîneront des incohérences.", "centered_modal_overlay_color": "Couleur de superposition modale centrée", "change_anyway": "Changer de toute façon", "change_background": "Changer l'arrière-plan", "change_question_type": "Changer le type de question", + "change_survey_type": "Le changement de type de sondage affecte l'accès existant", "change_the_background_color_of_the_card": "Changez la couleur de fond de la carte.", "change_the_background_color_of_the_input_fields": "Changez la couleur de fond des champs de saisie.", "change_the_background_to_a_color_image_or_animation": "Changez l'arrière-plan en une couleur, une image ou une animation.", @@ -1343,8 +1303,8 @@ "change_the_brand_color_of_the_survey": "Changez la couleur de la marque du sondage.", "change_the_placement_of_this_survey": "Changez le placement de cette enquête.", "change_the_question_color_of_the_survey": "Changez la couleur des questions du sondage.", - "change_the_shadow_color_of_the_card": "Changez la couleur de l'ombre de la carte.", "changes_saved": "Modifications enregistrées.", + "changing_survey_type_will_remove_existing_distribution_channels": "Le changement du type de sondage affectera la façon dont il peut être partagé. Si les répondants ont déjà des liens d'accès pour le type actuel, ils peuvent perdre l'accès après le changement.", "character_limit_toggle_description": "Limitez la longueur des réponses.", "character_limit_toggle_title": "Ajouter des limites de caractères", "checkbox_label": "Étiquette de case à cocher", @@ -1354,10 +1314,11 @@ "close_survey_on_date": "Clôturer l'enquête à la date", "close_survey_on_response_limit": "Fermer l'enquête sur la limite de réponse", "color": "Couleur", + "column_used_in_logic_error": "Cette colonne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.", "columns": "Colonnes", "company": "Société", "company_logo": "Logo de l'entreprise", - "completed_responses": "réponses complètes.", + "completed_responses": "des réponses partielles ou complètes.", "concat": "Concat +", "conditional_logic": "Logique conditionnelle", "confirm_default_language": "Confirmer la langue par défaut", @@ -1391,8 +1352,9 @@ "does_not_start_with": "Ne commence pas par", "edit_recall": "Modifier le rappel", "edit_translations": "Modifier les traductions {lang}", - "enable_encryption_of_single_use_id_suid_in_survey_url": "Activer le chiffrement de l'identifiant à usage unique (suId) dans l'URL de l'enquête.", "enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.", + "enable_recaptcha_to_protect_your_survey_from_spam": "La protection contre le spam utilise reCAPTCHA v3 pour filtrer les réponses indésirables.", + "enable_spam_protection": "Protection contre le spam", "end_screen_card": "Carte de fin d'écran", "ending_card": "Carte de fin", "ending_card_used_in_logic": "Cette carte de fin est utilisée dans la logique de la question '{'questionIndex'}'.", @@ -1403,6 +1365,7 @@ "error_saving_changes": "Erreur lors de l'enregistrement des modifications", "even_after_they_submitted_a_response_e_g_feedback_box": "Même après avoir soumis une réponse (par exemple, la boîte de feedback)", "everyone": "Tout le monde", + "fallback_for": "Solution de repli pour ", "fallback_missing": "Fallback manquant", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.", "field_name_eg_score_price": "Nom du champ par exemple, score, prix", @@ -1420,6 +1383,8 @@ "follow_ups_item_issue_detected_tag": "Problème détecté", "follow_ups_item_response_tag": "Une réponse quelconque", "follow_ups_item_send_email_tag": "Envoyer un e-mail", + "follow_ups_modal_action_attach_response_data_description": "Ajouter les données de la réponse à l'enquête au suivi", + "follow_ups_modal_action_attach_response_data_label": "Joindre les données de réponse", "follow_ups_modal_action_body_label": "Corps", "follow_ups_modal_action_body_placeholder": "Corps de l'email", "follow_ups_modal_action_email_content": "Contenu de l'email", @@ -1450,9 +1415,6 @@ "follow_ups_new": "Nouveau suivi", "follow_ups_upgrade_button_text": "Passez à la version supérieure pour activer les relances", "form_styling": "Style de formulaire", - "formbricks_ai_description": "Décrivez votre enquête et laissez l'IA de Formbricks créer l'enquête pour vous.", - "formbricks_ai_generate": "Générer", - "formbricks_ai_prompt_placeholder": "Saisissez les informations de l'enquête (par exemple, les sujets clés à aborder)", "formbricks_sdk_is_not_connected": "Le SDK Formbricks n'est pas connecté", "four_points": "4 points", "heading": "En-tête", @@ -1465,7 +1427,6 @@ "hide_the_logo_in_this_specific_survey": "Cacher le logo dans cette enquête spécifique", "hostname": "Nom d'hôte", "how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}", - "how_it_works": "Comment ça fonctionne", "if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît", "if_you_really_want_that_answer_ask_until_you_get_it": "Si tu veux vraiment cette réponse, demande jusqu'à ce que tu l'obtiennes.", "ignore_waiting_time_between_surveys": "Ignorer le temps d'attente entre les enquêtes", @@ -1481,10 +1442,13 @@ "invalid_youtube_url": "URL YouTube invalide", "is_accepted": "C'est accepté", "is_after": "est après", + "is_any_of": "Est l'un des", "is_before": "Est avant", "is_booked": "Est réservé", "is_clicked": "Est cliqué", "is_completely_submitted": "Est complètement soumis", + "is_empty": "Est vide", + "is_not_empty": "N'est pas vide", "is_not_set": "N'est pas défini", "is_partially_submitted": "Est partiellement soumis", "is_set": "Est défini", @@ -1500,7 +1464,6 @@ "limit_the_maximum_file_size": "Limiter la taille maximale du fichier", "limit_upload_file_size_to": "Limiter la taille des fichiers téléchargés à", "link_survey_description": "Partagez un lien vers une page d'enquête ou intégrez-le dans une page web ou un e-mail.", - "link_used_message": "Lien utilisé", "load_segment": "Segment de chargement", "logic_error_warning": "Changer causera des erreurs logiques", "logic_error_warning_text": "Changer le type de question supprimera les conditions logiques de cette question.", @@ -1516,6 +1479,7 @@ "no_hidden_fields_yet_add_first_one_below": "Aucun champ caché pour le moment. Ajoutez le premier ci-dessous.", "no_images_found_for": "Aucune image trouvée pour ''{query}\"", "no_languages_found_add_first_one_to_get_started": "Aucune langue trouvée. Ajoutez la première pour commencer.", + "no_option_found": "Aucune option trouvée", "no_variables_yet_add_first_one_below": "Aucune variable pour le moment. Ajoutez la première ci-dessous.", "number": "Numéro", "once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Une fois défini, la langue par défaut de cette enquête ne peut être changée qu'en désactivant l'option multilingue et en supprimant toutes les traductions.", @@ -1567,6 +1531,7 @@ "response_limits_redirections_and_more": "Limites de réponse, redirections et plus.", "response_options": "Options de réponse", "roundness": "Rondité", + "row_used_in_logic_error": "Cette ligne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.", "rows": "Lignes", "save_and_close": "Enregistrer et fermer", "scale": "Échelle", @@ -1590,10 +1555,12 @@ "show_survey_to_users": "Afficher l'enquête à % des utilisateurs", "show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés", "simple": "Simple", - "single_use_survey_links": "Liens d'enquête à usage unique", - "single_use_survey_links_description": "Autoriser uniquement 1 réponse par lien d'enquête.", + "six_points": "6 points", "skip_button_label": "Étiquette du bouton Ignorer", "smiley": "Sourire", + "spam_protection_note": "La protection contre le spam ne fonctionne pas pour les enquêtes affichées avec les SDK iOS, React Native et Android. Cela cassera l'enquête.", + "spam_protection_threshold_description": "Définir une valeur entre 0 et 1, les réponses en dessous de cette valeur seront rejetées.", + "spam_protection_threshold_heading": "Seuil de réponse", "star": "Étoile", "starts_with": "Commence par", "state": "État", @@ -1604,8 +1571,6 @@ "subheading": "Sous-titre", "subtract": "Soustraire -", "suggest_colors": "Suggérer des couleurs", - "survey_already_answered_heading": "L'enquête a déjà été répondue.", - "survey_already_answered_subheading": "Vous ne pouvez utiliser ce lien qu'une seule fois.", "survey_completed_heading": "Enquête terminée", "survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée", "survey_display_settings": "Paramètres d'affichage de l'enquête", @@ -1636,7 +1601,6 @@ "upload": "Télécharger", "upload_at_least_2_images": "Téléchargez au moins 2 images", "upper_label": "Étiquette supérieure", - "url_encryption": "Chiffrement d'URL", "url_filters": "Filtres d'URL", "url_not_supported": "URL non supportée", "use_with_caution": "À utiliser avec précaution", @@ -1649,7 +1613,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Attendez quelques secondes après le déclencheur avant de montrer l'enquête.", "waiting_period": "période d'attente", "welcome_message": "Message de bienvenue", - "when": "Quand", "when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Lorsque les conditions correspondent, le temps d'attente sera ignoré et l'enquête sera affichée.", "without_a_filter_all_of_your_users_can_be_surveyed": "Sans filtre, tous vos utilisateurs peuvent être sondés.", "you_have_not_created_a_segment_yet": "Tu n'as pas encore créé de segment.", @@ -1660,9 +1623,11 @@ "zip": "Zip" }, "error_deleting_survey": "Une erreur est survenue lors de la suppression de l'enquête.", - "failed_to_copy_link_to_results": "Échec de la copie du lien vers les résultats", - "failed_to_copy_url": "Échec de la copie de l'URL : pas dans un environnement de navigateur.", - "new_single_use_link_generated": "Nouveau lien à usage unique généré", + "filter": { + "complete_and_partial_responses": "Réponses complètes et partielles", + "complete_responses": "Réponses complètes", + "partial_responses": "Réponses partielles" + }, "new_survey": "Nouveau Sondage", "no_surveys_created_yet": "Aucun sondage créé pour le moment", "open_options": "Ouvrir les options", @@ -1681,9 +1646,11 @@ "company": "Société", "completed": "Terminé ✅", "country": "Pays", + "delete_response_confirmation": "Cela supprimera la réponse au sondage, y compris toutes les réponses, les étiquettes, les documents joints et les métadonnées de réponse.", "device": "Dispositif", "device_info": "Informations sur l'appareil", "email": "Email", + "error_downloading_responses": "Une erreur s'est produite lors du téléchargement des réponses", "first_name": "Prénom", "how_to_identify_users": "Comment identifier les utilisateurs", "last_name": "Nom de famille", @@ -1702,8 +1669,91 @@ "this_response_is_in_progress": "Cette réponse est en cours.", "zip_post_code": "Code postal" }, - "results_unpublished_successfully": "Résultats publiés avec succès.", "search_by_survey_name": "Recherche par nom d'enquête", + "share": { + "anonymous_links": { + "custom_single_use_id_description": "Si vous n’encryptez pas les identifiants à usage unique, toute valeur pour « suid=... » fonctionne pour une seule réponse", + "custom_single_use_id_title": "Vous pouvez définir n'importe quelle valeur comme identifiant à usage unique dans l'URL.", + "custom_start_point": "Point de départ personnalisé", + "data_prefilling": "Préremplissage des données", + "description": "Les réponses provenant de ces liens seront anonymes", + "disable_multi_use_link_modal_button": "Désactiver le lien multi-usage", + "disable_multi_use_link_modal_description": "La désactivation du lien multi-usage empêchera quiconque de soumettre une réponse via le lien.", + "disable_multi_use_link_modal_description_subtext": "Cela cassera également toutes les intégrations actives sur les sites Web, les emails, les réseaux sociaux et les codes QR qui utilisent ce lien multi-usage.", + "disable_multi_use_link_modal_title": "Êtes-vous sûr ? Cela peut casser les intégrations actives.", + "disable_single_use_link_modal_button": "Désactiver les liens à usage unique", + "disable_single_use_link_modal_description": "Si vous avez partagé des liens à usage unique, les participants ne pourront plus répondre au sondage.", + "generate_and_download_links": "Générer et télécharger les liens", + "generate_links_error": "Les liens à usage unique n'ont pas pu être générés. Veuillez travailler directement avec l'API", + "multi_use_link": "Lien multi-usage", + "multi_use_link_description": "Recueillir plusieurs réponses de répondants anonymes avec un seul lien.", + "multi_use_powers_other_channels_description": "Si vous le désactivez, ces autres canaux de distribution seront également désactivés.", + "multi_use_powers_other_channels_title": "Ce lien alimente les intégrations du site Web, les intégrations de courrier électronique, le partage sur les réseaux sociaux et les codes QR.", + "nav_title": "Liens anonymes", + "number_of_links_label": "Nombre de liens (1 - 5,000)", + "single_use_link": "Liens à usage unique", + "single_use_link_description": "Autoriser uniquement une réponse par lien d'enquête", + "single_use_links": "Liens à usage unique", + "source_tracking": "Suivi des sources", + "url_encryption_description": "Désactiver seulement si vous devez définir un identifiant unique personnalisé", + "url_encryption_label": "Cryptage de l'identifiant à usage unique dans l'URL" + }, + "dynamic_popup": { + "alert_button": "Modifier enquête", + "alert_description": "Ce sondage est actuellement configuré comme un sondage de lien, qui ne prend pas en charge les pop-ups dynamiques. Vous pouvez le modifier dans l'onglet des paramètres de l'éditeur de sondage.", + "alert_title": "Changer le type d'enquête en application intégrée", + "attribute_based_targeting": "Ciblage basé sur des attributs", + "code_no_code_triggers": "Déclencheurs avec et sans code", + "description": "Les enquêtes Formbricks peuvent être intégrées sous forme de pop-up, en fonction de l'interaction de l'utilisateur.", + "nav_title": "Dynamique (Pop-up)", + "recontact_options": "Options de recontact" + }, + "embed_on_website": { + "description": "Les enquêtes Formbricks peuvent être intégrées comme élément statique.", + "embed_code_copied_to_clipboard": "Code d'intégration copié dans le presse-papiers !", + "embed_mode": "Mode d'intégration", + "embed_mode_description": "Intégrez votre enquête avec un design minimaliste, en supprimant les marges et l'arrière-plan.", + "nav_title": "Incorporer sur le site web" + }, + "personal_links": { + "create_and_manage_segments": "Créez et gérez vos Segments sous Contacts > Segments", + "description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact.", + "expiry_date_description": "Une fois le lien expiré, le destinataire ne peut plus répondre au sondage.", + "expiry_date_optional": "Date d'expiration (facultatif)", + "generate_and_download_links": "Générer et télécharger les liens", + "generating_links": "Génération de liens", + "generating_links_toast": "Génération des liens, le téléchargement commencera bientôt…", + "links_generated_success_toast": "Liens générés avec succès, votre téléchargement commencera bientôt.", + "nav_title": "Liens personnels", + "no_segments_available": "Aucun segment disponible", + "select_segment": "Sélectionner le segment", + "upgrade_prompt_description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact.", + "upgrade_prompt_title": "Utilisez des liens personnels avec un plan supérieur", + "work_with_segments": "Les liens personnels fonctionnent avec les segments." + }, + "send_email": { + "copy_embed_code": "Copier le code d'intégration", + "description": "Intégrez votre sondage dans un email pour obtenir des réponses de votre audience.", + "email_preview_tab": "Aperçu de l'email", + "email_sent": "Email envoyé !", + "email_subject_label": "Sujet", + "email_to_label": "à", + "embed_code_copied_to_clipboard": "Code d'intégration copié dans le presse-papiers !", + "embed_code_copied_to_clipboard_failed": "Échec de la copie, veuillez réessayer", + "embed_code_tab": "Code d'intégration", + "formbricks_email_survey_preview": "Aperçu de l'enquête par e-mail Formbricks", + "nav_title": "Email intégré", + "send_preview": "Envoyer un aperçu", + "send_preview_email": "Envoyer un e-mail d'aperçu" + }, + "share_view_title": "Partager par", + "social_media": { + "description": "Obtenez des réponses de vos contacts sur divers réseaux sociaux.", + "source_tracking_enabled": "Suivi des sources activé", + "source_tracking_enabled_alert_description": "En partageant depuis cette boîte de dialogue, le réseau social sera ajouté au lien du sondage afin que vous sachiez quelles réponses proviennent de chaque réseau.", + "title": "Médias sociaux" + } + }, "summary": { "added_filter_for_responses_where_answer_to_question": "Filtre ajouté pour les réponses où la réponse à la question '{'questionIdx'}' est '{'filterComboBoxValue'}' - '{'filterValue'}' ", "added_filter_for_responses_where_answer_to_question_is_skipped": "Filtre ajouté pour les réponses où la réponse à la question '{'questionIdx'}' est ignorée", @@ -1717,52 +1767,50 @@ "configure_alerts": "Configurer les alertes", "congrats": "Félicitations ! Votre enquête est en ligne.", "connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.", - "copy_link_to_public_results": "Copier le lien vers les résultats publics", - "create_single_use_links": "Créer des liens à usage unique", - "create_single_use_links_description": "Acceptez uniquement une soumission par lien. Voici comment.", - "current_selection_csv": "Sélection actuelle (CSV)", - "current_selection_excel": "Sélection actuelle (Excel)", "custom_range": "Plage personnalisée...", - "data_prefilling": "Préremplissage des données", - "data_prefilling_description": "Vous souhaitez préremplir certains champs dans l'enquête ? Voici comment faire.", - "define_when_and_where_the_survey_should_pop_up": "Définissez quand et où le sondage doit apparaître.", + "delete_all_existing_responses_and_displays": "Supprimer toutes les réponses existantes et les affichages", + "download_qr_code": "Télécharger code QR", "drop_offs": "Dépôts", "drop_offs_tooltip": "Nombre de fois que l'enquête a été commencée mais non terminée.", - "dynamic_popup": "Dynamique (Pop-up)", - "email_sent": "Email envoyé !", - "embed_code_copied_to_clipboard": "Code d'intégration copié dans le presse-papiers !", - "embed_in_an_email": "Inclure dans un e-mail", - "embed_in_app": "Intégrer dans l'application", - "embed_mode": "Mode d'intégration", - "embed_mode_description": "Intégrez votre enquête avec un design minimaliste, en supprimant les marges et l'arrière-plan.", - "embed_on_website": "Incorporer sur le site web", - "embed_pop_up_survey_title": "Comment intégrer une enquête pop-up sur votre site web", - "embed_survey": "Intégrer l'enquête", - "enable_ai_insights_banner_button": "Activer les insights", - "enable_ai_insights_banner_description": "Vous pouvez activer la nouvelle fonctionnalité d'aperçus pour l'enquête afin d'obtenir des aperçus basés sur l'IA pour vos réponses en texte libre.", - "enable_ai_insights_banner_success": "Génération d'analyses pour cette enquête. Veuillez revenir dans quelques minutes.", - "enable_ai_insights_banner_title": "Prêt à tester les insights de l'IA ?", - "enable_ai_insights_banner_tooltip": "Veuillez nous contacter à hola@formbricks.com pour générer des insights pour cette enquête.", "failed_to_copy_link": "Échec de la copie du lien", "filter_added_successfully": "Filtre ajouté avec succès", "filter_updated_successfully": "Filtre mis à jour avec succès", - "formbricks_email_survey_preview": "Aperçu de l'enquête par e-mail Formbricks", + "filtered_responses_csv": "Réponses filtrées (CSV)", + "filtered_responses_excel": "Réponses filtrées (Excel)", "go_to_setup_checklist": "Allez à la liste de contrôle de configuration \uD83D\uDC49", - "hide_embed_code": "Cacher le code d'intégration", - "how_to_create_a_panel": "Comment créer un panneau", - "how_to_create_a_panel_step_1": "Étape 1 : Créez un compte avec Prolific", - "how_to_create_a_panel_step_1_description": "Créez un compte avec Prolific et vérifiez votre adresse e-mail.", - "how_to_create_a_panel_step_2": "Étape 2 : Créer une étude", - "how_to_create_a_panel_step_2_description": "Chez Prolific, vous créez une nouvelle étude où vous pouvez choisir votre audience préférée en fonction de centaines de caractéristiques.", - "how_to_create_a_panel_step_3": "Étape 3 : Connectez votre enquête", - "how_to_create_a_panel_step_3_description": "Configurez des champs cachés dans votre enquête Formbricks pour suivre quel participant a fourni quelle réponse.", - "how_to_create_a_panel_step_4": "Étape 4 : Lancez votre étude", - "how_to_create_a_panel_step_4_description": "Une fois que tout est configuré, vous pouvez lancer votre étude. Dans quelques heures, vous recevrez les premières réponses.", "impressions": "Impressions", "impressions_tooltip": "Nombre de fois que l'enquête a été consultée.", + "in_app": { + "connection_description": "Le sondage sera affiché aux utilisateurs de votre site web, qui correspondent aux critères listés ci-dessous", + "connection_title": "Le SDK Formbricks est connecté", + "description": "Les enquêtes Formbricks peuvent être intégrées sous forme de pop-up, en fonction de l'interaction de l'utilisateur.", + "display_criteria": "Critères d'affichage", + "display_criteria.audience_description": "Public cible", + "display_criteria.code_trigger": "Code Action", + "display_criteria.everyone": "Tout le monde", + "display_criteria.no_code_trigger": "Pas de code", + "display_criteria.overwritten": "Réécrit", + "display_criteria.randomizer": "{percentage}% Randomiseur", + "display_criteria.randomizer_description": "Seulement {percentage}% des personnes qui réalisent l'action pourraient être sondées.", + "display_criteria.recontact_description": "Options de recontact", + "display_criteria.targeted": "Ciblé", + "display_criteria.time_based_always": "Afficher toujours l'enquête", + "display_criteria.time_based_day": "Jour", + "display_criteria.time_based_days": "Jours", + "display_criteria.time_based_description": "Temps d'attente global", + "display_criteria.trigger_description": "Déclencheur d'enquête", + "documentation_title": "Distribuer des sondages d'interception sur toutes les plateformes", + "html_embed": "Code HTML intégré dans ", + "ios_sdk": "SDK iOS pour les applications Apple", + "javascript_sdk": "SDK JavaScript", + "kotlin_sdk": "Kotlin SDK pour applications Android", + "no_connection_description": "Connectez votre site web ou votre application à Formbricks pour publier des sondages interceptés.", + "no_connection_title": "Vous n'êtes pas encore branché !", + "react_native_sdk": "SDK React Native pour les applications RN", + "title": "Paramètres de sondage par interception" + }, "includes_all": "Comprend tous", "includes_either": "Comprend soit", - "insights_disabled": "Insights désactivés", "install_widget": "Installer le widget Formbricks", "is_equal_to": "Est égal à", "is_less_than": "est inférieur à", @@ -1772,60 +1820,34 @@ "last_month": "Le mois dernier", "last_quarter": "dernier trimestre", "last_year": "l'année dernière", - "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", - "only_completed": "Uniquement terminé", + "no_responses_found": "Aucune réponse trouvée", "other_values_found": "D'autres valeurs trouvées", "overall": "Globalement", - "publish_to_web": "Publier sur le web", - "publish_to_web_warning": "Vous êtes sur le point de rendre ces résultats d'enquête publics.", - "publish_to_web_warning_description": "Les résultats de votre enquête seront publics. Toute personne en dehors de votre organisation pourra y accéder si elle a le lien.", - "quickstart_mobile_apps": "Démarrage rapide : Applications mobiles", - "quickstart_mobile_apps_description": "Pour commencer avec les enquêtes dans les applications mobiles, veuillez suivre le guide de démarrage rapide :", - "quickstart_web_apps": "Démarrage rapide : Applications web", - "quickstart_web_apps_description": "Veuillez suivre le guide de démarrage rapide pour commencer :", - "results_are_public": "Les résultats sont publics.", - "send_preview": "Envoyer un aperçu", - "send_to_panel": "Envoyer au panneau", - "setup_instructions": "Instructions d'installation", + "qr_code": "Code QR", + "qr_code_description": "Les réponses collectées via le code QR sont anonymes.", + "qr_code_download_failed": "Échec du téléchargement du code QR", + "qr_code_download_with_start_soon": "Le téléchargement du code QR débutera bientôt", + "qr_code_generation_failed": "\"Un problème est survenu lors du chargement du code QR du sondage. Veuillez réessayer.\"", + "reset_survey": "Réinitialiser l'enquête", + "reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.", + "selected_responses_csv": "Réponses sélectionnées (CSV)", + "selected_responses_excel": "Réponses sélectionnées (Excel)", "setup_integrations": "Configurer les intégrations", - "share_results": "Partager les résultats", - "share_the_link": "Partager le lien", - "share_the_link_to_get_responses": "Partagez le lien pour obtenir des réponses", + "share_survey": "Partager l'enquête", "show_all_responses_that_match": "Afficher toutes les réponses correspondantes", "show_all_responses_where": "Afficher toutes les réponses où...", - "single_use_links": "Liens à usage unique", - "source_tracking": "Suivi des sources", - "source_tracking_description": "Exécutez un suivi des sources conforme au RGPD et au CCPA sans outils supplémentaires.", "starts": "Commence", "starts_tooltip": "Nombre de fois que l'enquête a été commencée.", - "static_iframe": "Statique (iframe)", - "survey_results_are_public": "Les résultats de votre enquête sont publics !", - "survey_results_are_shared_with_anyone_who_has_the_link": "Les résultats de votre enquête sont partagés avec quiconque possède le lien. Les résultats ne seront pas indexés par les moteurs de recherche.", + "survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.", "this_month": "Ce mois-ci", "this_quarter": "Ce trimestre", "this_year": "Cette année", "time_to_complete": "Temps à compléter", - "to_connect_your_website_with_formbricks": "connecter votre site web à Formbricks", "ttc_tooltip": "Temps moyen pour compléter l'enquête.", "unknown_question_type": "Type de question inconnu", - "unpublish_from_web": "Désactiver la publication sur le web", - "unsupported_video_tag_warning": "Votre navigateur ne prend pas en charge la balise vidéo.", - "view_embed_code": "Voir le code d'intégration", - "view_embed_code_for_email": "Voir le code d'intégration pour l'email", - "view_site": "Voir le site", + "use_personal_links": "Utilisez des liens personnels", "waiting_for_response": "En attente d'une réponse \uD83E\uDDD8‍♂️", - "web_app": "application web", - "what_is_a_panel": "Qu'est-ce qu'un panneau ?", - "what_is_a_panel_answer": "Un panel est un groupe de participants sélectionnés en fonction de caractéristiques telles que l'âge, la profession, le sexe, etc.", - "what_is_prolific": "Qu'est-ce que Prolific ?", - "what_is_prolific_answer": "Nous nous associons à Prolific pour vous donner accès à un panel de plus de 200 000 participants vérifiés.", "whats_next": "Qu'est-ce qui vient ensuite ?", - "when_do_i_need_it": "Quand en ai-je besoin ?", - "when_do_i_need_it_answer": "Si vous n'avez pas accès à suffisamment de personnes correspondant à votre public cible, il est logique de payer pour accéder à un panel.", - "you_can_do_a_lot_more_with_links_surveys": "Vous pouvez faire beaucoup plus avec des sondages par lien \uD83D\uDCA1", "your_survey_is_public": "Votre enquête est publique.", "youre_not_plugged_in_yet": "Vous n'êtes pas encore branché !" }, @@ -1954,11 +1976,6 @@ "this_user_has_all_the_power": "Cet utilisateur a tout le pouvoir." } }, - "share": { - "back_to_home": "Retour à l'accueil", - "page_not_found": "Page non trouvée", - "page_not_found_description": "Désolé, nous n'avons pas pu trouver l'ID de partage des réponses que vous recherchez." - }, "templates": { "address": "Adresse", "address_description": "Demander une adresse postale", @@ -1969,7 +1986,6 @@ "alignment_and_engagement_survey_question_1_upper_label": "Compréhension complète", "alignment_and_engagement_survey_question_2_headline": "Je sens que mes valeurs s'alignent avec la mission et la culture de l'entreprise.", "alignment_and_engagement_survey_question_2_lower_label": "Non aligné", - "alignment_and_engagement_survey_question_2_upper_label": "Complètement aligné", "alignment_and_engagement_survey_question_3_headline": "Je collabore efficacement avec mon équipe pour atteindre nos objectifs.", "alignment_and_engagement_survey_question_3_lower_label": "Mauvaise collaboration", "alignment_and_engagement_survey_question_3_upper_label": "Excellente collaboration", @@ -1979,7 +1995,6 @@ "book_interview": "Réserver un entretien", "build_product_roadmap_description": "Identifiez la chose UNIQUE que vos utilisateurs désirent le plus et construisez-la.", "build_product_roadmap_name": "Élaborer la feuille de route du produit", - "build_product_roadmap_name_with_project_name": "Entrée de feuille de route $[projectName]", "build_product_roadmap_question_1_headline": "Dans quelle mesure êtes-vous satisfait des fonctionnalités et de l'ergonomie de $[projectName] ?", "build_product_roadmap_question_1_lower_label": "Pas du tout satisfait", "build_product_roadmap_question_1_upper_label": "Extrêmement satisfait", @@ -2162,7 +2177,6 @@ "csat_question_7_choice_3": "Quelque peu réactif", "csat_question_7_choice_4": "Pas si réactif", "csat_question_7_choice_5": "Pas du tout réactif", - "csat_question_7_choice_6": "Non applicable", "csat_question_7_headline": "Dans quelle mesure avons-nous été réactifs à vos questions concernant nos services ?", "csat_question_7_subheader": "Veuillez en sélectionner un :", "csat_question_8_choice_1": "Ceci est mon premier achat", @@ -2170,7 +2184,6 @@ "csat_question_8_choice_3": "Six mois à un an", "csat_question_8_choice_4": "1 - 2 ans", "csat_question_8_choice_5": "3 ans ou plus", - "csat_question_8_choice_6": "Je n'ai pas encore effectué d'achat.", "csat_question_8_headline": "Depuis combien de temps êtes-vous client de $[projectName] ?", "csat_question_8_subheader": "Veuillez en sélectionner un :", "csat_question_9_choice_1": "Extrêmement probable", @@ -2385,7 +2398,6 @@ "identify_sign_up_barriers_question_9_dismiss_button_label": "Passer pour l'instant", "identify_sign_up_barriers_question_9_headline": "Merci ! Voici votre code : SIGNUPNOW10", "identify_sign_up_barriers_question_9_html": "

Merci beaucoup d'avoir pris le temps de partager vos retours \uD83D\uDE4F

", - "identify_sign_up_barriers_with_project_name": "Barrières d'inscription $[projectName]", "identify_upsell_opportunities_description": "Découvrez combien de temps votre produit fait gagner à vos utilisateurs. Utilisez-le pour vendre davantage.", "identify_upsell_opportunities_name": "Identifier les opportunités de vente additionnelle", "identify_upsell_opportunities_question_1_choice_1": "Moins d'une heure", @@ -2597,7 +2609,7 @@ "preview_survey_question_2_back_button_label": "Retour", "preview_survey_question_2_choice_1_label": "Oui, tiens-moi au courant.", "preview_survey_question_2_choice_2_label": "Non, merci !", - "preview_survey_question_2_headline": "Tu veux rester dans la boucle ?", + "preview_survey_question_2_headline": "Vous voulez rester informé ?", "preview_survey_welcome_card_headline": "Bienvenue !", "preview_survey_welcome_card_html": "Merci pour vos retours - allons-y !", "prioritize_features_description": "Identifiez les fonctionnalités dont vos utilisateurs ont le plus et le moins besoin.", @@ -2750,7 +2762,6 @@ "site_abandonment_survey_question_6_choice_3": "Plus de variété de produits", "site_abandonment_survey_question_6_choice_4": "Conception de site améliorée", "site_abandonment_survey_question_6_choice_5": "Plus d'avis clients", - "site_abandonment_survey_question_6_choice_6": "Autre", "site_abandonment_survey_question_6_headline": "Quelles améliorations vous inciteraient à rester plus longtemps sur notre site ?", "site_abandonment_survey_question_6_subheader": "Veuillez sélectionner tout ce qui s'applique :", "site_abandonment_survey_question_7_headline": "Souhaitez-vous recevoir des mises à jour sur les nouveaux produits et les promotions ?", @@ -2781,6 +2792,8 @@ "star_rating_survey_question_3_placeholder": "Tapez votre réponse ici...", "star_rating_survey_question_3_subheader": "Aidez-nous à améliorer votre expérience.", "statement_call_to_action": "Déclaration (Appel à l'action)", + "strongly_agree": "Tout à fait d'accord", + "strongly_disagree": "Fortement en désaccord", "supportive_work_culture_survey_description": "Évaluer les perceptions des employés concernant le soutien des dirigeants, la communication et l'environnement de travail global.", "supportive_work_culture_survey_name": "Culture de travail bienveillante", "supportive_work_culture_survey_question_1_headline": "Mon manager me fournit le soutien dont j'ai besoin pour accomplir mon travail.", @@ -2836,6 +2849,18 @@ "understand_purchase_intention_question_2_headline": "Compris. Quelle est votre raison principale de visite aujourd'hui ?", "understand_purchase_intention_question_2_placeholder": "Entrez votre réponse ici...", "understand_purchase_intention_question_3_headline": "Qu'est-ce qui vous empêche de faire un achat aujourd'hui, s'il y a quelque chose ?", - "understand_purchase_intention_question_3_placeholder": "Entrez votre réponse ici..." + "understand_purchase_intention_question_3_placeholder": "Entrez votre réponse ici...", + "usability_question_10_headline": "J'ai dû beaucoup apprendre avant de pouvoir utiliser correctement le système.", + "usability_question_1_headline": "Je pourrais probablement utiliser ce système souvent.", + "usability_question_2_headline": "Le système semblait plus compliqué qu'il ne devait l'être.", + "usability_question_3_headline": "Le système était facile à comprendre.", + "usability_question_4_headline": "Je pense que j'aurais besoin de l'aide d'un expert en technologie pour utiliser ce système.", + "usability_question_5_headline": "Tout dans le système semblait bien fonctionner ensemble.", + "usability_question_6_headline": "Le système semblait incohérent dans la façon dont les choses fonctionnaient.", + "usability_question_7_headline": "Je pense que la plupart des gens pourraient apprendre à utiliser ce système rapidement.", + "usability_question_8_headline": "Utiliser le système semblait être une corvée.", + "usability_question_9_headline": "Je me suis senti confiant en utilisant le système.", + "usability_rating_description": "Mesurez la convivialité perçue en demandant aux utilisateurs d'évaluer leur expérience avec votre produit via un sondage standardisé de 10 questions.", + "usability_score_name": "Score d'Utilisabilité du Système (SUS)" } } diff --git a/packages/lib/messages/pt-BR.json b/apps/web/locales/pt-BR.json similarity index 90% rename from packages/lib/messages/pt-BR.json rename to apps/web/locales/pt-BR.json index 207961d70c50..7c5d5b3db86d 100644 --- a/packages/lib/messages/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -1,12 +1,23 @@ { "auth": { - "continue_with_azure": "Continuar com Azure", + "continue_with_azure": "Continuar com Microsoft", "continue_with_email": "Continuar com o Email", "continue_with_github": "Continuar com o GitHub", "continue_with_google": "Continuar com o Google", "continue_with_oidc": "Continuar com {oidcDisplayName}", "continue_with_openid": "Continuar com OpenID", "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_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" + }, "forgot-password": { "back_to_login": "Voltar para o login", "email-sent": { @@ -23,7 +34,8 @@ "text": "Agora você pode fazer login com sua nova senha" } }, - "reset_password": "Redefinir senha" + "reset_password": "Redefinir senha", + "reset_password_description": "Você será desconectado para redefinir sua senha." }, "invite": { "create_account": "Cria uma conta", @@ -68,7 +80,7 @@ }, "signup_without_verification_success": { "user_successfully_created": "Usuário criado com sucesso", - "user_successfully_created_description": "Seu novo usuário foi criado com sucesso. Por favor, clique no botão abaixo e faça login na sua conta." + "user_successfully_created_info": "Verificamos se há uma conta associada a {email}. Se não existia, criamos uma para você. Se uma conta já existia, nenhuma alteração foi feita. Por favor, faça login abaixo para continuar." }, "testimonial_1": "Mediamos a clareza dos nossos documentos e aprendemos com a rotatividade tudo em uma única plataforma. Ótimo produto, equipe muito atenciosa!", "testimonial_all_features_included": "Todas as funcionalidades incluídas", @@ -78,12 +90,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": "Email de verificação enviado com sucesso. Por favor, verifique sua caixa de entrada.", - "we_sent_an_email_to": "Enviamos um email para {email}", + "verification_email_resent_successfully": "E-mail de verificação enviado! Por favor, verifique sua caixa de entrada.", + "verification_email_successfully_sent_info": "Se houver uma conta associada a {email}, enviamos um link de verificação para esse endereço. Por favor, verifique sua caixa de entrada para completar o cadastro.", "you_didnt_receive_an_email_or_your_link_expired": "Você não recebeu um e-mail ou seu link expirou?" }, "verify": { @@ -96,6 +108,10 @@ "thanks_for_upgrading": "Valeu demais por atualizar sua assinatura do Formbricks.", "upgrade_successful": "Atualização bem-sucedida" }, + "c": { + "link_expired": "Seu link está expirado.", + "link_expired_description": "O link que você usou não é mais válido." + }, "common": { "accepted": "Aceito", "account": "conta", @@ -108,6 +124,7 @@ "add_action": "Adicionar ação", "add_filter": "Adicionar filtro", "add_logo": "Adicionar logo", + "add_member": "Adicionar membro", "add_project": "Adicionar projeto", "add_to_team": "Adicionar à equipe", "all": "Todos", @@ -123,7 +140,6 @@ "app_survey": "Pesquisa de App", "apply_filters": "Aplicar filtros", "are_you_sure": "Certeza?", - "are_you_sure_this_action_cannot_be_undone": "Tem certeza? Essa ação não pode ser desfeita.", "attributes": "atributos", "avatar": "Avatar", "back": "Voltar", @@ -149,11 +165,13 @@ "connect_formbricks": "Conectar Formbricks", "connected": "conectado", "contacts": "Contatos", + "copied": "Copiado", "copied_to_clipboard": "Copiado para a área de transferência", "copy": "Copiar", "copy_code": "Copiar código", "copy_link": "Copiar Link", "create_new_organization": "Criar nova organização", + "create_project": "Criar projeto", "create_segment": "Criar segmento", "create_survey": "Criar pesquisa", "created": "Criado", @@ -180,20 +198,17 @@ "e_commerce": "comércio eletrônico", "edit": "Editar", "email": "Email", - "embed": "incorporar", "enterprise_license": "Licença Empresarial", "environment_not_found": "Ambiente não encontrado", "environment_notice": "Você está atualmente no ambiente {environment}.", "error": "Erro", - "error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.", - "error_component_title": "Erro ao carregar recursos", "expand_rows": "Expandir linhas", "finish": "Terminar", "follow_these": "Siga esses", "formbricks_version": "Versão do Formbricks", "full_name": "Nome completo", "gathering_responses": "Recolhendo respostas", - "general": "geral", + "general": "Geral", "go_back": "Voltar", "go_to_dashboard": "Ir para o Painel", "hidden": "Escondido", @@ -209,7 +224,6 @@ "in_progress": "Em andamento", "inactive_surveys": "Pesquisas inativas", "input_type": "Tipo de entrada", - "insights": "Percepções", "integration": "integração", "integrations": "Integrações", "invalid_date": "Data inválida", @@ -225,7 +239,6 @@ "limits_reached": "Limites Atingidos", "link": "link", "link_and_email": "Link & E-mail", - "link_copied": "Link copiado para a área de transferência!", "link_survey": "Pesquisa de Link", "link_surveys": "Link de Pesquisas", "load_more": "Carregar mais", @@ -246,8 +259,6 @@ "move_up": "Subir", "multiple_languages": "Vários idiomas", "name": "Nome", - "negative": "Negativo", - "neutral": "Neutro", "new": "Novo", "new_survey": "Nova Pesquisa", "new_version_available": "Formbricks {version} chegou. Atualize agora!", @@ -269,6 +280,8 @@ "on": "ligado", "only_one_file_allowed": "É permitido apenas um arquivo", "only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.", + "option_id": "ID da opção", + "option_ids": "IDs da Opção", "or": "ou", "organization": "organização", "organization_id": "ID da Organização", @@ -285,32 +298,34 @@ "phone": "Celular", "photo_by": "Foto por", "pick_a_date": "Escolhe uma data", + "picture": "Imagem", "placeholder": "Espaço reservado", "please_select_at_least_one_survey": "Por favor, selecione pelo menos uma pesquisa", "please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho", "please_upgrade_your_plan": "Por favor, atualize seu plano.", - "positive": "Positivo", "preview": "Prévia", "preview_survey": "Prévia da Pesquisa", "privacy": "Política de Privacidade", - "privacy_policy": "Política de Privacidade", "product_manager": "Gerente de Produto", "profile": "Perfil", - "project": "Projeto", + "profile_id": "ID de Perfil", "project_configuration": "Configuração do Projeto", + "project_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.", "project_id": "ID do Projeto", "project_name": "Nome do Projeto", + "project_name_placeholder": "por exemplo, Formbricks", "project_not_found": "Projeto não encontrado", "project_permission_not_found": "Permissão do projeto não encontrada", "projects": "Projetos", - "projects_limit_reached": "Limites de projetos atingidos", "question": "Pergunta", "question_id": "ID da Pergunta", "questions": "Perguntas", - "read_docs": "Ler Documentos", + "read_docs": "Ler Documentação", + "recipients": "Destinatários", "remove": "remover", "reorder_and_hide_columns": "Reordenar e ocultar colunas", "report_survey": "Relatório de Pesquisa", + "request_pricing": "Solicitar Preços", "request_trial_license": "Pedir licença de teste", "reset_to_default": "Restaurar para o padrão", "response": "Resposta", @@ -330,6 +345,7 @@ "select": "Selecionar", "select_all": "Selecionar tudo", "select_survey": "Selecionar Pesquisa", + "select_teams": "Selecionar times", "selected": "Selecionado", "selected_questions": "Perguntas selecionadas", "selection": "seleção", @@ -346,12 +362,13 @@ "skipped": "Pulou", "skips": "Pula", "some_files_failed_to_upload": "Alguns arquivos falharam ao enviar", + "something_went_wrong": "Algo deu errado", "something_went_wrong_please_try_again": "Algo deu errado. Tente novamente.", "sort_by": "Ordenar por", "start_free_trial": "Iniciar Teste Grátis", "status": "status", "step_by_step_manual": "Manual passo a passo", - "styling": "estilização", + "styling": "Estilização", "submit": "Enviar", "summary": "Resumo", "survey": "Pesquisa", @@ -363,15 +380,16 @@ "survey_paused": "Pesquisa pausada.", "survey_scheduled": "Pesquisa agendada.", "survey_type": "Tipo de Pesquisa", - "surveys": "pesquisas", + "surveys": "Pesquisas", "switch_organization": "Mudar organização", "switch_to": "Mudar para {environment}", "table_items_deleted_successfully": "{type}s deletados com sucesso", "table_settings": "Arrumação da mesa", - "tags": "etiquetas", + "tags": "Etiquetas", "targeting": "mirando", "team": "Time", "team_access": "Acesso da equipe", + "team_id": "ID da Equipe", "team_name": "Nome da equipe", "teams": "Controle de Acesso", "teams_not_found": "Equipes não encontradas", @@ -404,9 +422,7 @@ "website_and_app_connection": "Conexão de Site e App", "website_app_survey": "Pesquisa de Site e App", "website_survey": "Pesquisa de Site", - "weekly_summary": "Resumo semanal", "welcome_card": "Cartão de boas-vindas", - "yes": "Sim", "you": "Você", "you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.", "you_are_not_authorised_to_perform_this_action": "Você não tem autorização para fazer isso.", @@ -446,41 +462,13 @@ "invite_email_text_par1": "Seu colega", "invite_email_text_par2": "te convidou para se juntar a eles na Formbricks. Para aceitar o convite, por favor clique no link abaixo:", "invite_member_email_subject": "Você foi convidado a colaborar no Formbricks!", - "live_survey_notification_completed": "Concluído", - "live_survey_notification_draft": "Rascunho", - "live_survey_notification_in_progress": "Em andamento", - "live_survey_notification_no_new_response": "Nenhuma resposta nova recebida essa semana \uD83D\uDD75️", - "live_survey_notification_no_responses_yet": "Ainda sem respostas!", - "live_survey_notification_paused": "Pausado", - "live_survey_notification_scheduled": "agendado", - "live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas", - "live_survey_notification_view_previous_responses": "Ver respostas anteriores", - "live_survey_notification_view_response": "Ver Resposta", - "notification_footer_all_the_best": "Tudo de bom,", - "notification_footer_in_your_settings": "nas suas configurações \uD83D\uDE4F", - "notification_footer_please_turn_them_off": "por favor, desliga eles", - "notification_footer_the_formbricks_team": "A Equipe Formbricks \uD83E\uDD0D", - "notification_footer_to_halt_weekly_updates": "Para parar as Atualizações Semanais,", - "notification_header_hey": "Oi \uD83D\uDC4B", - "notification_header_weekly_report_for": "Relatório Semanal de", - "notification_insight_completed": "Concluído", - "notification_insight_completion_rate": "Conclusão %", - "notification_insight_displays": "telas", - "notification_insight_responses": "Respostas", - "notification_insight_surveys": "pesquisas", - "onboarding_invite_email_button_label": "Entre na organização de {inviterName}", - "onboarding_invite_email_connect_formbricks": "Conecte o Formbricks ao seu app ou site via HTML Snippet ou NPM em apenas alguns minutos.", - "onboarding_invite_email_create_account": "Crie uma conta para entrar na organização de {inviterName}.", - "onboarding_invite_email_done": "Feito ✅", - "onboarding_invite_email_get_started_in_minutes": "Comece em Minutos", - "onboarding_invite_email_heading": "Oi ", - "onboarding_invite_email_subject": "{inviterName} precisa de ajuda para configurar o Formbricks. Você pode ajudar?", + "new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:", "password_changed_email_heading": "Senha alterada", "password_changed_email_text": "Sua senha foi alterada com sucesso.", "password_reset_notify_email_subject": "Sua senha Formbricks foi alterada", - "powered_by_formbricks": "Desenvolvido por Formbricks", "privacy_policy": "Política de Privacidade", "reject": "Rejeitar", + "render_email_response_value_file_upload_response_link_not_included": "O link para o arquivo enviado não está incluído por motivos de privacidade de dados", "response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅", "response_finished_email_subject_with_email": "{personEmail} acabou de completar sua pesquisa {surveyName} ✅", "schedule_your_meeting": "Agendar sua reunião", @@ -505,14 +493,9 @@ "verification_email_thanks": "Valeu por validar seu e-mail!", "verification_email_to_fill_survey": "Para preencher a pesquisa, por favor clique no botão abaixo:", "verification_email_verify_email": "Verificar e-mail", - "verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida.", - "weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um horário de 15 minutos na agenda do nosso CEO", - "weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe uma semana passar sem aprender sobre seus usuários:", - "weekly_summary_create_reminder_notification_body_need_help": "Precisa de ajuda pra encontrar a pesquisa certa pro seu produto?", - "weekly_summary_create_reminder_notification_body_reply_email": "ou responde a esse e-mail :)", - "weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurar uma nova pesquisa", - "weekly_summary_create_reminder_notification_body_text": "Adoraríamos te enviar um Resumo Semanal, mas no momento não há pesquisas em andamento para {projectName}.", - "weekly_summary_email_subject": "Insights de usuários do {projectName} – Semana passada por Formbricks" + "verification_new_email_subject": "Verificação de alteração de e-mail", + "verification_security_notice": "Se você não solicitou essa mudança de email, por favor ignore este email ou entre em contato com o suporte imediatamente.", + "verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida." }, "environments": { "actions": { @@ -525,21 +508,21 @@ "action_with_key_already_exists": "Ação com a chave {key} já existe", "action_with_name_already_exists": "Ação com o nome {name} já existe", "add_css_class_or_id": "Adicionar classe ou id CSS", + "add_regular_expression_here": "Adicionar uma expressão regular aqui", "add_url": "Adicionar URL", "click": "Clica", "contains": "contém", "create_action": "criar ação", "css_selector": "Seletor CSS", "delete_action_text": "Tem certeza de que quer deletar essa ação? Isso também vai remover essa ação como gatilho de todas as suas pesquisas.", - "display_name": "Nome de exibição", "does_not_contain": "não contém", "does_not_exactly_match": "Não bate exatamente", "eg_clicked_download": "Por exemplo, clicou em baixar", "eg_download_cta_click_on_home": "e.g. download_cta_click_on_home", "eg_install_app": "Ex: Instalar App", - "eg_user_clicked_download_button": "Por exemplo, usuário clicou no botão de download", "ends_with": "Termina com", "enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Digite uma URL para ver se um usuário que a visita seria rastreado.", + "enter_url": "ex.: https://app.com/dashboard", "exactly_matches": "Combina exatamente", "exit_intent": "Intenção de Saída", "fifty_percent_scroll": "Rolar 50%", @@ -548,9 +531,14 @@ "if_a_user_clicks_a_button_with_a_specific_text": "Se um usuário clicar em um botão com um texto específico", "in_your_code_read_more_in_our": "no seu código. Leia mais em nosso", "inner_text": "Texto Interno", + "invalid_action_type_code": "Tipo de ação inválido para ação com código", + "invalid_action_type_no_code": "Tipo de ação inválido para ação noCode", "invalid_css_selector": "Seletor CSS Inválido", + "invalid_match_type": "A opção selecionada não está disponível.", + "invalid_regex": "Por favor, use uma expressão regular válida.", "limit_the_pages_on_which_this_action_gets_captured": "Limite as páginas nas quais essa ação é capturada", "limit_to_specific_pages": "Limitar a páginas específicas", + "matches_regex": "Correspondência regex", "on_all_pages": "Em todas as páginas", "page_filter": "filtro de página", "page_view": "Visualização de Página", @@ -570,7 +558,9 @@ "user_clicked_download_button": "Usuário clicou no botão de download", "what_did_your_user_do": "O que seu usuário fez?", "what_is_the_user_doing": "O que o usuário tá fazendo?", - "you_can_track_code_action_anywhere_in_your_app_using": "Você pode rastrear ações de código em qualquer lugar do seu app usando" + "you_can_track_code_action_anywhere_in_your_app_using": "Você pode rastrear ações de código em qualquer lugar do seu app usando", + "your_survey_would_be_shown_on_this_url": "Sua pesquisa seria exibida neste URL.", + "your_survey_would_not_be_shown": "Sua pesquisa não seria exibida." }, "connect": { "congrats": "Parabéns!", @@ -587,8 +577,8 @@ "contact_deleted_successfully": "Contato excluído com sucesso", "contact_not_found": "Nenhum contato encontrado", "contacts_table_refresh": "Atualizar contatos", - "contacts_table_refresh_error": "Ocorreu um erro ao atualizar os contatos. Por favor, tente novamente.", "contacts_table_refresh_success": "Contatos atualizados com sucesso", + "delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.", "first_name": "Primeiro Nome", "last_name": "Sobrenome", "no_responses_found": "Nenhuma resposta encontrada", @@ -616,33 +606,6 @@ "upload_contacts_modal_preview": "Aqui está uma prévia dos seus dados.", "upload_contacts_modal_upload_btn": "Fazer upload de contatos" }, - "experience": { - "all": "tudo", - "all_time": "Todo o tempo", - "analysed_feedbacks": "Feedbacks Analisados", - "category": "Categoria", - "category_updated_successfully": "Categoria atualizada com sucesso!", - "complaint": "Reclamação", - "did_you_find_this_insight_helpful": "Você achou essa dica útil?", - "failed_to_update_category": "Falha ao atualizar categoria", - "feature_request": "Pedido de Recurso", - "good_afternoon": "\uD83C\uDF24️ Boa tarde", - "good_evening": "\uD83C\uDF19 Boa noite", - "good_morning": "☀️ Bom dia", - "insights_description": "Todos os insights gerados a partir das respostas de todas as suas pesquisas", - "insights_for_project": "Insights para {projectName}", - "new_responses": "Novas Respostas", - "no_insights_for_this_filter": "Sem insights para este filtro", - "no_insights_found": "Não foram encontrados insights. Colete mais respostas de pesquisa ou ative insights para suas pesquisas existentes para começar.", - "praise": "elogio", - "sentiment_score": "Pontuação de Sentimento", - "templates_card_description": "Escolha um template ou comece do zero", - "templates_card_title": "Meça a experiência do seu cliente", - "this_month": "Este mês", - "this_quarter": "Esse trimestre", - "this_week": "Essa semana", - "today": "Hoje" - }, "formbricks_logo": "Logo da Formbricks", "integrations": { "activepieces_integration_description": "Conecte o Formbricks instantaneamente com aplicativos populares para automatizar tarefas sem codificação.", @@ -652,6 +615,7 @@ "airtable_integration": "Integração com Airtable", "airtable_integration_description": "Sincronize respostas diretamente com o Airtable.", "airtable_integration_is_not_configured": "A integração com o Airtable não está configurada", + "airtable_logo": "Logo do Airtable", "connect_with_airtable": "Conectar com o Airtable", "link_airtable_table": "Vincular Tabela do Airtable", "link_new_table": "Vincular nova tabela", @@ -719,7 +683,6 @@ "select_a_database": "Selecionar Banco de Dados", "select_a_field_to_map": "Selecione um campo para mapear", "select_a_survey_question": "Escolha uma pergunta da pesquisa", - "sync_responses_with_a_notion_database": "Sincronizar respostas com um banco de dados do Notion", "update_connection": "Reconectar Notion", "update_connection_tooltip": "Reconecte a integração para incluir os novos bancos de dados adicionados. Suas integrações existentes permanecerão intactas." }, @@ -741,6 +704,7 @@ "slack_integration": "Integração com o Slack", "slack_integration_description": "Manda as respostas direto pro Slack.", "slack_integration_is_not_configured": "A integração do Slack não está configurada na sua instância do Formbricks.", + "slack_logo": "Logotipo do Slack", "slack_reconnect_button": "Reconectar", "slack_reconnect_button_description": "Observação: Recentemente, alteramos nossa integração com o Slack para também suportar canais privados. Por favor, reconecte seu workspace do Slack." }, @@ -777,6 +741,7 @@ }, "project": { "api_keys": { + "access_control": "Controle de Acesso", "add_api_key": "Adicionar Chave API", "api_key": "Chave de API", "api_key_copied_to_clipboard": "Chave da API copiada para a área de transferência", @@ -784,9 +749,12 @@ "api_key_deleted": "Chave da API deletada", "api_key_label": "Rótulo da Chave API", "api_key_security_warning": "Por motivos de segurança, a chave da API será mostrada apenas uma vez após a criação. Por favor, copie-a para o seu destino imediatamente.", + "api_key_updated": "Chave de API atualizada", "duplicate_access": "Acesso duplicado ao projeto não permitido", "no_api_keys_yet": "Você ainda não tem nenhuma chave de API", + "no_env_permissions_found": "Nenhuma permissão de ambiente encontrada", "organization_access": "Acesso à Organização", + "organization_access_description": "Selecione privilégios de leitura ou escrita para recursos de toda a organização.", "permissions": "Permissões", "project_access": "Acesso ao Projeto", "secret": "Segredo", @@ -796,6 +764,8 @@ "api_host_description": "Essa é a URL do seu backend do Formbricks.", "app_connection": "Conexão do App", "app_connection_description": "Conecte seu app ao Formbricks.", + "cache_update_delay_description": "Quando você faz atualizações em pesquisas, contatos, ações ou outros dados, pode levar até 5 minutos para que essas mudanças apareçam no seu app local rodando o SDK do Formbricks. Esse atraso é devido a uma limitação no nosso sistema de cache atual. Estamos ativamente retrabalhando o cache e planejamos lançar uma correção no Formbricks 4.0.", + "cache_update_delay_title": "As mudanças serão refletidas após 5 minutos devido ao cache", "check_out_the_docs": "Confere a documentação.", "dive_into_the_docs": "Mergulha na documentação.", "does_your_widget_work": "Seu widget funciona?", @@ -921,8 +891,7 @@ "tag_already_exists": "Tag já existe", "tag_deleted": "Tag apagada", "tag_updated": "Tag atualizada", - "tags_merged": "Tags mescladas", - "unique_constraint_failed_on_the_fields": "Falha na restrição única nos campos" + "tags_merged": "Tags mescladas" }, "teams": { "manage_teams": "Gerenciar Equipes", @@ -970,6 +939,7 @@ "save_your_filters_as_a_segment_to_use_it_in_other_surveys": "Salve seus filtros como um Segmento para usar em outras pesquisas", "segment_created_successfully": "Segmento criado com sucesso!", "segment_deleted_successfully": "Segmento deletado com sucesso!", + "segment_id": "ID do segmento", "segment_saved_successfully": "Segmento salvo com sucesso", "segment_updated_successfully": "Segmento atualizado com sucesso!", "segments_help_you_target_users_with_same_characteristics_easily": "Segmentos ajudam você a direcionar usuários com as mesmas características facilmente", @@ -991,67 +961,56 @@ "api_keys": { "add_api_key": "Adicionar chave de API", "add_permission": "Adicionar permissão", - "api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks", - "only_organization_owners_and_managers_can_manage_api_keys": "Apenas proprietários e gerentes da organização podem gerenciar chaves de API" + "api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks" }, "billing": { - "10000_monthly_responses": "10000 Respostas Mensais", - "1500_monthly_responses": "1500 Respostas Mensais", - "2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente", - "30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente", + "1000_monthly_responses": "1000 Respostas Mensais", + "1_project": "1 Projeto", + "2000_contacts": "2.000 Contatos", "3_projects": "3 Projetos", - "5000_monthly_responses": "5000 Respostas Mensais", - "5_projects": "5 Projetos", - "7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente", - "advanced_targeting": "Mira Avançada", + "5000_monthly_responses": "5,000 Respostas Mensais", + "7500_contacts": "7.500 Contatos", "all_integrations": "Todas as Integrações", - "all_surveying_features": "Todos os recursos de levantamento", "annually": "anualmente", "api_webhooks": "API e Webhooks", "app_surveys": "Pesquisas de App", - "contact_us": "Fale Conosco", + "attribute_based_targeting": "Segmentação Baseada em Atributos", "current": "atual", "current_plan": "Plano Atual", "current_tier_limit": "Limite Atual de Nível", - "custom_miu_limit": "Limite MIU personalizado", + "custom": "Personalizado e Escala", + "custom_contacts_limit": "Limite de Contatos Personalizado", "custom_project_limit": "Limite de Projeto Personalizado", - "customer_success_manager": "Gerente de Sucesso do Cliente", + "custom_response_limit": "Limite de Resposta Personalizado", "email_embedded_surveys": "Pesquisas Incorporadas no Email", - "email_support": "Suporte por Email", - "enterprise": "Empresa", + "email_follow_ups": "Acompanhamentos por Email", "enterprise_description": "Suporte premium e limites personalizados.", "everybody_has_the_free_plan_by_default": "Todo mundo tem o plano gratuito por padrão!", "everything_in_free": "Tudo de graça", - "everything_in_scale": "Tudo em Escala", "everything_in_startup": "Tudo em Startup", "free": "grátis", "free_description": "Pesquisas ilimitadas, membros da equipe e mais.", "get_2_months_free": "Ganhe 2 meses grátis", "get_in_touch": "Entre em contato", + "hosted_in_frankfurt": "Hospedado em Frankfurt", + "ios_android_sdks": "SDK para iOS e Android para pesquisas móveis", "link_surveys": "Link de Pesquisas (Compartilhável)", "logic_jumps_hidden_fields_recurring_surveys": "Pulos Lógicos, Campos Ocultos, Pesquisas Recorrentes, etc.", "manage_card_details": "Gerenciar Detalhes do Cartão", "manage_subscription": "Gerenciar Assinatura", "monthly": "mensal", "monthly_identified_users": "Usuários Identificados Mensalmente", - "multi_language_surveys": "Pesquisas Multilíngues", "per_month": "por mês", "per_year": "por ano", "plan_upgraded_successfully": "Plano atualizado com sucesso", "premium_support_with_slas": "Suporte premium com SLAs", - "priority_support": "Suporte Prioritário", "remove_branding": "Remover Marca", - "say_hi": "Diz oi!", - "scale": "escala", - "scale_description": "Recursos avançados pra escalar seu negócio.", "startup": "startup", "startup_description": "Tudo no Grátis com recursos adicionais.", "switch_plan": "Mudar Plano", "switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.", "team_access_roles": "Funções de Acesso da Equipe", - "technical_onboarding": "Integração Técnica", "unable_to_upgrade_plan": "Não foi possível atualizar o plano", - "unlimited_apps_websites": "Apps e Sites Ilimitados", "unlimited_miu": "MIU Ilimitado", "unlimited_projects": "Projetos Ilimitados", "unlimited_responses": "Respostas Ilimitadas", @@ -1062,7 +1021,6 @@ "website_surveys": "Pesquisas de Site" }, "enterprise": { - "ai": "Análise de IA", "audit_logs": "Registros de Auditoria", "coming_soon": "Em breve", "contacts_and_segments": "Gerenciamento de contatos e segmentos", @@ -1091,6 +1049,7 @@ "create_new_organization": "Criar nova organização", "create_new_organization_description": "Criar uma nova organização para lidar com um conjunto diferente de projetos.", "customize_email_with_a_higher_plan": "Personalize o email com um plano superior", + "delete_member_confirmation": "Membros apagados perderão acesso a todos os projetos e pesquisas da sua organização.", "delete_organization": "Excluir Organização", "delete_organization_description": "Excluir organização com todos os seus projetos, incluindo todas as pesquisas, respostas, pessoas, ações e atributos", "delete_organization_warning": "Antes de continuar com a exclusão desta organização, esteja ciente das seguintes consequências:", @@ -1100,13 +1059,7 @@ "eliminate_branding_with_whitelabel": "Elimine a marca Formbricks e ative opções adicionais de personalização de marca branca.", "email_customization_preview_email_heading": "Oi {userName}", "email_customization_preview_email_text": "Esta é uma pré-visualização de e-mail para mostrar qual logo será renderizado nos e-mails.", - "enable_formbricks_ai": "Ativar Formbricks IA", "error_deleting_organization_please_try_again": "Erro ao deletar a organização. Por favor, tente novamente.", - "formbricks_ai": "Formbricks IA", - "formbricks_ai_description": "Obtenha insights personalizados das suas respostas de pesquisa com o Formbricks AI", - "formbricks_ai_disable_success_message": "Formbricks AI desativado com sucesso.", - "formbricks_ai_enable_success_message": "Formbricks AI ativado com sucesso.", - "formbricks_ai_privacy_policy_text": "Ao ativar o Formbricks AI, você concorda com a versão atualizada", "from_your_organization": "da sua organização", "invitation_sent_once_more": "Convite enviado de novo.", "invite_deleted_successfully": "Convite deletado com sucesso", @@ -1153,10 +1106,8 @@ "need_slack_or_discord_notifications": "Preciso de notificações no Slack ou Discord", "notification_settings_updated": "Configurações de notificação atualizadas", "set_up_an_alert_to_get_an_email_on_new_responses": "Configura um alerta pra receber um e-mail com novas respostas", - "stay_up_to_date_with_a_Weekly_every_Monday": "Fique por dentro com um resumo semanal toda segunda-feira", "use_the_integration": "Use a integração", "want_to_loop_in_organization_mates": "Quero incluir os colegas da organização", - "weekly_summary_projects": "Resumo semanal (Projetos)", "you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Você não vai ser mais inscrito automaticamente nas pesquisas dessa organização!", "you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Você não vai receber mais e-mails sobre respostas dessa pesquisa!" }, @@ -1172,6 +1123,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.", @@ -1252,8 +1204,9 @@ "copy_survey_description": "Copiar essa pesquisa para outro ambiente", "copy_survey_error": "Falha ao copiar pesquisa", "copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência", + "copy_survey_partially_success": "{success} pesquisas copiadas com sucesso, {error} falharam.", "copy_survey_success": "Pesquisa copiada com sucesso!", - "delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas? Essa ação não pode ser desfeita.", + "delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?", "edit": { "1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para essa pesquisa:", "2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:", @@ -1273,6 +1226,8 @@ "add_description": "Adicionar Descrição", "add_ending": "Adicionar final", "add_ending_below": "Adicione o final abaixo", + "add_fallback": "Adicionar", + "add_fallback_placeholder": "Adicionar um texto padrão para mostrar se a pergunta for ignorada:", "add_hidden_field_id": "Adicionar campo oculto ID", "add_highlight_border": "Adicionar borda de destaque", "add_highlight_border_description": "Adicione uma borda externa ao seu cartão de pesquisa.", @@ -1311,8 +1266,6 @@ "automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Liberar automaticamente a pesquisa no começo do dia (UTC).", "back_button_label": "Voltar", "background_styling": "Estilo de Fundo", - "blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloqueia a pesquisa se já existir uma submissão com o Id de Uso Único (suId).", - "blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloqueia a pesquisa se a URL da pesquisa não tiver um Id de Uso Único (suId).", "brand_color": "Cor da marca", "brightness": "brilho", "button_label": "Rótulo do Botão", @@ -1326,14 +1279,21 @@ "card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}", "card_background_color": "Cor de fundo do cartão", "card_border_color": "Cor da borda do cartão", - "card_shadow_color": "cor da sombra do cartão", "card_styling": "Estilização de Cartão", "casual": "Casual", + "caution_edit_duplicate": "Duplicar e editar", + "caution_edit_published_survey": "Editar uma pesquisa publicada?", + "caution_explanation_intro": "Entendemos que você ainda pode querer fazer alterações. Aqui está o que acontece se você fizer:", + "caution_explanation_new_responses_separated": "Respostas antes da mudança podem não ser ou apenas parcialmente incluídas no resumo da pesquisa.", + "caution_explanation_only_new_responses_in_summary": "Todos os dados, incluindo respostas anteriores, permanecem disponíveis para download na página de resumo da pesquisa.", + "caution_explanation_responses_are_safe": "Respostas antigas e novas são misturadas, o que pode levar a resumos de dados enganosos.", + "caution_recommendation": "Isso pode causar inconsistências de dados no resumo da pesquisa. Recomendamos duplicar a pesquisa em vez disso.", "caution_text": "Mudanças vão levar a inconsistências", "centered_modal_overlay_color": "cor de sobreposição modal centralizada", "change_anyway": "Mudar mesmo assim", "change_background": "Mudar fundo", "change_question_type": "Mudar tipo de pergunta", + "change_survey_type": "Alterar o tipo de pesquisa afeta o acesso existente", "change_the_background_color_of_the_card": "Muda a cor de fundo do cartão.", "change_the_background_color_of_the_input_fields": "Mude a cor de fundo dos campos de entrada.", "change_the_background_to_a_color_image_or_animation": "Mude o fundo para uma cor, imagem ou animação.", @@ -1343,8 +1303,8 @@ "change_the_brand_color_of_the_survey": "Muda a cor da marca da pesquisa.", "change_the_placement_of_this_survey": "Muda a posição dessa pesquisa.", "change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.", - "change_the_shadow_color_of_the_card": "Muda a cor da sombra do cartão.", "changes_saved": "Mudanças salvas.", + "changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de pesquisa afetará a forma como ela pode ser compartilhada. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.", "character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.", "character_limit_toggle_title": "Adicionar limites de caracteres", "checkbox_label": "Rótulo da Caixa de Seleção", @@ -1354,10 +1314,11 @@ "close_survey_on_date": "Fechar pesquisa na data", "close_survey_on_response_limit": "Fechar pesquisa ao atingir limite de respostas", "color": "cor", + "column_used_in_logic_error": "Esta coluna é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.", "columns": "colunas", "company": "empresa", "company_logo": "Logo da empresa", - "completed_responses": "respostas completas", + "completed_responses": "respostas parciais ou completas.", "concat": "Concatenar +", "conditional_logic": "Lógica Condicional", "confirm_default_language": "Confirmar idioma padrão", @@ -1391,8 +1352,9 @@ "does_not_start_with": "Não começa com", "edit_recall": "Editar Lembrete", "edit_translations": "Editar traduções de {lang}", - "enable_encryption_of_single_use_id_suid_in_survey_url": "Habilitar criptografia do Id de Uso Único (suId) na URL da pesquisa.", "enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.", + "enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.", + "enable_spam_protection": "Proteção contra spam", "end_screen_card": "cartão de tela final", "ending_card": "Cartão de encerramento", "ending_card_used_in_logic": "Esse cartão de encerramento é usado na lógica da pergunta {questionIndex}.", @@ -1403,6 +1365,7 @@ "error_saving_changes": "Erro ao salvar alterações", "even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de eles enviarem uma resposta (por exemplo, Caixa de Feedback)", "everyone": "Todo mundo", + "fallback_for": "Alternativa para", "fallback_missing": "Faltando alternativa", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.", "field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço", @@ -1420,6 +1383,8 @@ "follow_ups_item_issue_detected_tag": "Problema detectado", "follow_ups_item_response_tag": "Qualquer resposta", "follow_ups_item_send_email_tag": "Enviar e-mail", + "follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta da pesquisa ao acompanhamento", + "follow_ups_modal_action_attach_response_data_label": "Anexar dados da resposta", "follow_ups_modal_action_body_label": "Corpo", "follow_ups_modal_action_body_placeholder": "Corpo do e-mail", "follow_ups_modal_action_email_content": "Conteúdo do e-mail", @@ -1450,9 +1415,6 @@ "follow_ups_new": "Novo acompanhamento", "follow_ups_upgrade_button_text": "Atualize para habilitar os Acompanhamentos", "form_styling": "Estilização de Formulários", - "formbricks_ai_description": "Descreva sua pesquisa e deixe a Formbricks AI criar a pesquisa pra você", - "formbricks_ai_generate": "gerar", - "formbricks_ai_prompt_placeholder": "Insira as informações da pesquisa (ex.: tópicos principais a serem abordados)", "formbricks_sdk_is_not_connected": "O SDK do Formbricks não está conectado", "four_points": "4 pontos", "heading": "Título", @@ -1465,7 +1427,6 @@ "hide_the_logo_in_this_specific_survey": "Esconder o logo nessa pesquisa específica", "hostname": "nome do host", "how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão descoladas você quer suas cartas em Pesquisas {surveyTypeDerived}", - "how_it_works": "Como funciona", "if_you_need_more_please": "Se você precisar de mais, por favor", "if_you_really_want_that_answer_ask_until_you_get_it": "Se você realmente quer essa resposta, pergunte até conseguir.", "ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre pesquisas", @@ -1481,10 +1442,13 @@ "invalid_youtube_url": "URL do YouTube inválida", "is_accepted": "Está aceito", "is_after": "é depois", + "is_any_of": "É qualquer um de", "is_before": "é antes", "is_booked": "Tá reservado", "is_clicked": "É clicado", "is_completely_submitted": "Está completamente submetido", + "is_empty": "Está vazio", + "is_not_empty": "Não está vazio", "is_not_set": "Não está definido", "is_partially_submitted": "Parcialmente enviado", "is_set": "Está definido", @@ -1500,7 +1464,6 @@ "limit_the_maximum_file_size": "Limitar o tamanho máximo do arquivo", "limit_upload_file_size_to": "Limitar tamanho do arquivo de upload para", "link_survey_description": "Compartilhe um link para a página da pesquisa ou incorpore-a em uma página da web ou e-mail.", - "link_used_message": "Link Usado", "load_segment": "segmento de carga", "logic_error_warning": "Mudar vai causar erros de lógica", "logic_error_warning_text": "Mudar o tipo de pergunta vai remover as condições lógicas dessa pergunta", @@ -1516,6 +1479,7 @@ "no_hidden_fields_yet_add_first_one_below": "Ainda não há campos ocultos. Adicione o primeiro abaixo.", "no_images_found_for": "Nenhuma imagem encontrada para ''{query}\"", "no_languages_found_add_first_one_to_get_started": "Nenhum idioma encontrado. Adicione o primeiro para começar.", + "no_option_found": "Nenhuma opção encontrada", "no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.", "number": "Número", "once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Depois de definido, o idioma padrão desta pesquisa só pode ser alterado desativando a opção de vários idiomas e excluindo todas as traduções.", @@ -1566,7 +1530,8 @@ "response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).", "response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.", "response_options": "Opções de Resposta", - "roundness": "redondeza", + "roundness": "Circularidade", + "row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.", "rows": "linhas", "save_and_close": "Salvar e Fechar", "scale": "escala", @@ -1590,22 +1555,22 @@ "show_survey_to_users": "Mostrar pesquisa para % dos usuários", "show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados", "simple": "Simples", - "single_use_survey_links": "Links de pesquisa de uso único", - "single_use_survey_links_description": "Permitir apenas 1 resposta por link da pesquisa.", + "six_points": "6 pontos", "skip_button_label": "Botão de Pular", "smiley": "Sorridente", + "spam_protection_note": "A proteção contra spam não funciona para pesquisas exibidas com os SDKs iOS, React Native e Android. Isso vai quebrar a pesquisa.", + "spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo desse valor serão rejeitadas.", + "spam_protection_threshold_heading": "Limite de resposta", "star": "Estrela", "starts_with": "Começa com", "state": "Estado", - "straight": "hétero", + "straight": "Alinhado", "style_the_question_texts_descriptions_and_input_fields": "Estilize os textos das perguntas, descrições e campos de entrada.", "style_the_survey_card": "Estilize o cartão da pesquisa.", "styling_set_to_theme_styles": "Estilo definido para os estilos do tema", "subheading": "Subtítulo", "subtract": "Subtrair -", "suggest_colors": "Sugerir cores", - "survey_already_answered_heading": "A pesquisa já foi respondida.", - "survey_already_answered_subheading": "Você só pode usar esse link uma vez.", "survey_completed_heading": "Pesquisa Concluída", "survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada", "survey_display_settings": "Configurações de Exibição da Pesquisa", @@ -1636,7 +1601,6 @@ "upload": "Enviar", "upload_at_least_2_images": "Faz o upload de pelo menos 2 imagens", "upper_label": "Etiqueta Superior", - "url_encryption": "Criptografia de URL", "url_filters": "Filtros de URL", "url_not_supported": "URL não suportada", "use_with_caution": "Use com cuidado", @@ -1649,7 +1613,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Espera alguns segundos depois do gatilho antes de mostrar a pesquisa", "waiting_period": "período de espera", "welcome_message": "Mensagem de boas-vindas", - "when": "Quando", "when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Quando as condições forem atendidas, o tempo de espera será ignorado e a pesquisa será exibida.", "without_a_filter_all_of_your_users_can_be_surveyed": "Sem um filtro, todos os seus usuários podem ser pesquisados.", "you_have_not_created_a_segment_yet": "Você ainda não criou um segmento.", @@ -1660,9 +1623,11 @@ "zip": "Fecho éclair" }, "error_deleting_survey": "Ocorreu um erro ao deletar a pesquisa", - "failed_to_copy_link_to_results": "Falha ao copiar link dos resultados", - "failed_to_copy_url": "Falha ao copiar URL: não está em um ambiente de navegador.", - "new_single_use_link_generated": "Novo link de uso único gerado", + "filter": { + "complete_and_partial_responses": "Respostas completas e parciais", + "complete_responses": "Respostas completas", + "partial_responses": "Respostas parciais" + }, "new_survey": "Nova Pesquisa", "no_surveys_created_yet": "Ainda não foram criadas pesquisas", "open_options": "Abre opções", @@ -1681,9 +1646,11 @@ "company": "empresa", "completed": "Concluído ✅", "country": "País", + "delete_response_confirmation": "Isso irá excluir a resposta da pesquisa, incluindo todas as respostas, etiquetas, documentos anexados e metadados da resposta.", "device": "dispositivo", "device_info": "Informações do dispositivo", "email": "Email", + "error_downloading_responses": "Ocorreu um erro ao baixar respostas", "first_name": "Primeiro Nome", "how_to_identify_users": "Como identificar usuários", "last_name": "Sobrenome", @@ -1702,8 +1669,91 @@ "this_response_is_in_progress": "Essa resposta está em andamento.", "zip_post_code": "CEP / Código postal" }, - "results_unpublished_successfully": "Resultados não publicados com sucesso.", "search_by_survey_name": "Buscar pelo nome da pesquisa", + "share": { + "anonymous_links": { + "custom_single_use_id_description": "Se você não criptografar ID’s de uso único, qualquer valor para “suid=...” funciona para uma resposta", + "custom_single_use_id_title": "Você pode definir qualquer valor como ID de uso único na URL.", + "custom_start_point": "Ponto de início personalizado", + "data_prefilling": "preenchimento automático de dados", + "description": "Respostas vindas desses links serão anônimas", + "disable_multi_use_link_modal_button": "Desativar link de uso múltiplo", + "disable_multi_use_link_modal_description": "Desativar o link de uso múltiplo impedirá que alguém envie uma resposta por meio do link.", + "disable_multi_use_link_modal_description_subtext": "Também quebrará quaisquer incorporações ativas em Sites, Emails, Mídias Sociais e códigos QR que usem esse link de uso múltiplo.", + "disable_multi_use_link_modal_title": "Tem certeza? Isso pode quebrar incorporações ativas", + "disable_single_use_link_modal_button": "Desativar links de uso único", + "disable_single_use_link_modal_description": "Se você compartilhou links de uso único, os participantes não poderão mais responder à pesquisa.", + "generate_and_download_links": "Gerar & baixar links", + "generate_links_error": "Não foi possível gerar links de uso único. Por favor, trabalhe diretamente com a API", + "multi_use_link": "Link de uso múltiplo", + "multi_use_link_description": "Coletar múltiplas respostas de respondentes anônimos com um link.", + "multi_use_powers_other_channels_description": "Se você desativar, esses outros canais de distribuição também serão desativados", + "multi_use_powers_other_channels_title": "Este link habilita incorporações em sites, incorporações em e-mails, compartilhamento em redes sociais e códigos QR", + "nav_title": "Links anônimos", + "number_of_links_label": "Número de links (1 - 5.000)", + "single_use_link": "Links de uso único", + "single_use_link_description": "Permitir apenas uma resposta por link da pesquisa.", + "single_use_links": "Links de uso único", + "source_tracking": "rastreamento de origem", + "url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado", + "url_encryption_label": "Criptografia de URL de ID de uso único" + }, + "dynamic_popup": { + "alert_button": "Editar pesquisa", + "alert_description": "Esta pesquisa está atualmente configurada como uma pesquisa de link, o que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de pesquisas.", + "alert_title": "Alterar o tipo de pesquisa para dentro do app", + "attribute_based_targeting": "Segmentação baseada em atributos", + "code_no_code_triggers": "Gatilhos de código e sem código", + "description": "\"As pesquisas do Formbricks podem ser integradas como um pop-up, baseado na interação do usuário.\"", + "nav_title": "Dinâmico (Pop-up)", + "recontact_options": "Opções de Recontato" + }, + "embed_on_website": { + "description": "Os formulários Formbricks podem ser incorporados como um elemento estático.", + "embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!", + "embed_mode": "Modo Embutido", + "embed_mode_description": "Incorpore sua pesquisa com um design minimalista, sem preenchimento e fundo.", + "nav_title": "Incorporar no site" + }, + "personal_links": { + "create_and_manage_segments": "Crie e gerencie seus Segmentos em Contatos > Segmentos", + "description": "Gerar links pessoais para um segmento e associar respostas de pesquisa a cada contato.", + "expiry_date_description": "Quando o link expirar, o destinatário não poderá mais responder à pesquisa.", + "expiry_date_optional": "Data de expiração (opcional)", + "generate_and_download_links": "Gerar & baixar links", + "generating_links": "Gerando links", + "generating_links_toast": "Gerando links, o download começará em breve…", + "links_generated_success_toast": "Links gerados com sucesso, o download começará em breve.", + "nav_title": "Links pessoais", + "no_segments_available": "Nenhum segmento disponível", + "select_segment": "Selecionar segmento", + "upgrade_prompt_description": "Gerar links pessoais para um segmento e vincular respostas de pesquisa a cada contato.", + "upgrade_prompt_title": "Use links pessoais com um plano superior", + "work_with_segments": "Links pessoais funcionam com segmentos." + }, + "send_email": { + "copy_embed_code": "Copiar código incorporado", + "description": "Incorpore sua pesquisa em um e-mail para obter respostas do seu público.", + "email_preview_tab": "Prévia do Email", + "email_sent": "Email enviado!", + "email_subject_label": "Assunto", + "email_to_label": "Para", + "embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!", + "embed_code_copied_to_clipboard_failed": "Falha ao copiar, por favor, tente novamente", + "embed_code_tab": "Código de Incorporação", + "formbricks_email_survey_preview": "Prévia da Pesquisa por E-mail do Formbricks", + "nav_title": "Incorporação de Email", + "send_preview": "Enviar prévia", + "send_preview_email": "Enviar prévia de e-mail" + }, + "share_view_title": "Compartilhar via", + "social_media": { + "description": "Obtenha respostas de seus contatos em várias redes sociais.", + "source_tracking_enabled": "rastreamento de origem ativado", + "source_tracking_enabled_alert_description": "Ao compartilhar a partir deste diálogo, a rede social será adicionada ao link da pesquisa para que você saiba de qual rede vieram as respostas.", + "title": "Mídia Social" + } + }, "summary": { "added_filter_for_responses_where_answer_to_question": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é {filterComboBoxValue} - {filterValue} ", "added_filter_for_responses_where_answer_to_question_is_skipped": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} foi pulada", @@ -1717,52 +1767,50 @@ "configure_alerts": "Configurar alertas", "congrats": "Parabéns! Sua pesquisa está no ar.", "connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.", - "copy_link_to_public_results": "Copiar link para resultados públicos", - "create_single_use_links": "Crie links de uso único", - "create_single_use_links_description": "Aceite apenas uma submissão por link. Aqui está como.", - "current_selection_csv": "Seleção atual (CSV)", - "current_selection_excel": "Seleção atual (Excel)", "custom_range": "Intervalo personalizado...", - "data_prefilling": "preenchimento automático de dados", - "data_prefilling_description": "Quer preencher alguns campos da pesquisa? Aqui está como fazer.", - "define_when_and_where_the_survey_should_pop_up": "Defina quando e onde a pesquisa deve aparecer", + "delete_all_existing_responses_and_displays": "Excluir todas as respostas e exibições existentes", + "download_qr_code": "baixar código QR", "drop_offs": "Pontos de Entrega", "drop_offs_tooltip": "Número de vezes que a pesquisa foi iniciada mas não concluída.", - "dynamic_popup": "Dinâmico (Pop-up)", - "email_sent": "Email enviado!", - "embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!", - "embed_in_an_email": "Incorporar em um e-mail", - "embed_in_app": "Integrar no app", - "embed_mode": "Modo Embutido", - "embed_mode_description": "Incorpore sua pesquisa com um design minimalista, sem preenchimento e fundo.", - "embed_on_website": "Incorporar no site", - "embed_pop_up_survey_title": "Como incorporar uma pesquisa pop-up no seu site", - "embed_survey": "Incorporar pesquisa", - "enable_ai_insights_banner_button": "Ativar insights", - "enable_ai_insights_banner_description": "Você pode ativar o novo recurso de insights para a pesquisa e obter insights baseados em IA para suas respostas em texto aberto.", - "enable_ai_insights_banner_success": "Gerando insights para essa pesquisa. Por favor, volte em alguns minutos.", - "enable_ai_insights_banner_title": "Pronto pra testar as ideias da IA?", - "enable_ai_insights_banner_tooltip": "Por favor, entre em contato conosco pelo e-mail hola@formbricks.com para gerar insights para esta pesquisa", "failed_to_copy_link": "Falha ao copiar link", "filter_added_successfully": "Filtro adicionado com sucesso", "filter_updated_successfully": "Filtro atualizado com sucesso", - "formbricks_email_survey_preview": "Prévia da Pesquisa por E-mail do Formbricks", + "filtered_responses_csv": "Respostas filtradas (CSV)", + "filtered_responses_excel": "Respostas filtradas (Excel)", "go_to_setup_checklist": "Vai para a Lista de Configuração \uD83D\uDC49", - "hide_embed_code": "Esconder código de incorporação", - "how_to_create_a_panel": "Como criar um painel", - "how_to_create_a_panel_step_1": "Passo 1: Crie uma conta no Prolific", - "how_to_create_a_panel_step_1_description": "Cria uma conta no Prolific e verifica teu e-mail.", - "how_to_create_a_panel_step_2": "Passo 2: Crie um estudo", - "how_to_create_a_panel_step_2_description": "Na Prolific, você cria um novo estudo onde pode escolher seu público preferido com base em centenas de características.", - "how_to_create_a_panel_step_3": "Passo 3: Conecte sua pesquisa", - "how_to_create_a_panel_step_3_description": "Configure campos ocultos na sua pesquisa do Formbricks para rastrear qual participante forneceu qual resposta.", - "how_to_create_a_panel_step_4": "Passo 4: Lançar seu estudo", - "how_to_create_a_panel_step_4_description": "Depois que tudo estiver configurado, você pode iniciar seu estudo. Em algumas horas, você vai receber as primeiras respostas.", "impressions": "Impressões", "impressions_tooltip": "Número de vezes que a pesquisa foi visualizada.", + "in_app": { + "connection_description": "A pesquisa será exibida para usuários do seu site, que atendam aos critérios listados abaixo", + "connection_title": "O SDK do Formbricks está conectado", + "description": "\"As pesquisas do Formbricks podem ser embutidas como um pop-up, de acordo com a interação do usuário.\"", + "display_criteria": "Exibir critérios", + "display_criteria.audience_description": "Público-alvo", + "display_criteria.code_trigger": "Ação de Código", + "display_criteria.everyone": "Todo mundo", + "display_criteria.no_code_trigger": "Sem código", + "display_criteria.overwritten": "Sobrescrito", + "display_criteria.randomizer": "Randomizador {percentage}%", + "display_criteria.randomizer_description": "Apenas {percentage}% das pessoas que realizam a ação podem ser pesquisadas.", + "display_criteria.recontact_description": "Opções de Recontato", + "display_criteria.targeted": "direcionado", + "display_criteria.time_based_always": "Mostrar pesquisa sempre", + "display_criteria.time_based_day": "Dia", + "display_criteria.time_based_days": "Dias", + "display_criteria.time_based_description": "Tempo de espera global", + "display_criteria.trigger_description": "Gatilho de Pesquisa", + "documentation_title": "Distribua pesquisas de interceptação em todas as plataformas", + "html_embed": "HTML embutido no ", + "ios_sdk": "SDK iOS para aplicativos da Apple", + "javascript_sdk": "SDK JavaScript", + "kotlin_sdk": "SDK Kotlin para aplicativos Android", + "no_connection_description": "Conecte seu site ou app com o Formbricks para publicar pesquisas de interceptação.", + "no_connection_title": "Você ainda não tá conectado!", + "react_native_sdk": "SDK React Native para apps RN", + "title": "Configurações de interceptação de pesquisa" + }, "includes_all": "Inclui tudo", "includes_either": "Inclui ou", - "insights_disabled": "Insights desativados", "install_widget": "Instalar Widget do Formbricks", "is_equal_to": "É igual a", "is_less_than": "É menor que", @@ -1772,60 +1820,34 @@ "last_month": "Último mês", "last_quarter": "Último trimestre", "last_year": "Último ano", - "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", - "only_completed": "Somente concluído", + "no_responses_found": "Nenhuma resposta encontrada", "other_values_found": "Outros valores encontrados", "overall": "No geral", - "publish_to_web": "Publicar na web", - "publish_to_web_warning": "Você está prestes a divulgar esses resultados da pesquisa para o público.", - "publish_to_web_warning_description": "Os resultados da sua pesquisa serão públicos. Qualquer pessoa fora da sua organização pode acessá-los se tiver o link.", - "quickstart_mobile_apps": "Início rápido: Aplicativos móveis", - "quickstart_mobile_apps_description": "Para começar com pesquisas em aplicativos móveis, por favor, siga o guia de início rápido:", - "quickstart_web_apps": "Início rápido: Aplicativos web", - "quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:", - "results_are_public": "Os resultados são públicos", - "send_preview": "Enviar prévia", - "send_to_panel": "Enviar para o painel", - "setup_instructions": "Instruções de configuração", + "qr_code": "Código QR", + "qr_code_description": "Respostas coletadas via código QR são anônimas.", + "qr_code_download_failed": "falha no download do código QR", + "qr_code_download_with_start_soon": "O download do código QR começará em breve", + "qr_code_generation_failed": "Houve um problema ao carregar o Código QR do questionário. Por favor, tente novamente.", + "reset_survey": "Redefinir pesquisa", + "reset_survey_warning": "Redefinir uma pesquisa remove todas as respostas e exibições associadas a esta pesquisa. Isto não pode ser desfeito.", + "selected_responses_csv": "Respostas selecionadas (CSV)", + "selected_responses_excel": "Respostas selecionadas (Excel)", "setup_integrations": "Configurar integrações", - "share_results": "Compartilhar resultados", - "share_the_link": "Compartilha o link", - "share_the_link_to_get_responses": "Compartilha o link pra receber respostas", + "share_survey": "Compartilhar pesquisa", "show_all_responses_that_match": "Mostrar todas as respostas que correspondem", "show_all_responses_where": "Mostre todas as respostas onde...", - "single_use_links": "Links de uso único", - "source_tracking": "rastreamento de origem", - "source_tracking_description": "Rastreie a origem de forma compatível com GDPR e CCPA sem ferramentas extras.", "starts": "começa", "starts_tooltip": "Número de vezes que a pesquisa foi iniciada.", - "static_iframe": "Estático (iframe)", - "survey_results_are_public": "Os resultados da sua pesquisa são públicos!", - "survey_results_are_shared_with_anyone_who_has_the_link": "Os resultados da sua pesquisa são compartilhados com quem tiver o link. Os resultados não serão indexados por motores de busca.", + "survey_reset_successfully": "Pesquisa redefinida com sucesso! {responseCount} respostas e {displayCount} exibições foram deletadas.", "this_month": "Este mês", "this_quarter": "Este trimestre", "this_year": "Este ano", "time_to_complete": "Tempo para Concluir", - "to_connect_your_website_with_formbricks": "conectar seu site com o Formbricks", "ttc_tooltip": "Tempo médio para completar a pesquisa.", "unknown_question_type": "Tipo de pergunta desconhecido", - "unpublish_from_web": "Despublicar da web", - "unsupported_video_tag_warning": "Seu navegador não suporta a tag de vídeo.", - "view_embed_code": "Ver código incorporado", - "view_embed_code_for_email": "Ver código incorporado para e-mail", - "view_site": "Ver site", + "use_personal_links": "Use links pessoais", "waiting_for_response": "Aguardando uma resposta \uD83E\uDDD8‍♂️", - "web_app": "aplicativo web", - "what_is_a_panel": "O que é um painel?", - "what_is_a_panel_answer": "Um painel é um grupo de participantes selecionados com base em características como idade, profissão, gênero, etc.", - "what_is_prolific": "O que é Prolific?", - "what_is_prolific_answer": "Estamos fazendo parceria com a Prolific pra te dar acesso a um grupo de mais de 200.000 participantes verificados.", "whats_next": "E agora?", - "when_do_i_need_it": "Quando eu preciso disso?", - "when_do_i_need_it_answer": "Se você não tem acesso a pessoas suficientes que correspondam ao seu público-alvo, faz sentido pagar por acesso a um painel.", - "you_can_do_a_lot_more_with_links_surveys": "Você pode fazer muito mais com pesquisas de links \uD83D\uDCA1", "your_survey_is_public": "Sua pesquisa é pública", "youre_not_plugged_in_yet": "Você ainda não tá conectado!" }, @@ -1954,11 +1976,6 @@ "this_user_has_all_the_power": "Esse usuário tem todo o poder." } }, - "share": { - "back_to_home": "Voltar pra casa", - "page_not_found": "Página não encontrada", - "page_not_found_description": "Desculpa, não conseguimos encontrar as respostas com o ID que você está procurando." - }, "templates": { "address": "endereço", "address_description": "Pede um endereço pra correspondência", @@ -1969,7 +1986,6 @@ "alignment_and_engagement_survey_question_1_upper_label": "Entendimento completo", "alignment_and_engagement_survey_question_2_headline": "Sinto que meus valores estão alinhados com a missão e cultura da empresa.", "alignment_and_engagement_survey_question_2_lower_label": "Nenhum alinhamento", - "alignment_and_engagement_survey_question_2_upper_label": "Totalmente alinhado", "alignment_and_engagement_survey_question_3_headline": "Eu trabalho efetivamente com minha equipe para atingir nossos objetivos.", "alignment_and_engagement_survey_question_3_lower_label": "Colaboração ruim", "alignment_and_engagement_survey_question_3_upper_label": "Colaboração excelente", @@ -1979,7 +1995,6 @@ "book_interview": "Marcar entrevista", "build_product_roadmap_description": "Identifique a ÚNICA coisa que seus usuários mais querem e construa isso.", "build_product_roadmap_name": "Construir Roteiro do Produto", - "build_product_roadmap_name_with_project_name": "Entrada do Roadmap do $[projectName]", "build_product_roadmap_question_1_headline": "Quão satisfeito(a) você está com os recursos e funcionalidades do $[projectName]?", "build_product_roadmap_question_1_lower_label": "Nada satisfeito", "build_product_roadmap_question_1_upper_label": "Super satisfeito", @@ -2162,7 +2177,6 @@ "csat_question_7_choice_3": "Meio responsivo", "csat_question_7_choice_4": "Não tão responsivo", "csat_question_7_choice_5": "Nada responsivo", - "csat_question_7_choice_6": "Não se aplica", "csat_question_7_headline": "Quão rápido temos respondido suas perguntas sobre nossos serviços?", "csat_question_7_subheader": "Por favor, escolha uma:", "csat_question_8_choice_1": "Essa é minha primeira compra", @@ -2170,7 +2184,6 @@ "csat_question_8_choice_3": "De seis meses a um ano", "csat_question_8_choice_4": "1 - 2 anos", "csat_question_8_choice_5": "3 ou mais anos", - "csat_question_8_choice_6": "Ainda não fiz uma compra", "csat_question_8_headline": "Há quanto tempo você é cliente do $[projectName]?", "csat_question_8_subheader": "Por favor, escolha uma:", "csat_question_9_choice_1": "Muito provável", @@ -2385,7 +2398,6 @@ "identify_sign_up_barriers_question_9_dismiss_button_label": "Pular por enquanto", "identify_sign_up_barriers_question_9_headline": "Valeu! Aqui está seu código: SIGNUPNOW10", "identify_sign_up_barriers_question_9_html": "Valeu demais por tirar um tempinho pra compartilhar seu feedback \uD83D\uDE4F", - "identify_sign_up_barriers_with_project_name": "Barreiras de Cadastro do $[projectName]", "identify_upsell_opportunities_description": "Descubra quanto tempo seu produto economiza para o usuário. Use isso para fazer upsell.", "identify_upsell_opportunities_name": "Identificar Oportunidades de Upsell", "identify_upsell_opportunities_question_1_choice_1": "Menos de 1 hora", @@ -2750,7 +2762,6 @@ "site_abandonment_survey_question_6_choice_3": "Mais variedade de produtos", "site_abandonment_survey_question_6_choice_4": "Design do site melhorado", "site_abandonment_survey_question_6_choice_5": "Mais avaliações de clientes", - "site_abandonment_survey_question_6_choice_6": "outro", "site_abandonment_survey_question_6_headline": "Quais melhorias fariam você ficar mais tempo no nosso site?", "site_abandonment_survey_question_6_subheader": "Por favor, selecione todas as opções que se aplicam:", "site_abandonment_survey_question_7_headline": "Você gostaria de receber atualizações sobre novos produtos e promoções?", @@ -2781,6 +2792,8 @@ "star_rating_survey_question_3_placeholder": "Digite sua resposta aqui...", "star_rating_survey_question_3_subheader": "Ajude-nos a melhorar sua experiência.", "statement_call_to_action": "Declaração (Chamada para Ação)", + "strongly_agree": "Concordo totalmente", + "strongly_disagree": "Discordo totalmente", "supportive_work_culture_survey_description": "Avalie a percepção dos funcionários sobre o suporte da liderança, comunicação e ambiente geral de trabalho.", "supportive_work_culture_survey_name": "Cultura de Trabalho de Apoio", "supportive_work_culture_survey_question_1_headline": "Meu gestor me oferece o suporte necessário para realizar meu trabalho.", @@ -2836,6 +2849,18 @@ "understand_purchase_intention_question_2_headline": "Entendi. Qual é o principal motivo da sua visita hoje?", "understand_purchase_intention_question_2_placeholder": "Digite sua resposta aqui...", "understand_purchase_intention_question_3_headline": "O que, se é que tem algo, está te impedindo de fazer a compra hoje?", - "understand_purchase_intention_question_3_placeholder": "Digite sua resposta aqui..." + "understand_purchase_intention_question_3_placeholder": "Digite sua resposta aqui...", + "usability_question_10_headline": "Tive que aprender muito antes de poder começar a usar o sistema corretamente.", + "usability_question_1_headline": "Provavelmente eu usaria este sistema frequentemente.", + "usability_question_2_headline": "O sistema parecia mais complicado do que precisava ser.", + "usability_question_3_headline": "O sistema foi fácil de entender.", + "usability_question_4_headline": "Acho que precisaria da ajuda de um especialista em tecnologia para usar este sistema.", + "usability_question_5_headline": "Tudo no sistema parecia funcionar bem juntos.", + "usability_question_6_headline": "O sistema parecia inconsistente em como as coisas funcionavam.", + "usability_question_7_headline": "Eu acho que a maioria das pessoas poderia aprender a usar este sistema rapidamente.", + "usability_question_8_headline": "Usar o sistema foi uma dor de cabeça.", + "usability_question_9_headline": "Me senti confiante ao usar o sistema.", + "usability_rating_description": "Meça a usabilidade percebida perguntando aos usuários para avaliar sua experiência com seu produto usando uma pesquisa padronizada de 10 perguntas.", + "usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)" } } diff --git a/packages/lib/messages/pt-PT.json b/apps/web/locales/pt-PT.json similarity index 90% rename from packages/lib/messages/pt-PT.json rename to apps/web/locales/pt-PT.json index 8fe2fbba5aa3..6a384b729bed 100644 --- a/packages/lib/messages/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -1,12 +1,23 @@ { "auth": { - "continue_with_azure": "Continuar com Azure", + "continue_with_azure": "Continuar com Microsoft", "continue_with_email": "Continuar com Email", "continue_with_github": "Continuar com GitHub", "continue_with_google": "Continuar com Google", "continue_with_oidc": "Continuar com {oidcDisplayName}", "continue_with_openid": "Continuar com OpenID", "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_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" + }, "forgot-password": { "back_to_login": "Voltar ao login", "email-sent": { @@ -23,7 +34,8 @@ "text": "Pode agora iniciar sessão com a sua nova palavra-passe" } }, - "reset_password": "Redefinir palavra-passe" + "reset_password": "Redefinir palavra-passe", + "reset_password_description": "Será desconectado para redefinir a sua palavra-passe." }, "invite": { "create_account": "Criar uma conta", @@ -68,7 +80,7 @@ }, "signup_without_verification_success": { "user_successfully_created": "Utilizador criado com sucesso", - "user_successfully_created_description": "O seu novo utilizador foi criado com sucesso. Por favor, clique no botão abaixo e inicie sessão na sua conta." + "user_successfully_created_info": "Verificámos a existência de uma conta associada a {email}. Se não existia, criámos uma para si. Se já existia uma conta, não foram feitas alterações. Por favor, inicie sessão abaixo para continuar." }, "testimonial_1": "Medimos a clareza dos nossos documentos e aprendemos com a rotatividade, tudo numa só plataforma. Ótimo produto, equipa muito responsiva!", "testimonial_all_features_included": "Todas as funcionalidades incluídas", @@ -78,12 +90,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 com sucesso. Por favor, verifique a sua caixa de entrada.", - "we_sent_an_email_to": "Enviámos um email para {email}. ", + "verification_email_resent_successfully": "Email de verificação enviado! Por favor, verifique a sua caixa de entrada.", + "verification_email_successfully_sent_info": "Se houver uma conta associada a {email}, enviámos um link de verificação para esse endereço. Por favor, verifique a sua caixa de entrada para completar o registo.", "you_didnt_receive_an_email_or_your_link_expired": "Não recebeu um email ou o seu link expirou?" }, "verify": { @@ -96,6 +108,10 @@ "thanks_for_upgrading": "Muito obrigado por atualizar a sua subscrição do Formbricks.", "upgrade_successful": "Atualização bem-sucedida" }, + "c": { + "link_expired": "O seu link expirou.", + "link_expired_description": "O link que utilizou já não é válido." + }, "common": { "accepted": "Aceite", "account": "Conta", @@ -108,6 +124,7 @@ "add_action": "Adicionar ação", "add_filter": "Adicionar filtro", "add_logo": "Adicionar logótipo", + "add_member": "Adicionar membro", "add_project": "Adicionar projeto", "add_to_team": "Adicionar à equipa", "all": "Todos", @@ -123,7 +140,6 @@ "app_survey": "Inquérito da Aplicação", "apply_filters": "Aplicar filtros", "are_you_sure": "Tem a certeza?", - "are_you_sure_this_action_cannot_be_undone": "Tem a certeza? Esta ação não pode ser desfeita.", "attributes": "Atributos", "avatar": "Avatar", "back": "Voltar", @@ -149,11 +165,13 @@ "connect_formbricks": "Ligar Formbricks", "connected": "Conectado", "contacts": "Contactos", + "copied": "Copiado", "copied_to_clipboard": "Copiado para a área de transferência", "copy": "Copiar", "copy_code": "Copiar código", "copy_link": "Copiar Link", "create_new_organization": "Criar nova organização", + "create_project": "Criar projeto", "create_segment": "Criar segmento", "create_survey": "Criar inquérito", "created": "Criado", @@ -180,13 +198,10 @@ "e_commerce": "Comércio Eletrónico", "edit": "Editar", "email": "Email", - "embed": "Incorporar", "enterprise_license": "Licença Enterprise", "environment_not_found": "Ambiente não encontrado", "environment_notice": "Está atualmente no ambiente {environment}.", "error": "Erro", - "error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.", - "error_component_title": "Erro ao carregar recursos", "expand_rows": "Expandir linhas", "finish": "Concluir", "follow_these": "Siga estes", @@ -209,7 +224,6 @@ "in_progress": "Em Progresso", "inactive_surveys": "Inquéritos inativos", "input_type": "Tipo de entrada", - "insights": "Informações", "integration": "integração", "integrations": "Integrações", "invalid_date": "Data inválida", @@ -225,7 +239,6 @@ "limits_reached": "Limites Atingidos", "link": "Link", "link_and_email": "Link e Email", - "link_copied": "Link copiado para a área de transferência!", "link_survey": "Ligar Inquérito", "link_surveys": "Ligar Inquéritos", "load_more": "Carregar mais", @@ -246,8 +259,6 @@ "move_up": "Mover para cima", "multiple_languages": "Várias línguas", "name": "Nome", - "negative": "Negativo", - "neutral": "Neutro", "new": "Novo", "new_survey": "Novo inquérito", "new_version_available": "Formbricks {version} está aqui. Atualize agora!", @@ -269,6 +280,8 @@ "on": "Ligado", "only_one_file_allowed": "Apenas um ficheiro é permitido", "only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.", + "option_id": "ID de Opção", + "option_ids": "IDs de Opção", "or": "ou", "organization": "Organização", "organization_id": "ID da Organização", @@ -285,32 +298,34 @@ "phone": "Telefone", "photo_by": "Foto de", "pick_a_date": "Escolha uma data", + "picture": "Imagem", "placeholder": "Espaço reservado", "please_select_at_least_one_survey": "Por favor, selecione pelo menos um inquérito", "please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho", "please_upgrade_your_plan": "Por favor, atualize o seu plano.", - "positive": "Positivo", "preview": "Pré-visualização", "preview_survey": "Pré-visualização do inquérito", "privacy": "Política de Privacidade", - "privacy_policy": "Política de Privacidade", "product_manager": "Gestor de Produto", "profile": "Perfil", - "project": "Projeto", + "profile_id": "ID do Perfil", "project_configuration": "Configuração do Projeto", + "project_creation_description": "Organize questionários em projetos para um melhor controlo de acesso.", "project_id": "ID do Projeto", "project_name": "Nome do Projeto", + "project_name_placeholder": "por exemplo, Formbricks", "project_not_found": "Projeto não encontrado", "project_permission_not_found": "Permissão do projeto não encontrada", "projects": "Projetos", - "projects_limit_reached": "Limite de projetos atingido", "question": "Pergunta", "question_id": "ID da pergunta", "questions": "Perguntas", "read_docs": "Ler Documentos", + "recipients": "Destinatários", "remove": "Remover", "reorder_and_hide_columns": "Reordenar e ocultar colunas", "report_survey": "Relatório de Inquérito", + "request_pricing": "Pedido de Preços", "request_trial_license": "Solicitar licença de teste", "reset_to_default": "Repor para o padrão", "response": "Resposta", @@ -330,6 +345,7 @@ "select": "Selecionar", "select_all": "Selecionar tudo", "select_survey": "Selecionar Inquérito", + "select_teams": "Selecionar equipas", "selected": "Selecionado", "selected_questions": "Perguntas selecionadas", "selection": "Seleção", @@ -346,6 +362,7 @@ "skipped": "Ignorado", "skips": "Saltos", "some_files_failed_to_upload": "Alguns ficheiros falharam ao carregar", + "something_went_wrong": "Algo correu mal", "something_went_wrong_please_try_again": "Algo correu mal. Por favor, tente novamente.", "sort_by": "Ordenar por", "start_free_trial": "Iniciar Teste Grátis", @@ -372,6 +389,7 @@ "targeting": "Segmentação", "team": "Equipa", "team_access": "Acesso da Equipa", + "team_id": "ID da Equipa", "team_name": "Nome da equipa", "teams": "Controlo de Acesso", "teams_not_found": "Equipas não encontradas", @@ -404,9 +422,7 @@ "website_and_app_connection": "Ligação de Website e Aplicação", "website_app_survey": "Inquérito do Website e da Aplicação", "website_survey": "Inquérito do Website", - "weekly_summary": "Resumo semanal", "welcome_card": "Cartão de boas-vindas", - "yes": "Sim", "you": "Você", "you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.", "you_are_not_authorised_to_perform_this_action": "Não está autorizado para realizar esta ação.", @@ -446,41 +462,13 @@ "invite_email_text_par1": "O seu colega", "invite_email_text_par2": "convidou-o a juntar-se a eles no Formbricks. Para aceitar o convite, por favor clique no link abaixo:", "invite_member_email_subject": "Está convidado a colaborar no Formbricks!", - "live_survey_notification_completed": "Concluído", - "live_survey_notification_draft": "Rascunho", - "live_survey_notification_in_progress": "Em Progresso", - "live_survey_notification_no_new_response": "Nenhuma nova resposta recebida esta semana \uD83D\uDD75️", - "live_survey_notification_no_responses_yet": "Ainda sem respostas!", - "live_survey_notification_paused": "Pausado", - "live_survey_notification_scheduled": "Agendado", - "live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas", - "live_survey_notification_view_previous_responses": "Ver respostas anteriores", - "live_survey_notification_view_response": "Ver Resposta", - "notification_footer_all_the_best": "Tudo de bom,", - "notification_footer_in_your_settings": "nas suas definições \uD83D\uDE4F", - "notification_footer_please_turn_them_off": "por favor, desative-os", - "notification_footer_the_formbricks_team": "A Equipa Formbricks \uD83E\uDD0D", - "notification_footer_to_halt_weekly_updates": "Para parar as Atualizações Semanais,", - "notification_header_hey": "Olá \uD83D\uDC4B", - "notification_header_weekly_report_for": "Relatório Semanal para", - "notification_insight_completed": "Concluído", - "notification_insight_completion_rate": "Conclusão %", - "notification_insight_displays": "Ecrãs", - "notification_insight_responses": "Respostas", - "notification_insight_surveys": "Inquéritos", - "onboarding_invite_email_button_label": "Junte-se à organização de {inviterName}", - "onboarding_invite_email_connect_formbricks": "Conecte o Formbricks à sua aplicação ou website através de um Snippet HTML ou NPM em apenas alguns minutos.", - "onboarding_invite_email_create_account": "Crie uma conta para se juntar à organização de {inviterName}.", - "onboarding_invite_email_done": "Concluído ✅", - "onboarding_invite_email_get_started_in_minutes": "Começar em Minutos", - "onboarding_invite_email_heading": "Olá ", - "onboarding_invite_email_subject": "{inviterName} precisa de ajuda para configurar o Formbricks. Podes ajudar?", + "new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:", "password_changed_email_heading": "Palavra-passe alterada", "password_changed_email_text": "A sua palavra-passe foi alterada com sucesso.", "password_reset_notify_email_subject": "A sua palavra-passe do Formbricks foi alterada", - "powered_by_formbricks": "Desenvolvido por Formbricks", "privacy_policy": "Política de Privacidade", "reject": "Rejeitar", + "render_email_response_value_file_upload_response_link_not_included": "O link para o ficheiro carregado não está incluído por razões de privacidade de dados", "response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅", "response_finished_email_subject_with_email": "{personEmail} acabou de completar o seu inquérito {surveyName} ✅", "schedule_your_meeting": "Agende a sua reunião", @@ -505,14 +493,9 @@ "verification_email_thanks": "Obrigado por validar o seu email!", "verification_email_to_fill_survey": "Para preencher o questionário, clique no botão abaixo:", "verification_email_verify_email": "Verificar email", - "verified_link_survey_email_subject": "O seu inquérito está pronto para ser preenchido.", - "weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um intervalo de 15 minutos no calendário do nosso CEO", - "weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe passar uma semana sem aprender sobre os seus utilizadores:", - "weekly_summary_create_reminder_notification_body_need_help": "Precisa de ajuda para encontrar o inquérito certo para o seu produto?", - "weekly_summary_create_reminder_notification_body_reply_email": "ou responda a este email :)", - "weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurar um novo inquérito", - "weekly_summary_create_reminder_notification_body_text": "Gostaríamos de lhe enviar um Resumo Semanal, mas de momento não há inquéritos a decorrer para {projectName}.", - "weekly_summary_email_subject": "{projectName} Informações do Utilizador - Última Semana por Formbricks" + "verification_new_email_subject": "Verificação de alteração de email", + "verification_security_notice": "Se não solicitou esta alteração de email, ignore este email ou contacte o suporte imediatamente.", + "verified_link_survey_email_subject": "O seu inquérito está pronto para ser preenchido." }, "environments": { "actions": { @@ -525,21 +508,21 @@ "action_with_key_already_exists": "Ação com a chave {key} já existe", "action_with_name_already_exists": "Ação com o nome {name} já existe", "add_css_class_or_id": "Adicionar classe ou id CSS", + "add_regular_expression_here": "Adicione uma expressão regular aqui", "add_url": "Adicionar URL", "click": "Clique", "contains": "Contém", "create_action": "Criar ação", "css_selector": "Seletor CSS", "delete_action_text": "Tem a certeza de que deseja eliminar esta ação? Isto também remove esta ação como um gatilho de todos os seus inquéritos.", - "display_name": "Nome de exibição", "does_not_contain": "Não contém", "does_not_exactly_match": "Não corresponde exatamente", "eg_clicked_download": "Por exemplo, Clicou em Descarregar", "eg_download_cta_click_on_home": "por exemplo, descarregar_cta_clicar_em_home", "eg_install_app": "Ex. Instalar App", - "eg_user_clicked_download_button": "Por exemplo, Utilizador clicou no Botão Descarregar", "ends_with": "Termina com", "enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Introduza um URL para ver se um utilizador que o visita seria rastreado.", + "enter_url": "por exemplo, https://app.com/dashboard", "exactly_matches": "Corresponde exatamente", "exit_intent": "Intenção de Saída", "fifty_percent_scroll": "Rolar 50%", @@ -548,9 +531,14 @@ "if_a_user_clicks_a_button_with_a_specific_text": "Se um utilizador clicar num botão com um texto específico", "in_your_code_read_more_in_our": "no seu código. Leia mais no nosso", "inner_text": "Texto Interno", + "invalid_action_type_code": "Tipo de ação inválido para ação de código", + "invalid_action_type_no_code": "Tipo de ação inválido para ação noCode", "invalid_css_selector": "Seletor CSS inválido", + "invalid_match_type": "A opção selecionada não está disponível.", + "invalid_regex": "Por favor, utilize uma expressão regular válida.", "limit_the_pages_on_which_this_action_gets_captured": "Limitar as páginas nas quais esta ação é capturada", "limit_to_specific_pages": "Limitar a páginas específicas", + "matches_regex": "Coincide com regex", "on_all_pages": "Em todas as páginas", "page_filter": "Filtro de página", "page_view": "Visualização de Página", @@ -570,7 +558,9 @@ "user_clicked_download_button": "Utilizador clicou no Botão Descarregar", "what_did_your_user_do": "O que fez o seu utilizador?", "what_is_the_user_doing": "O que está o utilizador a fazer?", - "you_can_track_code_action_anywhere_in_your_app_using": "Pode rastrear a ação do código em qualquer lugar na sua aplicação usando" + "you_can_track_code_action_anywhere_in_your_app_using": "Pode rastrear a ação do código em qualquer lugar na sua aplicação usando", + "your_survey_would_be_shown_on_this_url": "O seu inquérito seria mostrado neste URL.", + "your_survey_would_not_be_shown": "O seu inquérito não seria mostrado." }, "connect": { "congrats": "Parabéns!", @@ -587,8 +577,8 @@ "contact_deleted_successfully": "Contacto eliminado com sucesso", "contact_not_found": "Nenhum contacto encontrado", "contacts_table_refresh": "Atualizar contactos", - "contacts_table_refresh_error": "Algo correu mal ao atualizar os contactos, por favor, tente novamente", "contacts_table_refresh_success": "Contactos atualizados com sucesso", + "delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.", "first_name": "Primeiro Nome", "last_name": "Apelido", "no_responses_found": "Nenhuma resposta encontrada", @@ -616,33 +606,6 @@ "upload_contacts_modal_preview": "Aqui está uma pré-visualização dos seus dados.", "upload_contacts_modal_upload_btn": "Carregar contactos" }, - "experience": { - "all": "Todos", - "all_time": "Todo o tempo", - "analysed_feedbacks": "Respostas de Texto Livre Analisadas", - "category": "Categoria", - "category_updated_successfully": "Categoria atualizada com sucesso!", - "complaint": "Queixa", - "did_you_find_this_insight_helpful": "Achou esta informação útil?", - "failed_to_update_category": "Falha ao atualizar a categoria", - "feature_request": "Pedido", - "good_afternoon": "\uD83C\uDF24️ Boa tarde", - "good_evening": "\uD83C\uDF19 Boa noite", - "good_morning": "☀️ Bom dia", - "insights_description": "Todos os insights gerados a partir das respostas de todos os seus inquéritos", - "insights_for_project": "Informações sobre {projectName}", - "new_responses": "Respostas", - "no_insights_for_this_filter": "Sem informações para este filtro", - "no_insights_found": "Não foram encontradas informações. Recolha mais respostas ao inquérito ou ative informações para os seus inquéritos existentes para começar.", - "praise": "Elogio", - "sentiment_score": "Pontuação de Sentimento", - "templates_card_description": "Escolha um modelo ou comece do zero", - "templates_card_title": "Meça a experiência do seu cliente", - "this_month": "Este mês", - "this_quarter": "Este trimestre", - "this_week": "Esta semana", - "today": "Hoje" - }, "formbricks_logo": "Logotipo do Formbricks", "integrations": { "activepieces_integration_description": "Conecte instantaneamente o Formbricks com apps populares para automatizar tarefas sem codificação.", @@ -652,6 +615,7 @@ "airtable_integration": "Integração com o Airtable", "airtable_integration_description": "Sincronize respostas diretamente com o Airtable.", "airtable_integration_is_not_configured": "A integração com o Airtable não está configurada", + "airtable_logo": "logotipo Airtable", "connect_with_airtable": "Ligar ao Airtable", "link_airtable_table": "Ligar Tabela Airtable", "link_new_table": "Ligar nova tabela", @@ -719,7 +683,6 @@ "select_a_database": "Selecionar Base de Dados", "select_a_field_to_map": "Selecione um campo para mapear", "select_a_survey_question": "Selecione uma pergunta do inquérito", - "sync_responses_with_a_notion_database": "Sincronizar respostas com uma Base de Dados do Notion", "update_connection": "Reconectar Notion", "update_connection_tooltip": "Restabeleça a integração para incluir as bases de dados recentemente adicionadas. As suas integrações existentes permanecerão intactas." }, @@ -741,6 +704,7 @@ "slack_integration": "Integração com Slack", "slack_integration_description": "Enviar respostas diretamente para o Slack.", "slack_integration_is_not_configured": "A integração com o Slack não está configurada na sua instância do Formbricks.", + "slack_logo": "Logótipo Slack", "slack_reconnect_button": "Reconectar", "slack_reconnect_button_description": "Nota: Recentemente alterámos a nossa integração com o Slack para também suportar canais privados. Por favor, reconecte o seu espaço de trabalho do Slack." }, @@ -777,6 +741,7 @@ }, "project": { "api_keys": { + "access_control": "Controlo de Acesso", "add_api_key": "Adicionar Chave API", "api_key": "Chave API", "api_key_copied_to_clipboard": "Chave API copiada para a área de transferência", @@ -784,9 +749,12 @@ "api_key_deleted": "Chave API eliminada", "api_key_label": "Etiqueta da Chave API", "api_key_security_warning": "Por razões de segurança, a chave API será mostrada apenas uma vez após a criação. Por favor, copie-a para o seu destino imediatamente.", + "api_key_updated": "Chave API atualizada", "duplicate_access": "Acesso duplicado ao projeto não permitido", "no_api_keys_yet": "Ainda não tem nenhuma chave API", + "no_env_permissions_found": "Nenhuma permissão de ambiente encontrada", "organization_access": "Acesso à Organização", + "organization_access_description": "Selecione privilégios de leitura ou escrita para recursos de toda a organização.", "permissions": "Permissões", "project_access": "Acesso ao Projeto", "secret": "Segredo", @@ -796,6 +764,8 @@ "api_host_description": "Este é o URL do seu backend Formbricks.", "app_connection": "Ligação de Aplicação", "app_connection_description": "Ligue a sua aplicação ao Formbricks", + "cache_update_delay_description": "Quando fizer atualizações para inquéritos, contactos, ações ou outros dados, pode demorar até 5 minutos para que essas alterações apareçam na sua aplicação local a correr o SDK do Formbricks. Este atraso deve-se a uma limitação no nosso atual sistema de cache. Estamos a trabalhar ativamente na reformulação da cache e lançaremos uma correção no Formbricks 4.0.", + "cache_update_delay_title": "As alterações serão refletidas após 5 minutos devido ao armazenamento em cache.", "check_out_the_docs": "Consulte a documentação.", "dive_into_the_docs": "Mergulhe na documentação.", "does_your_widget_work": "O seu widget funciona?", @@ -921,8 +891,7 @@ "tag_already_exists": "A etiqueta já existe", "tag_deleted": "Etiqueta eliminada", "tag_updated": "Etiqueta atualizada", - "tags_merged": "Etiquetas fundidas", - "unique_constraint_failed_on_the_fields": "A restrição de unicidade falhou nos campos" + "tags_merged": "Etiquetas fundidas" }, "teams": { "manage_teams": "Gerir equipas", @@ -970,6 +939,7 @@ "save_your_filters_as_a_segment_to_use_it_in_other_surveys": "Guarde os seus filtros como um Segmento para usá-los noutros questionários", "segment_created_successfully": "Segmento criado com sucesso!", "segment_deleted_successfully": "Segmento eliminado com sucesso!", + "segment_id": "ID do Segmento", "segment_saved_successfully": "Segmento guardado com sucesso", "segment_updated_successfully": "Segmento atualizado com sucesso!", "segments_help_you_target_users_with_same_characteristics_easily": "Os segmentos ajudam-no a direcionar utilizadores com as mesmas características facilmente", @@ -991,67 +961,56 @@ "api_keys": { "add_api_key": "Adicionar chave API", "add_permission": "Adicionar permissão", - "api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks", - "only_organization_owners_and_managers_can_manage_api_keys": "Apenas os proprietários e gestores da organização podem gerir chaves API" + "api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks" }, "billing": { - "10000_monthly_responses": "10000 Respostas Mensais", - "1500_monthly_responses": "1500 Respostas Mensais", - "2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente", - "30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente", + "1000_monthly_responses": "1000 Respostas Mensais", + "1_project": "1 Projeto", + "2000_contacts": "2,000 Contactos", "3_projects": "3 Projetos", - "5000_monthly_responses": "5000 Respostas Mensais", - "5_projects": "5 Projetos", - "7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente", - "advanced_targeting": "Segmentação Avançada", + "5000_monthly_responses": "5,000 Respostas Mensais", + "7500_contacts": "7,500 Contactos", "all_integrations": "Todas as Integrações", - "all_surveying_features": "Todas as funcionalidades de inquérito", "annually": "Anualmente", "api_webhooks": "API e Webhooks", "app_surveys": "Inquéritos da Aplicação", - "contact_us": "Contacte-nos", + "attribute_based_targeting": "Segmentação Baseada em Atributos", "current": "Atual", "current_plan": "Plano Atual", "current_tier_limit": "Limite Atual do Nível", - "custom_miu_limit": "Limite MIU Personalizado", + "custom": "Personalizado e Escala", + "custom_contacts_limit": "Limite de Contactos Personalizado", "custom_project_limit": "Limite de Projeto Personalizado", - "customer_success_manager": "Gestor de Sucesso do Cliente", + "custom_response_limit": "Limite de Resposta Personalizado", "email_embedded_surveys": "Inquéritos Incorporados no Email", - "email_support": "Suporte por Email", - "enterprise": "Empresa", + "email_follow_ups": "Acompanhamentos por Email", "enterprise_description": "Suporte premium e limites personalizados.", "everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!", "everything_in_free": "Tudo em Gratuito", - "everything_in_scale": "Tudo em Escala", "everything_in_startup": "Tudo em Startup", "free": "Grátis", "free_description": "Inquéritos ilimitados, membros da equipa e mais.", "get_2_months_free": "Obtenha 2 meses grátis", "get_in_touch": "Entre em contacto", + "hosted_in_frankfurt": "Hospedado em Frankfurt", + "ios_android_sdks": "SDK iOS e Android para inquéritos móveis", "link_surveys": "Ligar Inquéritos (Partilhável)", "logic_jumps_hidden_fields_recurring_surveys": "Saltos Lógicos, Campos Ocultos, Inquéritos Recorrentes, etc.", "manage_card_details": "Gerir Detalhes do Cartão", "manage_subscription": "Gerir Subscrição", "monthly": "Mensal", "monthly_identified_users": "Utilizadores Identificados Mensalmente", - "multi_language_surveys": "Inquéritos Multilingues", "per_month": "por mês", "per_year": "por ano", "plan_upgraded_successfully": "Plano atualizado com sucesso", "premium_support_with_slas": "Suporte premium com SLAs", - "priority_support": "Suporte Prioritário", "remove_branding": "Remover Marca", - "say_hi": "Diga Olá!", - "scale": "Escala", - "scale_description": "Funcionalidades avançadas para escalar o seu negócio.", "startup": "Inicialização", "startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.", "switch_plan": "Mudar Plano", "switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.", "team_access_roles": "Funções de Acesso da Equipa", - "technical_onboarding": "Integração Técnica", "unable_to_upgrade_plan": "Não é possível atualizar o plano", - "unlimited_apps_websites": "Aplicações e Websites Ilimitados", "unlimited_miu": "MIU Ilimitado", "unlimited_projects": "Projetos Ilimitados", "unlimited_responses": "Respostas Ilimitadas", @@ -1062,7 +1021,6 @@ "website_surveys": "Inquéritos do Website" }, "enterprise": { - "ai": "Análise de IA", "audit_logs": "Registos de Auditoria", "coming_soon": "Em breve", "contacts_and_segments": "Gestão de contactos e segmentos", @@ -1091,6 +1049,7 @@ "create_new_organization": "Criar nova organização", "create_new_organization_description": "Crie uma nova organização para gerir um conjunto diferente de projetos.", "customize_email_with_a_higher_plan": "Personalize o e-mail com um plano superior", + "delete_member_confirmation": "Membros eliminados perderão acesso a todos os projetos e inquéritos da sua organização.", "delete_organization": "Eliminar Organização", "delete_organization_description": "Eliminar organização com todos os seus projetos, incluindo todos os inquéritos, respostas, pessoas, ações e atributos", "delete_organization_warning": "Antes de prosseguir com a eliminação desta organização, esteja ciente das seguintes consequências:", @@ -1100,13 +1059,7 @@ "eliminate_branding_with_whitelabel": "Elimine a marca Formbricks e ative opções adicionais de personalização de marca branca.", "email_customization_preview_email_heading": "Olá {userName}", "email_customization_preview_email_text": "Esta é uma pré-visualização de email para mostrar qual logotipo será exibido nos emails.", - "enable_formbricks_ai": "Ativar Formbricks IA", "error_deleting_organization_please_try_again": "Erro ao eliminar a organização. Por favor, tente novamente.", - "formbricks_ai": "Formbricks IA", - "formbricks_ai_description": "Obtenha informações personalizadas das suas respostas aos inquéritos com o Formbricks IA", - "formbricks_ai_disable_success_message": "Formbricks AI desativado com sucesso.", - "formbricks_ai_enable_success_message": "Formbricks IA ativado com sucesso.", - "formbricks_ai_privacy_policy_text": "Ao ativar o Formbricks AI, você concorda com a atualização", "from_your_organization": "da sua organização", "invitation_sent_once_more": "Convite enviado mais uma vez.", "invite_deleted_successfully": "Convite eliminado com sucesso", @@ -1153,10 +1106,8 @@ "need_slack_or_discord_notifications": "Precisa de notificações do Slack ou Discord", "notification_settings_updated": "Definições de notificações atualizadas", "set_up_an_alert_to_get_an_email_on_new_responses": "Configurar um alerta para receber um e-mail sobre novas respostas", - "stay_up_to_date_with_a_Weekly_every_Monday": "Mantenha-se atualizado com um Resumo semanal todas as segundas-feiras", "use_the_integration": "Use a integração", "want_to_loop_in_organization_mates": "Quer incluir colegas da organização", - "weekly_summary_projects": "Resumo semanal (Projetos)", "you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Já não será automaticamente subscrito aos inquéritos desta organização!", "you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Não receberá mais emails para respostas a este inquérito!" }, @@ -1172,6 +1123,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.", @@ -1252,8 +1204,9 @@ "copy_survey_description": "Copiar este questionário para outro ambiente", "copy_survey_error": "Falha ao copiar inquérito", "copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência", + "copy_survey_partially_success": "{success} inquéritos copiados com sucesso, {error} falharam.", "copy_survey_success": "Inquérito copiado com sucesso!", - "delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas? Esta ação não pode ser desfeita.", + "delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas?", "edit": { "1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para este inquérito:", "2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:", @@ -1273,6 +1226,8 @@ "add_description": "Adicionar descrição", "add_ending": "Adicionar encerramento", "add_ending_below": "Adicionar encerramento abaixo", + "add_fallback": "Adicionar", + "add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se a pergunta for ignorada:", "add_hidden_field_id": "Adicionar ID do campo oculto", "add_highlight_border": "Adicionar borda de destaque", "add_highlight_border_description": "Adicione uma borda externa ao seu cartão de inquérito.", @@ -1311,8 +1266,6 @@ "automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Lançar automaticamente o inquérito no início do dia (UTC).", "back_button_label": "Rótulo do botão \"Voltar\"", "background_styling": "Estilo de Fundo", - "blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloqueia o inquérito se já existir uma submissão com o Id de Uso Único (suId).", - "blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloqueia o inquérito se o URL do inquérito não tiver um Id de Uso Único (suId).", "brand_color": "Cor da marca", "brightness": "Brilho", "button_label": "Rótulo do botão", @@ -1326,14 +1279,21 @@ "card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}", "card_background_color": "Cor de fundo do cartão", "card_border_color": "Cor da borda do cartão", - "card_shadow_color": "Cor da sombra do cartão", "card_styling": "Estilo do cartão", "casual": "Casual", + "caution_edit_duplicate": "Duplicar e editar", + "caution_edit_published_survey": "Editar um inquérito publicado?", + "caution_explanation_intro": "Entendemos que ainda pode querer fazer alterações. Eis o que acontece se o fizer:", + "caution_explanation_new_responses_separated": "Respostas antes da alteração podem não estar incluídas ou estar apenas parcialmente incluídas no resumo do inquérito.", + "caution_explanation_only_new_responses_in_summary": "Todos os dados, incluindo respostas anteriores, permanecem disponíveis para download na página de resumo do inquérito.", + "caution_explanation_responses_are_safe": "As respostas mais antigas e mais recentes se misturam, o que pode levar a resumos de dados enganosos.", + "caution_recommendation": "Isso pode causar inconsistências de dados no resumo do inquérito. Recomendamos duplicar o inquérito em vez disso.", "caution_text": "As alterações levarão a inconsistências", "centered_modal_overlay_color": "Cor da sobreposição modal centralizada", "change_anyway": "Alterar mesmo assim", "change_background": "Alterar fundo", "change_question_type": "Alterar tipo de pergunta", + "change_survey_type": "Alterar o tipo de inquérito afeta o acesso existente", "change_the_background_color_of_the_card": "Alterar a cor de fundo do cartão", "change_the_background_color_of_the_input_fields": "Alterar a cor de fundo dos campos de entrada", "change_the_background_to_a_color_image_or_animation": "Altere o fundo para uma cor, imagem ou animação", @@ -1343,8 +1303,8 @@ "change_the_brand_color_of_the_survey": "Alterar a cor da marca do inquérito", "change_the_placement_of_this_survey": "Alterar a colocação deste inquérito.", "change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito", - "change_the_shadow_color_of_the_card": "Alterar a cor da sombra do cartão.", "changes_saved": "Alterações guardadas.", + "changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de inquérito afetará como ele pode ser partilhado. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.", "character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.", "character_limit_toggle_title": "Adicionar limites de caracteres", "checkbox_label": "Rótulo da Caixa de Seleção", @@ -1354,10 +1314,11 @@ "close_survey_on_date": "Encerrar inquérito na data", "close_survey_on_response_limit": "Fechar inquérito no limite de respostas", "color": "Cor", + "column_used_in_logic_error": "Esta coluna é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.", "columns": "Colunas", "company": "Empresa", "company_logo": "Logotipo da empresa", - "completed_responses": "respostas concluídas", + "completed_responses": "respostas parciais ou completas", "concat": "Concatenar +", "conditional_logic": "Lógica Condicional", "confirm_default_language": "Confirmar idioma padrão", @@ -1391,8 +1352,9 @@ "does_not_start_with": "Não começa com", "edit_recall": "Editar Lembrete", "edit_translations": "Editar traduções {lang}", - "enable_encryption_of_single_use_id_suid_in_survey_url": "Ativar encriptação do Id de Uso Único (suId) no URL do inquérito.", "enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.", + "enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.", + "enable_spam_protection": "Proteção contra spam", "end_screen_card": "Cartão de ecrã final", "ending_card": "Cartão de encerramento", "ending_card_used_in_logic": "Este cartão final é usado na lógica da pergunta {questionIndex}.", @@ -1403,6 +1365,7 @@ "error_saving_changes": "Erro ao guardar alterações", "even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de terem enviado uma resposta (por exemplo, Caixa de Feedback)", "everyone": "Todos", + "fallback_for": "Alternativa para ", "fallback_missing": "Substituição em falta", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.", "field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço", @@ -1420,6 +1383,8 @@ "follow_ups_item_issue_detected_tag": "Problema detetado", "follow_ups_item_response_tag": "Qualquer resposta", "follow_ups_item_send_email_tag": "Enviar email", + "follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta do inquérito ao acompanhamento", + "follow_ups_modal_action_attach_response_data_label": "Anexar dados de resposta", "follow_ups_modal_action_body_label": "Corpo", "follow_ups_modal_action_body_placeholder": "Corpo do email", "follow_ups_modal_action_email_content": "Conteúdo do email", @@ -1450,9 +1415,6 @@ "follow_ups_new": "Novo acompanhamento", "follow_ups_upgrade_button_text": "Atualize para ativar os acompanhamentos", "form_styling": "Estilo do formulário", - "formbricks_ai_description": "Descreva o seu inquérito e deixe a Formbricks AI criar o inquérito para si", - "formbricks_ai_generate": "Gerar", - "formbricks_ai_prompt_placeholder": "Introduza as informações do inquérito (por exemplo, tópicos principais a abordar)", "formbricks_sdk_is_not_connected": "O SDK do Formbricks não está conectado", "four_points": "4 pontos", "heading": "Cabeçalho", @@ -1465,7 +1427,6 @@ "hide_the_logo_in_this_specific_survey": "Ocultar o logótipo neste inquérito específico", "hostname": "Nome do host", "how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão extravagantes quer os seus cartões em Inquéritos {surveyTypeDerived}", - "how_it_works": "Como funciona", "if_you_need_more_please": "Se precisar de mais, por favor", "if_you_really_want_that_answer_ask_until_you_get_it": "Se realmente quiser essa resposta, pergunte até obtê-la.", "ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre inquéritos", @@ -1481,10 +1442,13 @@ "invalid_youtube_url": "URL do YouTube inválido", "is_accepted": "É aceite", "is_after": "É depois", + "is_any_of": "É qualquer um de", "is_before": "É antes", "is_booked": "Está reservado", "is_clicked": "É clicado", "is_completely_submitted": "Está completamente submetido", + "is_empty": "Está vazio", + "is_not_empty": "Não está vazio", "is_not_set": "Não está definido", "is_partially_submitted": "Está parcialmente submetido", "is_set": "Está definido", @@ -1500,7 +1464,6 @@ "limit_the_maximum_file_size": "Limitar o tamanho máximo do ficheiro", "limit_upload_file_size_to": "Limitar tamanho do ficheiro carregado a", "link_survey_description": "Partilhe um link para uma página de inquérito ou incorpore-o numa página web ou email.", - "link_used_message": "Link Utilizado", "load_segment": "Carregar segmento", "logic_error_warning": "A alteração causará erros de lógica", "logic_error_warning_text": "Alterar o tipo de pergunta irá remover as condições lógicas desta pergunta", @@ -1516,6 +1479,7 @@ "no_hidden_fields_yet_add_first_one_below": "Ainda não há campos ocultos. Adicione o primeiro abaixo.", "no_images_found_for": "Não foram encontradas imagens para ''{query}\"", "no_languages_found_add_first_one_to_get_started": "Nenhuma língua encontrada. Adicione a primeira para começar.", + "no_option_found": "Nenhuma opção encontrada", "no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.", "number": "Número", "once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Depois de definido, o idioma padrão desta pesquisa só pode ser alterado desativando a opção de vários idiomas e eliminando todas as traduções.", @@ -1567,6 +1531,7 @@ "response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.", "response_options": "Opções de Resposta", "roundness": "Arredondamento", + "row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.", "rows": "Linhas", "save_and_close": "Guardar e Fechar", "scale": "Escala", @@ -1590,10 +1555,12 @@ "show_survey_to_users": "Mostrar inquérito a % dos utilizadores", "show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo", "simple": "Simples", - "single_use_survey_links": "Links de inquérito de uso único", - "single_use_survey_links_description": "Permitir apenas 1 resposta por link de inquérito.", + "six_points": "6 pontos", "skip_button_label": "Rótulo do botão Ignorar", "smiley": "Sorridente", + "spam_protection_note": "A proteção contra spam não funciona para inquéritos exibidos com os SDKs iOS, React Native e Android. Isso irá quebrar o inquérito.", + "spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo deste valor serão rejeitadas.", + "spam_protection_threshold_heading": "Limite de resposta", "star": "Estrela", "starts_with": "Começa com", "state": "Estado", @@ -1604,8 +1571,6 @@ "subheading": "Subtítulo", "subtract": "Subtrair -", "suggest_colors": "Sugerir cores", - "survey_already_answered_heading": "O inquérito já foi respondido.", - "survey_already_answered_subheading": "Só pode usar este link uma vez.", "survey_completed_heading": "Inquérito Concluído", "survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado", "survey_display_settings": "Configurações de Exibição do Inquérito", @@ -1636,7 +1601,6 @@ "upload": "Carregar", "upload_at_least_2_images": "Carregue pelo menos 2 imagens", "upper_label": "Etiqueta Superior", - "url_encryption": "Encriptação de URL", "url_filters": "Filtros de URL", "url_not_supported": "URL não suportado", "use_with_caution": "Usar com cautela", @@ -1649,7 +1613,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Aguarde alguns segundos após o gatilho antes de mostrar o inquérito", "waiting_period": "período de espera", "welcome_message": "Mensagem de boas-vindas", - "when": "Quando", "when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Quando as condições corresponderem, o tempo de espera será ignorado e o inquérito será mostrado.", "without_a_filter_all_of_your_users_can_be_surveyed": "Sem um filtro, todos os seus utilizadores podem ser pesquisados.", "you_have_not_created_a_segment_yet": "Ainda não criou um segmento", @@ -1660,9 +1623,11 @@ "zip": "Comprimir" }, "error_deleting_survey": "Ocorreu um erro ao eliminar o questionário", - "failed_to_copy_link_to_results": "Falha ao copiar link para resultados", - "failed_to_copy_url": "Falha ao copiar URL: não está num ambiente de navegador.", - "new_single_use_link_generated": "Novo link de uso único gerado", + "filter": { + "complete_and_partial_responses": "Respostas completas e parciais", + "complete_responses": "Respostas completas", + "partial_responses": "Respostas parciais" + }, "new_survey": "Novo inquérito", "no_surveys_created_yet": "Ainda não foram criados questionários", "open_options": "Abrir opções", @@ -1681,9 +1646,11 @@ "company": "Empresa", "completed": "Concluído ✅", "country": "País", + "delete_response_confirmation": "Isto irá apagar a resposta do inquérito, incluindo todas as respostas, etiquetas, documentos anexos e metadados da resposta.", "device": "Dispositivo", "device_info": "Informações do dispositivo", "email": "Email", + "error_downloading_responses": "Ocorreu um erro ao transferir respostas", "first_name": "Primeiro Nome", "how_to_identify_users": "Como identificar utilizadores", "last_name": "Apelido", @@ -1702,8 +1669,91 @@ "this_response_is_in_progress": "Esta resposta está em progresso.", "zip_post_code": "Código Postal" }, - "results_unpublished_successfully": "Resultados despublicados com sucesso.", "search_by_survey_name": "Pesquisar por nome do inquérito", + "share": { + "anonymous_links": { + "custom_single_use_id_description": "Se não encriptar os IDs de uso único, qualquer valor para “suid=...” funciona para uma resposta", + "custom_single_use_id_title": "Pode definir qualquer valor como ID de uso único no URL.", + "custom_start_point": "Ponto de início personalizado", + "data_prefilling": "Pré-preenchimento de dados", + "description": "Respostas provenientes destes links serão anónimas", + "disable_multi_use_link_modal_button": "Desativar link de uso múltiplo", + "disable_multi_use_link_modal_description": "Desativar o link de uso múltiplo impedirá que alguém submeta uma resposta através do link.", + "disable_multi_use_link_modal_description_subtext": "Isto também irá quebrar quaisquer incorporações ativas em websites, emails, redes sociais e códigos QR que utilizem este link de uso múltiplo.", + "disable_multi_use_link_modal_title": "Tem a certeza? Isto pode afetar integrações ativas", + "disable_single_use_link_modal_button": "Desativar links de uso único", + "disable_single_use_link_modal_description": "Se partilhou links de uso único, os participantes já não poderão responder ao inquérito.", + "generate_and_download_links": "Gerar & descarregar links", + "generate_links_error": "Não foi possível gerar links de uso único. Por favor, trabalhe diretamente com a API", + "multi_use_link": "Link de uso múltiplo", + "multi_use_link_description": "Recolha múltiplas respostas de respondentes anónimos com um só link.", + "multi_use_powers_other_channels_description": "Se desativar, estes outros canais de distribuição também serão desativados.", + "multi_use_powers_other_channels_title": "Este link alimenta incorporações em Websites, incorporações em Email, partilha em Redes Sociais e Códigos QR.", + "nav_title": "Links anónimos", + "number_of_links_label": "Número de links (1 - 5.000)", + "single_use_link": "Links de uso único", + "single_use_link_description": "Permitir apenas uma resposta por link de inquérito.", + "single_use_links": "Links de uso único", + "source_tracking": "Rastreamento de origem", + "url_encryption_description": "Desative apenas se precisar definir um ID de uso único personalizado.", + "url_encryption_label": "Encriptação do URL de ID de uso único" + }, + "dynamic_popup": { + "alert_button": "Editar inquérito", + "alert_description": "Este questionário está atualmente configurado como um questionário de link, que não suporta pop-ups dinâmicos. Você pode alterar isso na aba de configurações do editor de questionários.", + "alert_title": "Mudar tipo de inquérito para in-app", + "attribute_based_targeting": "Segmentação baseada em atributos", + "code_no_code_triggers": "Gatilhos com código e sem código", + "description": "Os inquéritos Formbricks podem ser incorporados como uma janela pop-up, com base na interação do utilizador.", + "nav_title": "Dinâmico (Pop-up)", + "recontact_options": "Opções de Recontacto" + }, + "embed_on_website": { + "description": "Os inquéritos Formbricks podem ser incorporados como um elemento estático.", + "embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!", + "embed_mode": "Modo de Incorporação", + "embed_mode_description": "Incorpore o seu inquérito com um design minimalista, descartando o preenchimento e o fundo.", + "nav_title": "Incorporar no site" + }, + "personal_links": { + "create_and_manage_segments": "Crie e gere os seus Segmentos em Contactos > Segmentos", + "description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto.", + "expiry_date_description": "Uma vez que o link expira, o destinatário não pode mais responder ao questionário.", + "expiry_date_optional": "Data de expiração (opcional)", + "generate_and_download_links": "Gerar & descarregar links", + "generating_links": "Gerando links", + "generating_links_toast": "A gerar links, o download começará em breve…", + "links_generated_success_toast": "Links gerados com sucesso, o seu download começará em breve.", + "nav_title": "Links pessoais", + "no_segments_available": "Sem segmentos disponíveis", + "select_segment": "Selecionar segmento", + "upgrade_prompt_description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto.", + "upgrade_prompt_title": "Utilize links pessoais com um plano superior", + "work_with_segments": "Os links pessoais funcionam com segmentos." + }, + "send_email": { + "copy_embed_code": "Copiar código de incorporação", + "description": "Incorpora o teu inquérito num email para obter respostas do teu público.", + "email_preview_tab": "Pré-visualização de Email", + "email_sent": "Email enviado!", + "email_subject_label": "Assunto", + "email_to_label": "Para", + "embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!", + "embed_code_copied_to_clipboard_failed": "A cópia falhou, por favor, tente novamente", + "embed_code_tab": "Código de Incorporação", + "formbricks_email_survey_preview": "Pré-visualização da Pesquisa de E-mail do Formbricks", + "nav_title": "Incorporação de Email", + "send_preview": "Enviar pré-visualização", + "send_preview_email": "Enviar pré-visualização de email" + }, + "share_view_title": "Partilhar via", + "social_media": { + "description": "Obtenha respostas dos seus contactos em várias redes sociais.", + "source_tracking_enabled": "Rastreamento de origem ativado", + "source_tracking_enabled_alert_description": "Ao partilhar a partir deste diálogo, a rede social será anexada ao link do inquérito para que saiba de que rede vieram as respostas.", + "title": "Redes Sociais" + } + }, "summary": { "added_filter_for_responses_where_answer_to_question": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é {filterComboBoxValue} - {filterValue} ", "added_filter_for_responses_where_answer_to_question_is_skipped": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é ignorada", @@ -1717,52 +1767,50 @@ "configure_alerts": "Configurar alertas", "congrats": "Parabéns! O seu inquérito está ativo.", "connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.", - "copy_link_to_public_results": "Copiar link para resultados públicos", - "create_single_use_links": "Criar links de uso único", - "create_single_use_links_description": "Aceitar apenas uma submissão por link. Aqui está como.", - "current_selection_csv": "Seleção atual (CSV)", - "current_selection_excel": "Seleção atual (Excel)", "custom_range": "Intervalo personalizado...", - "data_prefilling": "Pré-preenchimento de dados", - "data_prefilling_description": "Quer pré-preencher alguns campos no inquérito? Aqui está como.", - "define_when_and_where_the_survey_should_pop_up": "Defina quando e onde o inquérito deve aparecer", + "delete_all_existing_responses_and_displays": "Excluir todas as respostas existentes e exibições", + "download_qr_code": "Transferir código QR", "drop_offs": "Desistências", "drop_offs_tooltip": "Número de vezes que o inquérito foi iniciado mas não concluído.", - "dynamic_popup": "Dinâmico (Pop-up)", - "email_sent": "Email enviado!", - "embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!", - "embed_in_an_email": "Incorporar num email", - "embed_in_app": "Incorporar na aplicação", - "embed_mode": "Modo de Incorporação", - "embed_mode_description": "Incorpore o seu inquérito com um design minimalista, descartando o preenchimento e o fundo.", - "embed_on_website": "Incorporar no site", - "embed_pop_up_survey_title": "Como incorporar um questionário pop-up no seu site", - "embed_survey": "Incorporar inquérito", - "enable_ai_insights_banner_button": "Ativar insights", - "enable_ai_insights_banner_description": "Pode ativar a nova funcionalidade de insights para o inquérito para obter insights baseados em IA para as suas respostas de texto aberto.", - "enable_ai_insights_banner_success": "A gerar insights para este inquérito. Por favor, volte a verificar dentro de alguns minutos.", - "enable_ai_insights_banner_title": "Pronto para testar as perceções de IA?", - "enable_ai_insights_banner_tooltip": "Por favor, contacte-nos em hola@formbricks.com para gerar insights para este inquérito", "failed_to_copy_link": "Falha ao copiar link", "filter_added_successfully": "Filtro adicionado com sucesso", "filter_updated_successfully": "Filtro atualizado com sucesso", - "formbricks_email_survey_preview": "Pré-visualização da Pesquisa de E-mail do Formbricks", + "filtered_responses_csv": "Respostas filtradas (CSV)", + "filtered_responses_excel": "Respostas filtradas (Excel)", "go_to_setup_checklist": "Ir para a Lista de Verificação de Configuração \uD83D\uDC49", - "hide_embed_code": "Ocultar código de incorporação", - "how_to_create_a_panel": "Como criar um painel", - "how_to_create_a_panel_step_1": "Passo 1: Crie uma conta com a Prolific", - "how_to_create_a_panel_step_1_description": "Crie uma conta no Prolific e verifique o seu endereço de email.", - "how_to_create_a_panel_step_2": "Passo 2: Criar um estudo", - "how_to_create_a_panel_step_2_description": "No Prolific, cria um novo estudo onde pode escolher o seu público preferido com base em centenas de características.", - "how_to_create_a_panel_step_3": "Passo 3: Conecte o seu inquérito", - "how_to_create_a_panel_step_3_description": "Configure campos ocultos no seu inquérito Formbricks para rastrear qual participante forneceu qual resposta.", - "how_to_create_a_panel_step_4": "Passo 4: Lançar o seu estudo", - "how_to_create_a_panel_step_4_description": "Depois de tudo configurado, pode lançar o seu estudo. Dentro de algumas horas, receberá as primeiras respostas.", "impressions": "Impressões", "impressions_tooltip": "Número de vezes que o inquérito foi visualizado.", + "in_app": { + "connection_description": "O questionário será exibido aos utilizadores do seu website que correspondam aos critérios listados abaixo", + "connection_title": "O SDK do Formbricks está conectado", + "description": "Os inquéritos Formbricks podem ser incorporados como uma janela pop-up, com base na interação do utilizador.", + "display_criteria": "Critérios de exibição", + "display_criteria.audience_description": "Público-alvo", + "display_criteria.code_trigger": "Código de Ação", + "display_criteria.everyone": "Todos", + "display_criteria.no_code_trigger": "Sem código", + "display_criteria.overwritten": "Substituído", + "display_criteria.randomizer": "Aleatorizador {percentage}%", + "display_criteria.randomizer_description": "Apenas {percentage}% das pessoas que realizam a ação podem ser pesquisadas.", + "display_criteria.recontact_description": "Opções de Recontacto", + "display_criteria.targeted": "Alvo", + "display_criteria.time_based_always": "Mostrar sempre o inquérito", + "display_criteria.time_based_day": "Dia", + "display_criteria.time_based_days": "Dias", + "display_criteria.time_based_description": "Tempo de espera global", + "display_criteria.trigger_description": "Desencadeador de Inquérito", + "documentation_title": "Distribuir inquéritos de interceção em todas as plataformas", + "html_embed": "HTML embutido em ", + "ios_sdk": "SDK iOS para apps Apple", + "javascript_sdk": "JavaScript SDK", + "kotlin_sdk": "Kotlin SDK para aplicativos Android", + "no_connection_description": "Ligue o seu website ou aplicação ao Formbricks para publicar inquéritos de intercepção.", + "no_connection_title": "Ainda não está ligado!", + "react_native_sdk": "SDK React Native para aplicações RN.", + "title": "Configurações de interceptação de inquérito" + }, "includes_all": "Inclui tudo", "includes_either": "Inclui qualquer um", - "insights_disabled": "Informações desativadas", "install_widget": "Instalar Widget Formbricks", "is_equal_to": "É igual a", "is_less_than": "É menos que", @@ -1772,60 +1820,34 @@ "last_month": "Último mês", "last_quarter": "Último trimestre", "last_year": "Ano passado", - "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", - "only_completed": "Apenas concluído", + "no_responses_found": "Nenhuma resposta encontrada", "other_values_found": "Outros valores encontrados", "overall": "Geral", - "publish_to_web": "Publicar na web", - "publish_to_web_warning": "Está prestes a divulgar estes resultados do inquérito ao público.", - "publish_to_web_warning_description": "Os resultados do seu inquérito serão públicos. Qualquer pessoa fora da sua organização pode aceder a eles se tiver o link.", - "quickstart_mobile_apps": "Início rápido: Aplicações móveis", - "quickstart_mobile_apps_description": "Para começar com inquéritos em aplicações móveis, por favor, siga o guia de início rápido:", - "quickstart_web_apps": "Início rápido: Aplicações web", - "quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:", - "results_are_public": "Os resultados são públicos", - "send_preview": "Enviar pré-visualização", - "send_to_panel": "Enviar para painel", - "setup_instructions": "Instruções de configuração", + "qr_code": "Código QR", + "qr_code_description": "Respostas recolhidas através de código QR são anónimas.", + "qr_code_download_failed": "Falha ao transferir o código QR", + "qr_code_download_with_start_soon": "O download do código QR começará em breve", + "qr_code_generation_failed": "Ocorreu um problema ao carregar o Código QR do questionário. Por favor, tente novamente.", + "reset_survey": "Reiniciar inquérito", + "reset_survey_warning": "Repor um inquérito remove todas as respostas e visualizações associadas a este inquérito. Isto não pode ser desfeito.", + "selected_responses_csv": "Respostas selecionadas (CSV)", + "selected_responses_excel": "Respostas selecionadas (Excel)", "setup_integrations": "Configurar integrações", - "share_results": "Partilhar resultados", - "share_the_link": "Partilhar o link", - "share_the_link_to_get_responses": "Partilhe o link para obter respostas", + "share_survey": "Partilhar inquérito", "show_all_responses_that_match": "Mostrar todas as respostas que correspondem", "show_all_responses_where": "Mostrar todas as respostas onde...", - "single_use_links": "Links de uso único", - "source_tracking": "Rastreamento de origem", - "source_tracking_description": "Execute o rastreamento de origem em conformidade com o GDPR e o CCPA sem ferramentas adicionais.", "starts": "Começa", "starts_tooltip": "Número de vezes que o inquérito foi iniciado.", - "static_iframe": "Estático (iframe)", - "survey_results_are_public": "Os resultados do seu inquérito são públicos!", - "survey_results_are_shared_with_anyone_who_has_the_link": "Os resultados do seu inquérito são partilhados com qualquer pessoa que tenha o link. Os resultados não serão indexados pelos motores de busca.", + "survey_reset_successfully": "Inquérito reiniciado com sucesso! {responseCount} respostas e {displayCount} exibições foram eliminadas.", "this_month": "Este mês", "this_quarter": "Este trimestre", "this_year": "Este ano", "time_to_complete": "Tempo para Concluir", - "to_connect_your_website_with_formbricks": "para ligar o seu website ao Formbricks", "ttc_tooltip": "Tempo médio para concluir o inquérito.", "unknown_question_type": "Tipo de Pergunta Desconhecido", - "unpublish_from_web": "Despublicar da web", - "unsupported_video_tag_warning": "O seu navegador não suporta a tag de vídeo.", - "view_embed_code": "Ver código de incorporação", - "view_embed_code_for_email": "Ver código de incorporação para email", - "view_site": "Ver site", + "use_personal_links": "Utilize links pessoais", "waiting_for_response": "A aguardar uma resposta \uD83E\uDDD8‍♂️", - "web_app": "Aplicação web", - "what_is_a_panel": "O que é um painel?", - "what_is_a_panel_answer": "Um painel é um grupo de participantes selecionados com base em características como idade, profissão, género, etc.", - "what_is_prolific": "O que é o Prolific?", - "what_is_prolific_answer": "Estamos a colaborar com a Prolific para lhe dar acesso a um grupo de mais de 200.000 participantes verificados.", "whats_next": "O que se segue?", - "when_do_i_need_it": "Quando é que preciso disso?", - "when_do_i_need_it_answer": "Se não tiver acesso a pessoas suficientes que correspondam ao seu público-alvo, faz sentido pagar pelo acesso a um painel.", - "you_can_do_a_lot_more_with_links_surveys": "Pode fazer muito mais com inquéritos de links \uD83D\uDCA1", "your_survey_is_public": "O seu inquérito é público", "youre_not_plugged_in_yet": "Ainda não está ligado!" }, @@ -1897,12 +1919,12 @@ }, "s": { "check_inbox_or_spam": "Por favor, verifique também a sua pasta de spam se não vir o email na sua caixa de entrada.", - "completed": "Este inquérito gratuito e de código aberto foi encerrado.", - "create_your_own": "Crie o seu próprio", + "completed": "Este inquérito está encerrado.", + "create_your_own": "Crie o seu próprio inquérito de código aberto", "enter_pin": "Este inquérito está protegido. Introduza o PIN abaixo", "just_curious": "Só por curiosidade?", "link_invalid": "Este inquérito só pode ser respondido por convite.", - "paused": "Este inquérito gratuito e de código aberto está temporariamente pausado.", + "paused": "Este inquérito está temporariamente suspenso.", "please_try_again_with_the_original_link": "Por favor, tente novamente com o link original", "preview_survey_questions": "Pré-visualizar perguntas do inquérito.", "question_preview": "Pré-visualização da Pergunta", @@ -1954,11 +1976,6 @@ "this_user_has_all_the_power": "Este utilizador tem todo o poder." } }, - "share": { - "back_to_home": "Voltar para casa", - "page_not_found": "Página não encontrada", - "page_not_found_description": "Desculpe, não conseguimos encontrar o ID de partilha de respostas que está a procurar." - }, "templates": { "address": "Endereço", "address_description": "Pedir um endereço de correspondência", @@ -1969,7 +1986,6 @@ "alignment_and_engagement_survey_question_1_upper_label": "Compreensão completa", "alignment_and_engagement_survey_question_2_headline": "Sinto que os meus valores estão alinhados com a missão e a cultura da empresa.", "alignment_and_engagement_survey_question_2_lower_label": "Não alinhado", - "alignment_and_engagement_survey_question_2_upper_label": "Completamente alinhado", "alignment_and_engagement_survey_question_3_headline": "Colaboro eficazmente com a minha equipa para alcançar os nossos objetivos.", "alignment_and_engagement_survey_question_3_lower_label": "Colaboração fraca", "alignment_and_engagement_survey_question_3_upper_label": "Excelente colaboração", @@ -1979,7 +1995,6 @@ "book_interview": "Agendar entrevista", "build_product_roadmap_description": "Identifique a ÚNICA coisa que os seus utilizadores mais querem e construa-a.", "build_product_roadmap_name": "Construir Roteiro do Produto", - "build_product_roadmap_name_with_project_name": "Contributo para o Roteiro de $[projectName]", "build_product_roadmap_question_1_headline": "Quão satisfeito está com as funcionalidades e características de $[projectName]?", "build_product_roadmap_question_1_lower_label": "Nada satisfeito", "build_product_roadmap_question_1_upper_label": "Extremamente satisfeito", @@ -2162,7 +2177,6 @@ "csat_question_7_choice_3": "Um pouco responsivo", "csat_question_7_choice_4": "Não tão responsivo", "csat_question_7_choice_5": "Nada responsivo", - "csat_question_7_choice_6": "Não aplicável", "csat_question_7_headline": "Quão responsivos temos sido às suas perguntas sobre os nossos serviços?", "csat_question_7_subheader": "Por favor, selecione um:", "csat_question_8_choice_1": "Esta é a minha primeira compra", @@ -2170,7 +2184,6 @@ "csat_question_8_choice_3": "Seis meses a um ano", "csat_question_8_choice_4": "1 - 2 anos", "csat_question_8_choice_5": "3 ou mais anos", - "csat_question_8_choice_6": "Ainda não fiz uma compra", "csat_question_8_headline": "Há quanto tempo é cliente de $[projectName]?", "csat_question_8_subheader": "Por favor, selecione um:", "csat_question_9_choice_1": "Extremamente provável", @@ -2385,7 +2398,6 @@ "identify_sign_up_barriers_question_9_dismiss_button_label": "Saltar por agora", "identify_sign_up_barriers_question_9_headline": "Obrigado! Aqui está o seu código: SIGNUPNOW10", "identify_sign_up_barriers_question_9_html": "

Muito obrigado por dedicar tempo a partilhar feedback \uD83D\uDE4F

", - "identify_sign_up_barriers_with_project_name": "Barreiras de Inscrição do $[projectName]", "identify_upsell_opportunities_description": "Descubra quanto tempo o seu produto poupa ao seu utilizador. Use isso para vender mais.", "identify_upsell_opportunities_name": "Identificar Oportunidades de Venda Adicional", "identify_upsell_opportunities_question_1_choice_1": "Menos de 1 hora", @@ -2750,7 +2762,6 @@ "site_abandonment_survey_question_6_choice_3": "Mais variedade de produtos", "site_abandonment_survey_question_6_choice_4": "Design do site melhorado", "site_abandonment_survey_question_6_choice_5": "Mais avaliações de clientes", - "site_abandonment_survey_question_6_choice_6": "Outro", "site_abandonment_survey_question_6_headline": "Que melhorias o incentivariam a permanecer mais tempo no nosso site?", "site_abandonment_survey_question_6_subheader": "Por favor, selecione todas as opções aplicáveis:", "site_abandonment_survey_question_7_headline": "Gostaria de receber atualizações sobre novos produtos e promoções?", @@ -2781,6 +2792,8 @@ "star_rating_survey_question_3_placeholder": "Escreva a sua resposta aqui...", "star_rating_survey_question_3_subheader": "Ajude-nos a melhorar a sua experiência.", "statement_call_to_action": "Declaração (Chamada para Ação)", + "strongly_agree": "Concordo totalmente", + "strongly_disagree": "Discordo totalmente", "supportive_work_culture_survey_description": "Avaliar as perceções dos funcionários sobre o apoio da liderança, comunicação e o ambiente de trabalho geral.", "supportive_work_culture_survey_name": "Cultura de Trabalho de Apoio", "supportive_work_culture_survey_question_1_headline": "O meu gestor fornece-me o apoio de que preciso para concluir o meu trabalho.", @@ -2836,6 +2849,18 @@ "understand_purchase_intention_question_2_headline": "Entendido. Qual é a sua principal razão para visitar hoje?", "understand_purchase_intention_question_2_placeholder": "Escreva a sua resposta aqui...", "understand_purchase_intention_question_3_headline": "O que, se alguma coisa, o está a impedir de fazer uma compra hoje?", - "understand_purchase_intention_question_3_placeholder": "Escreva a sua resposta aqui..." + "understand_purchase_intention_question_3_placeholder": "Escreva a sua resposta aqui...", + "usability_question_10_headline": "Tive que aprender muito antes de poder começar a usar o sistema corretamente.", + "usability_question_1_headline": "Provavelmente usaria este sistema com frequência.", + "usability_question_2_headline": "O sistema parecia mais complicado do que precisava ser.", + "usability_question_3_headline": "O sistema foi fácil de entender.", + "usability_question_4_headline": "Acho que precisaria de ajuda de um especialista em tecnologia para utilizar este sistema.", + "usability_question_5_headline": "Tudo no sistema parecia funcionar bem em conjunto.", + "usability_question_6_headline": "O sistema parecia inconsistente na forma como as coisas funcionavam.", + "usability_question_7_headline": "Acho que a maioria das pessoas poderia aprender a usar este sistema rapidamente.", + "usability_question_8_headline": "Usar o sistema pareceu complicado.", + "usability_question_9_headline": "Eu senti-me confiante ao usar o sistema.", + "usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.", + "usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)" } } diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json new file mode 100644 index 000000000000..d79158386760 --- /dev/null +++ b/apps/web/locales/ro-RO.json @@ -0,0 +1,2866 @@ +{ + "auth": { + "continue_with_azure": "Continuă cu Microsoft", + "continue_with_email": "Continuă cu Email", + "continue_with_github": "Continuă cu GitHub", + "continue_with_google": "Continuă cu Google", + "continue_with_oidc": "Continuă cu {oidcDisplayName}", + "continue_with_openid": "Continuă cu OpenID", + "continue_with_saml": "Continuă cu SAML SSO", + "email-change": { + "confirm_password_description": "Vă rugăm să confirmați parola înainte de a schimba adresa de email", + "email_change_success": "Email schimbat cu succes", + "email_change_success_description": "Ați schimbat cu succes adresa de email. Vă rugăm să vă conectați cu noua adresă de email.", + "email_verification_failed": "Verificarea emailului a eșuat", + "email_verification_loading": "Verificarea adresei de email este în curs...", + "email_verification_loading_description": "Actualizăm adresa dumneavoastră de email în sistemul nostru. Acesta poate dura câteva secunde.", + "invalid_or_expired_token": "Schimbarea emailului a eșuat. Tokenul dvs. este invalid sau expirat.", + "new_email": "Email nou", + "old_email": "Email vechi" + }, + "forgot-password": { + "back_to_login": "Înapoi la conectare", + "email-sent": { + "heading": "Cerere de resetare parolă trimisă cu succes", + "text": "Dacă există un cont asociat cu acest email, veți primi în scurt timp instrucțiuni pentru resetarea parolei." + }, + "reset": { + "confirm_password": "Confirmă parola", + "new_password": "Parolă nouă", + "no_token_provided": "Niciun token furnizat", + "passwords_do_not_match": "Parolele nu se potrivesc", + "success": { + "heading": "Parola a fost resetată cu succes", + "text": "Acum te poți autentifica cu noua ta parolă" + } + }, + "reset_password": "Resetează parola", + "reset_password_description": "Veți fi delogat pentru a vă reseta parola." + }, + "invite": { + "create_account": "Creează un cont", + "email_does_not_match": "Ooops! Email greșit \uD83E\uDD26", + "email_does_not_match_description": "Emailul din invitație nu se potrivește cu al dumneavoastră.", + "go_to_app": "Deschide aplicația", + "happy_to_have_you": "Bucuros să te avem \uD83E\uDD17", + "happy_to_have_you_description": "Vă rugăm să creați un cont sau să vă autentificați.", + "invite_expired": "Invitație expirată \uD83D\uDE25", + "invite_expired_description": "Invitațiile sunt valabile timp de 7 zile. Vă rugăm să solicitați o invitație nouă.", + "invite_not_found": "Invitația nu a fost găsită \uD83D\uDE25", + "invite_not_found_description": "Codul de invitație nu poate fi găsit sau a fost deja utilizat.", + "login": "Autentificare", + "welcome_to_organization": "Ești în \uD83C\uDF89", + "welcome_to_organization_description": "Bun venit în organizație." + }, + "last_used": "Ultima utilizare", + "login": { + "backup_code": "Cod de rezervă", + "create_an_account": "Creează un cont", + "enter_your_backup_code": "Introduceți codul de rezervă", + "enter_your_two_factor_authentication_code": "Introduceți codul dvs. de autentificare în doi pași", + "forgot_your_password": "Ai uitat parola?", + "login_to_your_account": "Autentifică-te în contul tău", + "login_with_email": "Autentifică-te cu email", + "lost_access": "Acces pierdut?", + "new_to_formbricks": "Nou în Formbricks?", + "use_a_backup_code": "Folosiți un cod de rezervă" + }, + "saml_connection_error": "Ceva a mers prost. Vă rugăm să verificați consola aplicației pentru mai multe detalii.", + "signup": { + "captcha_failed": "Captcha eșuat", + "have_an_account": "Ai un cont?", + "log_in": "Conectează-te", + "password_validation_contain_at_least_1_number": "Conține cel puțin 1 număr", + "password_validation_minimum_8_and_maximum_128_characters": "Minim 8 & Maxim 128 caractere", + "password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule", + "please_verify_captcha": "Vă rugăm să verificați reCAPTCHA", + "privacy_policy": "Politica de Confidențialitate", + "terms_of_service": "Termeni de Serviciu", + "title": "Creați-vă contul Formbricks" + }, + "signup_without_verification_success": { + "user_successfully_created": "Utilizator creat cu succes", + "user_successfully_created_info": "Am verificat pentru un cont asociat cu {email}. Dacă nu a existat niciunul, am creat unul pentru tine. Dacă un cont deja exista, nu s-au făcut modificări. Vă rugăm să vă conectați mai jos pentru a continua." + }, + "testimonial_1": "\"Măsurăm claritatea documentațiilor noastre și învățăm din pierderi în folosirea aceleași platforme. Produs grozav, echipă foarte receptivă!\"", + "testimonial_all_features_included": "Toate funcționalitățile incluse", + "testimonial_free_and_open_source": "Gratuit și open-source", + "testimonial_no_credit_card_required": "Nu este necesar niciun card de credit", + "testimonial_title": "Transformați informațiile despre clienți în experiențe irezistibile.", + "verification-requested": { + "invalid_email_address": "Adresa de email invalidă", + "invalid_token": "Token invalid ☹️", + "new_email_verification_success": "Dacă adresa este validă, un email de verificare a fost trimis.", + "no_email_provided": "Niciun email furnizat", + "please_confirm_your_email_address": "Vă rugăm să confirmați adresa de email", + "resend_verification_email": "Retrimite emailul de verificare", + "verification_email_resent_successfully": "Email de verificare trimis! Vă rugăm să verificați căsuța de email.", + "verification_email_successfully_sent_info": "Dacă există un cont asociat cu {email}, am trimis un link de verificare la acea adresă. Vă rugăm să verificați inboxul pentru a finaliza înscrierea.", + "you_didnt_receive_an_email_or_your_link_expired": "Nu ați primit un email sau link-ul dvs. a expirat?" + }, + "verify": { + "no_token_provided": "Niciun token furnizat", + "verifying": "Verificare..." + } + }, + "billing_confirmation": { + "back_to_billing_overview": "Înapoi la privire generală asupra facturării", + "thanks_for_upgrading": "Mulțumim mult pentru actualizarea abonamentului Formbricks.", + "upgrade_successful": "Actualizare reușită" + }, + "c": { + "link_expired": "Link-ul dumneavoastră a expirat.", + "link_expired_description": "Link-ul pe care l-ați utilizat nu mai este valabil." + }, + "common": { + "accepted": "Acceptat", + "account": "Cont", + "account_settings": "Setări cont", + "action": "Acțiune", + "actions": "Acțiuni", + "active_surveys": "Sondaje active", + "activity": "Activitate", + "add": "Adaugă", + "add_action": "Adăugați acțiune", + "add_filter": "Adăugați filtru", + "add_logo": "Adaugă logo", + "add_member": "Adaugă membru", + "add_project": "Adaugă proiect", + "add_to_team": "Adaugă la echipă", + "all": "Toate", + "all_questions": "Toate întrebările", + "allow": "Permite", + "allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului", + "an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}", + "and": "Și", + "and_response_limit_of": "și limită răspuns", + "anonymous": "Anonim", + "api_keys": "Chei API", + "app": "Aplicație", + "app_survey": "Sondaj Aplicație", + "apply_filters": "Aplică filtre", + "are_you_sure": "Ești sigur?", + "attributes": "Atribute", + "avatar": "Avatar", + "back": "Înapoi", + "billing": "Facturare", + "booked": "Rezervat", + "bottom_left": "Stânga Jos", + "bottom_right": "Dreapta Jos", + "cancel": "Anulare", + "centered_modal": "Modală centralizată", + "choices": "Alegeri", + "clear_all": "Șterge tot", + "clear_filters": "Curăță filtrele", + "clear_selection": "Șterge selecția", + "click": "Click", + "clicks": "Clickuri", + "close": "Închide", + "code": "Cod", + "collapse_rows": "Restrânge rânduri", + "completed": "Completat", + "configuration": "Configurare", + "confirm": "Confirmare", + "connect": "Conectează", + "connect_formbricks": "Conectează Formbricks", + "connected": "Conectat", + "contacts": "Contacte", + "copied": "Copiat", + "copied_to_clipboard": "Copiat în clipboard", + "copy": "Copiază", + "copy_code": "Copiază codul", + "copy_link": "Copiază legătura", + "create_new_organization": "Creează organizație nouă", + "create_project": "Creează proiect", + "create_segment": "Creați segment", + "create_survey": "Creează sondaj", + "created": "Creat", + "created_at": "Creat la", + "created_by": "Creat de", + "customer_success": "Succesul Clientului", + "danger_zone": "Zonă periculoasă", + "dark_overlay": "Suprapunere întunecată", + "date": "Dată", + "default": "Implicit", + "delete": "Șterge", + "description": "Descriere", + "dev_env": "Mediu de dezvoltare", + "development_environment_banner": "Ești într-un mediu de dezvoltare. Configurează-l pentru a testa sondaje, acțiuni și atribute.", + "disable": "Dezactivează", + "disallow": "Nu permite", + "discard": "Renunță", + "dismissed": "Respins", + "docs": "Documentație", + "documentation": "Documentație", + "download": "Descărcare", + "draft": "Schiță", + "duplicate": "Duplicități", + "e_commerce": "Comerț electronic", + "edit": "Editare", + "email": "Email", + "enterprise_license": "Licență Întreprindere", + "environment_not_found": "Mediul nu a fost găsit", + "environment_notice": "Te afli în prezent în mediul {environment}", + "error": "Eroare", + "expand_rows": "Extinde rândurile", + "finish": "Finalizează", + "follow_these": "Urmați acestea", + "formbricks_version": "Versiunea Formbricks", + "full_name": "Nume complet", + "gathering_responses": "Culegere răspunsuri", + "general": "General", + "go_back": "Înapoi", + "go_to_dashboard": "Mergi la Tablou de Bord", + "hidden": "Ascuns", + "hidden_field": "Câmp ascuns", + "hidden_fields": "Câmpuri ascunse", + "hide": "Ascunde", + "hide_column": "Ascunde coloana", + "image": "Imagine", + "images": "Imagini", + "import": "Import", + "impressions": "Impresii", + "imprint": "Amprentă", + "in_progress": "În progres", + "inactive_surveys": "Sondaje inactive", + "input_type": "Tipul de intrare", + "integration": "integrare", + "integrations": "Integrări", + "invalid_date": "Dată invalidă", + "invalid_file_type": "Tip de fișier nevalid", + "invite": "Invită", + "invite_them": "Invită-i", + "key": "Cheie", + "label": "Etichetă", + "language": "Limba", + "learn_more": "Află mai multe", + "license": "Licență", + "light_overlay": "Suprapunere ușoară", + "limits_reached": "Limite atinse", + "link": "Legătura", + "link_and_email": "Link & email", + "link_survey": "Conectează chestionarul", + "link_surveys": "Conectează chestionarele", + "load_more": "Încarcă mai multe", + "loading": "Încărcare", + "logo": "Logo", + "logout": "Deconectare", + "look_and_feel": "Aspect și Comportament", + "manage": "Gestionați", + "marketing": "Marketing", + "maximum": "Maximum", + "member": "Membru", + "members": "Membri", + "membership_not_found": "Apartenența nu a fost găsită", + "metadata": "Metadate", + "minimum": "Minim", + "mobile_overlay_text": "Formbricks nu este disponibil pentru dispozitive cu rezoluții mai mici.", + "move_down": "Mută în jos", + "move_up": "Mută sus", + "multiple_languages": "Mai multe limbi", + "name": "Nume", + "new": "Nou", + "new_survey": "Chestionar Nou", + "new_version_available": "Formbricks {version} este disponibil. Actualizați acum!", + "next": "Următorul", + "no_background_image_found": "Nu a fost găsită nicio imagine de fundal.", + "no_code": "Fără Cod", + "no_files_uploaded": "Nu au fost încărcate fișiere", + "no_result_found": "Niciun rezultat găsit", + "no_results": "Nicio rezultat", + "no_surveys_found": "Nu au fost găsite sondaje.", + "not_authenticated": "Nu sunteți autentificat pentru a efectua această acțiune.", + "not_authorized": "Neautorizat", + "not_connected": "Neconectat", + "note": "Notă", + "notes": "Notele", + "notifications": "Notificări", + "number": "Număr", + "off": "Oprit", + "on": "Pe", + "only_one_file_allowed": "Este permis doar un fișier", + "only_owners_managers_and_manage_access_members_can_perform_this_action": "Doar proprietarii și managerii pot efectua această acțiune.", + "option_id": "ID opțiune", + "option_ids": "ID-uri opțiuni", + "or": "sau", + "organization": "Organizație", + "organization_id": "ID Organizație", + "organization_not_found": "Organizație nu a fost găsită", + "organization_teams_not_found": "Echipele organizației nu au fost găsite", + "other": "Altele", + "others": "Altele", + "overview": "Prezentare generală", + "password": "Parolă", + "paused": "Pauzat", + "pending_downgrade": "Reducere în aşteptare", + "people_manager": "Manager de persoane", + "person": "Persoană", + "phone": "Telefon", + "photo_by": "Fotografie de", + "pick_a_date": "Alege o dată", + "picture": "Poză", + "placeholder": "Marcaj substituent", + "please_select_at_least_one_survey": "Vă rugăm să selectați cel puțin un sondaj", + "please_select_at_least_one_trigger": "Vă rugăm să selectați cel puțin un declanșator", + "please_upgrade_your_plan": "Vă rugăm să vă actualizați planul.", + "preview": "Previzualizare", + "preview_survey": "Previzualizare Chestionar", + "privacy": "Politica de Confidențialitate", + "product_manager": "Manager de Produs", + "profile": "Profil", + "profile_id": "ID Profil", + "project_configuration": "Configurarea Proiectului", + "project_creation_description": "Organizați sondajele în proiecte pentru un control mai bun al accesului.", + "project_id": "ID proiect", + "project_name": "Nume proiect", + "project_name_placeholder": "de ex. Formbricks", + "project_not_found": "Proiectul nu a fost găsit", + "project_permission_not_found": "Permisiunea proiectului nu a fost găsită", + "projects": "Proiecte", + "question": "Întrebare", + "question_id": "ID întrebare", + "questions": "Întrebări", + "read_docs": "Citește documentația", + "recipients": "Destinatari", + "remove": "Șterge", + "reorder_and_hide_columns": "Reordonați și ascundeți coloanele", + "report_survey": "Raportează chestionarul", + "request_pricing": "Solicită Prețuri", + "request_trial_license": "Solicitați o licență de încercare", + "reset_to_default": "Revină la implicit", + "response": "Răspuns", + "responses": "Răspunsuri", + "restart": "Repornește", + "role": "Rolul", + "role_organization": "Rol (Organizație)", + "saas": "SaaS", + "sales": "Vânzări", + "save": "Salvează", + "save_changes": "Salvează modificările", + "scheduled": "Programat", + "search": "Căutare", + "security": "Securitate", + "segment": "Segment", + "segments": "Segment", + "select": "Selectați", + "select_all": "Selectați toate", + "select_survey": "Selectați chestionar", + "select_teams": "Selectați echipele", + "selected": "Selectat", + "selected_questions": "Întrebări selectate", + "selection": "Selecție", + "selections": "Selecții", + "send": "Trimite", + "send_test_email": "Trimite email de test", + "session_not_found": "Sesiune inexistentă", + "settings": "Setări", + "share_feedback": "Împărtășește feedback", + "show": "Afișează", + "show_response_count": "Afișează numărul de răspunsuri", + "shown": "Arătat", + "size": "Mărime", + "skipped": "Sărit", + "skips": "Salturi", + "some_files_failed_to_upload": "Unele fișiere nu au reușit să se încarce", + "something_went_wrong": "Ceva nu a mers bine", + "something_went_wrong_please_try_again": "Ceva nu a mers bine. Vă rugăm să încercați din nou.", + "sort_by": "Sortare după", + "start_free_trial": "Începe Perioada de Testare Gratuită", + "status": "Stare", + "step_by_step_manual": "Manual pas cu pas", + "styling": "Stilizare", + "submit": "Trimite", + "summary": "Sumar", + "survey": "Chestionar", + "survey_completed": "Sondaj finalizat", + "survey_id": "ID Chestionar", + "survey_languages": "Limbi chestionar", + "survey_live": "Chestionar activ", + "survey_not_found": "Sondajul nu a fost găsit", + "survey_paused": "Chestionar oprit.", + "survey_scheduled": "Chestionar programat.", + "survey_type": "Tip Chestionar", + "surveys": "Sondaje", + "switch_organization": "Comută organizația", + "switch_to": "Comută la {environment}", + "table_items_deleted_successfully": "\"{type} șterse cu succes\"", + "table_settings": "Setări tabel", + "tags": "Etichete", + "targeting": "Targetare", + "team": "Echipă", + "team_access": "Acces echipă", + "team_id": "ID echipă", + "team_name": "Nume echipă", + "teams": "Control acces", + "teams_not_found": "Echipele nu au fost găsite", + "text": "Text", + "time": "Timp", + "time_to_finish": "Timp până la finalizare", + "title": "Titlu", + "top_left": "Stânga Sus", + "top_right": "Dreapta Sus", + "try_again": "Încearcă din nou", + "type": "Tip", + "unlock_more_projects_with_a_higher_plan": "Deblocați mai multe proiecte cu un plan superior.", + "update": "Actualizare", + "updated": "Actualizat", + "updated_at": "Actualizat la", + "upload": "Încărcați", + "upload_input_description": "Faceți clic sau trageți pentru a încărca fișiere.", + "url": "URL", + "user": "Utilizator", + "user_id": "ID Utilizator", + "user_not_found": "Utilizatorul nu a fost găsit", + "variable": "Variabilă", + "variables": "Variante", + "verified_email": "Email verificat", + "video": "Video", + "warning": "Avertisment", + "we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nu am putut verifica licența dvs. deoarece serverul de licențe este inaccesibil.", + "webhook": "Webhook", + "webhooks": "Webhook-uri", + "website_and_app_connection": "Conectare site web și aplicație", + "website_app_survey": "Chestionar pentru site și aplicație", + "website_survey": "Chestionar despre site", + "welcome_card": "Card de bun venit", + "you": "Tu", + "you_are_downgraded_to_the_community_edition": "Ai fost retrogradat la ediția Community.", + "you_are_not_authorised_to_perform_this_action": "Nu sunteți autorizat să efectuați această acțiune.", + "you_have_reached_your_limit_of_project_limit": "Ați atins limita de {projectLimit} proiecte.", + "you_have_reached_your_monthly_miu_limit_of": "Ați atins limita lunară MIU de", + "you_have_reached_your_monthly_response_limit_of": "Ați atins limita lunară de răspunsuri de", + "you_will_be_downgraded_to_the_community_edition_on_date": "Vei fi retrogradat la ediția Community pe {date}." + }, + "emails": { + "accept": "Acceptă", + "click_or_drag_to_upload_files": "Faceți clic sau trageți pentru a încărca fișiere.", + "email_customization_preview_email_heading": "Salut {userName}", + "email_customization_preview_email_subject": "Previzualizare Personalizare Email Formbricks", + "email_customization_preview_email_text": "Acesta este un previzualizare a e-mailului pentru a vă arăta ce logo va fi afișat în e-mailurile.", + "email_footer_text_1": "O zi minunată!", + "email_footer_text_2": "Echipa Formbricks", + "email_template_text_1": "Acest email a fost trimis prin Formbricks.", + "embed_survey_preview_email_didnt_request": "Nu ați solicitat asta?", + "embed_survey_preview_email_environment_id": "ID de mediu", + "embed_survey_preview_email_fight_spam": "Ajută-ne să combatem spam-ul și trimite acest e-mail la hola@formbricks.com", + "embed_survey_preview_email_heading": "Previzualizare Incorporare Email", + "embed_survey_preview_email_subject": "Previzualizare Chestionar Email Formbricks", + "embed_survey_preview_email_text": "Așa arată fragmentul de cod încorporat într-un email:", + "forgot_password_email_change_password": "Schimbați parola", + "forgot_password_email_did_not_request": "Dacă nu ați solicitat acest lucru, vă rugăm să ignorați acest email.", + "forgot_password_email_heading": "Schimbați parola", + "forgot_password_email_link_valid_for_24_hours": "Linkul este valabil timp de 24 de ore.", + "forgot_password_email_subject": "Resetați parola dumneavoastră Formbricks", + "forgot_password_email_text": "Ați solicitat un link pentru a vă schimba parola. Puteți face acest lucru făcând clic pe linkul de mai jos:", + "imprint": "Amprentă", + "invite_accepted_email_heading": "Salut", + "invite_accepted_email_subject": "Ai un nou membru în organizație!", + "invite_accepted_email_text_par1": "Doar te anunț că", + "invite_accepted_email_text_par2": "a acceptat invitația ta. Distracție plăcută colaborând!", + "invite_email_button_label": "Alătură-te organizației", + "invite_email_heading": "Hei", + "invite_email_text_par1": "Colegul tău", + "invite_email_text_par2": "te-a invitat să li te alături la Formbricks. Pentru a accepta invitația, te rugăm să dai click pe linkul de mai jos:", + "invite_member_email_subject": "Ești invitat să colaborezi pe Formbricks!", + "new_email_verification_text": "Pentru a verifica noua dumneavoastră adresă de email, vă rugăm să faceți clic pe butonul de mai jos:", + "password_changed_email_heading": "Parola modificată", + "password_changed_email_text": "Parola dumneavoastră a fost schimbată cu succes.", + "password_reset_notify_email_subject": "Parola dumneavoastră Formbricks a fost schimbată", + "privacy_policy": "Politica de Confidențialitate", + "reject": "Respinge", + "render_email_response_value_file_upload_response_link_not_included": "Linkul către fișierul încărcat nu este inclus din motive de confidențialitate a datelor", + "response_finished_email_subject": "Un răspuns pentru {surveyName} a fost finalizat ✅", + "response_finished_email_subject_with_email": "{personEmail} tocmai a completat sondajul {surveyName} ✅", + "schedule_your_meeting": "Programați întâlnirea", + "select_a_date": "Selectați o dată", + "survey_response_finished_email_congrats": "Felicitări, aţi primit un răspuns nou la sondaj! Cineva tocmai a completat sondajul dumneavoastră: {surveyName}", + "survey_response_finished_email_dont_want_notifications": "Nu doriți să primiți aceste notificări?", + "survey_response_finished_email_hey": "Hei \uD83D\uDC4B", + "survey_response_finished_email_turn_off_notifications_for_all_new_forms": "Dezactivează notificările pentru toate formularele nou create", + "survey_response_finished_email_turn_off_notifications_for_this_form": "Dezactivează notificările pentru acest formular", + "survey_response_finished_email_view_more_responses": "Vizualizați {responseCount} mai multe răspunsuri", + "survey_response_finished_email_view_survey_summary": "Vizualizați sumarul sondajului", + "verification_email_click_on_this_link": "De asemenea, puteți face clic pe acest link:", + "verification_email_heading": "Aproape gata!", + "verification_email_hey": "Salut \uD83D\uDC4B", + "verification_email_if_expired_request_new_token": "Dacă a expirat, vă rugăm să solicitați un nou token aici:", + "verification_email_link_valid_for_24_hours": "Linkul este valabil timp de 24 de ore.", + "verification_email_request_new_verification": "Solicită o nouă verificare", + "verification_email_subject": "Vă rugăm să vă verificați emailul pentru a utiliza Formbricks", + "verification_email_survey_name": "Nume chestionar", + "verification_email_take_survey": "Participă la chestionar", + "verification_email_text": "Pentru a începe să utilizați Formbricks, vă rugăm să vă verificați emailul de mai jos:", + "verification_email_thanks": "Mulțumim pentru validarea adresei de email!", + "verification_email_to_fill_survey": "Pentru a completa sondajul, vă rugăm să faceți clic pe butonul de mai jos:", + "verification_email_verify_email": "Verifică emailul", + "verification_new_email_subject": "Verificare schimbare email", + "verification_security_notice": "Dacă nu ați cerut această modificare a e-mailului, vă rugăm să ignorați acest e-mail sau să contactați suportul imediat.", + "verified_link_survey_email_subject": "Chestionarul tău este gata să fie completat." + }, + "environments": { + "actions": { + "action_copied_successfully": "Acțiune copiată cu succes", + "action_copy_failed": "Copierea acțiunii a eșuat", + "action_created_successfully": "Acțiune creată cu succes", + "action_deleted_successfully": "Acțiune ștearsă cu succes.", + "action_type": "Tip Acțiune", + "action_updated_successfully": "Acțiune actualizată cu succes", + "action_with_key_already_exists": "Acțiunea cu cheia {key} există deja", + "action_with_name_already_exists": "Acțiunea cu numele {name} există deja", + "add_css_class_or_id": "Adăugați clasă CSS sau id", + "add_regular_expression_here": "Adăugați o expresie regulată aici", + "add_url": "Adaugă URL", + "click": "Click", + "contains": "Conține", + "create_action": "Creează acțiune", + "css_selector": "Selector CSS", + "delete_action_text": "Sigur doriți să ștergeți această acțiune? Acest lucru va elimina acțiunea ca declanșator din toate sondajele dvs.", + "does_not_contain": "Nu conține", + "does_not_exactly_match": "Nu se potrivește exact", + "eg_clicked_download": "Exemplu clic pe Descărcare", + "eg_download_cta_click_on_home": "exemplu descărcare_cta_click_pe_acasă", + "eg_install_app": "Exemplu: Instalați aplicația", + "ends_with": "Se termină cu", + "enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Introduceți un URL pentru a vedea dacă un utilizator care îl vizitează ar fi urmărit.", + "enter_url": "de exemplu: https://app.com/dashboard", + "exactly_matches": "Se potrivește exact", + "exit_intent": "Ieșire intenționată", + "fifty_percent_scroll": "50% Derulare", + "how_do_code_actions_work": "Cum funcționează acțiunile codului?", + "if_a_user_clicks_a_button_with_a_specific_css_class_or_id": "Dacă un utilizator dă clic pe un buton cu o clasă CSS sau id specifică", + "if_a_user_clicks_a_button_with_a_specific_text": "Dacă un utilizator dă clic pe un buton cu un text specific", + "in_your_code_read_more_in_our": "în codul dvs. Citiți mai mult în", + "inner_text": "Text Interior", + "invalid_action_type_code": "Tip acțiune invalid pentru acțiunea cod.", + "invalid_action_type_no_code": "Tip acțiune invalid pentru acțiunea noCode.", + "invalid_css_selector": "Selector CSS Invalid", + "invalid_match_type": "Opțiunea selectată nu este disponibilă.", + "invalid_regex": "Vă rugăm să utilizați o expresie regulată validă.", + "limit_the_pages_on_which_this_action_gets_captured": "Limitează paginile pe care această acțiune este capturată", + "limit_to_specific_pages": "Limitează la pagini specifice", + "matches_regex": "Se potrivește cu regex", + "on_all_pages": "Pe toate paginile", + "page_filter": "Filtru pagină", + "page_view": "Vizualizare Pagina", + "select_match_type": "Selectați tipul de potrivire", + "starts_with": "Începe cu", + "test_match": "Testează potrivirea", + "test_your_url": "Testează URL-ul tău", + "this_action_was_created_automatically_you_cannot_make_changes_to_it": "Această acțiune a fost creată automat. Nu puteți face modificări la aceasta.", + "this_action_will_be_triggered_when_the_page_is_loaded": "Această acțiune va fi declanșată atunci când pagina este încărcată.", + "this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Această acțiune va fi declanșată atunci când utilizatorul derulează 50% din pagină.", + "this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Această acțiune va fi declanșată atunci când utilizatorul încearcă să părăsească pagina.", + "this_is_a_code_action_please_make_changes_in_your_code_base": "Aceasta este o acțiune de cod. Vă rugăm să faceți modificări în baza dvs. de cod.", + "track_new_user_action": "Urmăriți acțiunea noului utilizator", + "track_user_action_to_display_surveys_or_create_user_segment": "Urmăriți acțiunea utilizatorului pentru a afișa sondaje sau a crea un segment de utilizator.", + "url": "URL", + "user_actions": "Acțiuni utilizator", + "user_clicked_download_button": "Utilizatorul a făcut clic pe butonul Descărcare", + "what_did_your_user_do": "Ce a făcut utilizatorul tău?", + "what_is_the_user_doing": "Ce face utilizatorul?", + "you_can_track_code_action_anywhere_in_your_app_using": "Puteți urmări acțiunea codului oriunde în aplicația dvs. folosind", + "your_survey_would_be_shown_on_this_url": "Sondajul dumneavoastră ar fi afișat pe acest URL.", + "your_survey_would_not_be_shown": "Sondajul dumneavoastră nu va fi afișat." + }, + "connect": { + "congrats": "Felicitări!", + "connection_successful_message": "Bravo! Suntem conectați.", + "do_it_later": "Am să o fac mai târziu", + "finish_onboarding": "Încheie Înregistrarea", + "headline": "Conectați aplicația sau site-ul dvs.", + "import_formbricks_and_initialize_the_widget_in_your_component": "Importați Formbricks și inițializați widgetul în componenta dumneavoastră (de exemplu, App.tsx):", + "insert_this_code_into_the_head_tag_of_your_website": "Introduceți acest cod în eticheta head a site-ului dvs.:", + "subtitle": "Durează mai puțin de 4 minute.", + "waiting_for_your_signal": "Așteptăm semnalul dumneavoastră..." + }, + "contacts": { + "contact_deleted_successfully": "Contact șters cu succes", + "contact_not_found": "Nu a fost găsit niciun contact", + "contacts_table_refresh": "Reîmprospătare contacte", + "contacts_table_refresh_success": "Contactele au fost actualizate cu succes", + "delete_contact_confirmation": "Acest lucru va șterge toate răspunsurile la sondaj și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute.", + "first_name": "Prenume", + "last_name": "Nume de familie", + "no_responses_found": "Nu s-au găsit răspunsuri", + "not_provided": "Neprovidat", + "search_contact": "Căutați contact", + "select_attribute": "Selectează Atributul", + "unlock_contacts_description": "Gestionează contactele și trimite sondaje țintite", + "unlock_contacts_title": "Deblocați contactele cu un plan superior.", + "upload_contacts_modal_attributes_description": "Mapează coloanele din CSV-ul tău la atributele din Formbricks.", + "upload_contacts_modal_attributes_new": "Atribut nou", + "upload_contacts_modal_attributes_search_or_add": "Căutați sau adăugați atribut", + "upload_contacts_modal_attributes_should_be_mapped_to": "ar trebui să fie mapat către", + "upload_contacts_modal_attributes_title": "Atribute", + "upload_contacts_modal_description": "Încărcați un fișier CSV pentru a importa rapid contactele cu atribute.", + "upload_contacts_modal_download_example_csv": "Descărcați exemplul CSV", + "upload_contacts_modal_duplicates_description": "Cum ar trebui să procedăm dacă un contact există deja în agenda dumneavoastră?", + "upload_contacts_modal_duplicates_overwrite_description": "Suprascrie contactele existente", + "upload_contacts_modal_duplicates_overwrite_title": "Suprascriere", + "upload_contacts_modal_duplicates_skip_description": "Omite contactele duplicate", + "upload_contacts_modal_duplicates_skip_title": "Omite", + "upload_contacts_modal_duplicates_title": "Duplicate", + "upload_contacts_modal_duplicates_update_description": "Actualizează contactele existente", + "upload_contacts_modal_duplicates_update_title": "Actualizare", + "upload_contacts_modal_pick_different_file": "Selectați un alt fișier", + "upload_contacts_modal_preview": "Iată o previzualizare a datelor tale.", + "upload_contacts_modal_upload_btn": "Încărcați contacte" + }, + "formbricks_logo": "Logo Formbricks", + "integrations": { + "activepieces_integration_description": "Conectați instantaneu Formbricks cu aplicații populare pentru a automatiza sarcini fără codare.", + "additional_settings": "Setări suplimentare", + "airtable": { + "airtable_base": "Bază Airtable", + "airtable_integration": "Integrarea Airtable", + "airtable_integration_description": "Sincronizați răspunsurile direct cu Airtable.", + "airtable_integration_is_not_configured": "Integrarea Airtable nu este configurată", + "airtable_logo": "Sigla Airtable", + "connect_with_airtable": "Conectează-te cu Airtable", + "link_airtable_table": "Leagă Tabel Airtable", + "link_new_table": "Leagă tabel nou", + "no_bases_found": "Nicio bază Airtable găsită", + "no_integrations_yet": "Integrațiile tale Airtable vor apărea aici de îndată ce le vei adăuga. ⏲️", + "please_create_a_base": "Vă rugăm să creați o bază pe Airtable", + "please_select_a_base": "Vă rugăm să selectați o bază", + "please_select_a_table": "Vă rugăm să selectați un tabel", + "sync_responses_with_airtable": "Sincronizați răspunsurile cu un Airtable", + "table_name": "Nume tabel" + }, + "airtable_integration_description": "Populați instantaneu tabelul Airtable cu datele chestionarului", + "connected_with_email": "Conectat cu {email}", + "connecting_integration_failed_please_try_again": "Conectarea integrării a eșuat. Vă rugăm să încercați din nou!", + "create_survey_warning": "Trebuie să creezi un sondaj pentru a putea configura această integrare", + "delete_integration": "Șterge Integrarea", + "delete_integration_confirmation": "Sigur doriți să ștergeți această integrare?", + "google_sheet_integration_description": "Completați instantaneu foile de calcul cu datele chestionarului", + "google_sheets": { + "connect_with_google_sheets": "Conectează-te cu Google Sheets", + "enter_a_valid_spreadsheet_url_error": "Vă rugăm să introduceți un URL valid pentru foaie de calcul", + "google_connection": "Conectare Google", + "google_connection_deletion_description": "Sincronizați răspunsurile direct cu Google Sheets.", + "google_sheet_integration_is_not_configured": "Integrarea Google Sheet nu este configurată în instanța dvs. de Formbricks.", + "google_sheet_logo": "Logo Google Sheet", + "google_sheet_name": "Nume Google Sheet", + "google_sheets_integration": "Integrare Google Sheets", + "google_sheets_integration_description": "Sincronizați răspunsurile direct cu Google Sheets.", + "link_google_sheet": "Leagă Google Sheet", + "link_new_sheet": "Leagă un nou Sheet", + "no_integrations_yet": "Integrațiile tale Google Sheet vor apărea aici de îndată ce le vei adăuga. ⏲️", + "spreadsheet_url": "URL foaie de calcul" + }, + "include_created_at": "Include Data Creării", + "include_hidden_fields": "Include câmpuri ascunse", + "include_metadata": "Includere Metadata (Browser, Țară, etc.)", + "include_variables": "Include Variabile", + "integration_added_successfully": "Integrarea adăugată cu succes", + "integration_removed_successfully": "Integrarea a fost eliminată cu succes", + "integration_updated_successfully": "Integrarea actualizată cu succes", + "make_integration_description": "Integratezi Formbricks cu peste 1000 de aplicații prin Make", + "manage_webhooks": "Gestionați Webhook-uri", + "n8n_integration_description": "Integrează Formbricks cu peste 350 de aplicații prin n8n", + "notion": { + "col_name_of_type_is_not_supported": "{col_name} de tipul {type} nu este acceptat de API-ul notion. Datele nu vor fi reflectate în baza ta de date notion.", + "connect_with_notion": "Conectează-te cu Notion", + "connected_with_workspace": "Conectat cu spațiul de lucru {workspace}", + "create_at_least_one_database_to_setup_this_integration": "Trebuie să creezi cel puțin o bază de date pentru a putea configura această integrare", + "database_name": "Numele bazei de date", + "duplicate_connection_warning": "O conexiune cu această bază de date este activă. Vă rugăm să faceți modificări cu precauție.", + "link_database": "Leagă baza de date", + "link_new_database": "Leagă o nouă bază de date", + "link_notion_database": "Leagă baza de date Notion", + "map_formbricks_fields_to_notion_property": "Mapează câmpurile Formbricks la proprietatea Notion", + "no_databases_found": "Integrațiile tale Notion vor apărea aici de îndată ce le vei adăuga. ⏲️", + "notion_integration": "Integrarea Notion", + "notion_integration_description": "Trimiteți răspunsurile direct la Notion.", + "notion_integration_is_not_configured": "Integrarea Notion nu este configurată în instanța dvs. de Formbricks.", + "notion_logo": "Logo-ul Notion", + "please_complete_mapping_fields_with_notion_property": "Vă rugăm să completați câmpurile de mapare cu proprietatea Notion", + "please_resolve_mapping_errors": "Vă rugăm să rezolvați erorile de cartografiere", + "please_select_a_database": "Vă rugăm să selectați o bază de date", + "please_select_at_least_one_mapping": "Vă rugăm să selectați cel puțin o mapare", + "que_name_of_type_cant_be_mapped_to": "\"{que_name} de tip {question_label} nu poate fi mapat la coloana {col_name} de tip {col_type}. În schimb, folosește coloana de tip {mapped_type}.\"", + "select_a_database": "Selectează baza de date", + "select_a_field_to_map": "Selectați un câmp de mapat", + "select_a_survey_question": "Selectați o întrebare de sondaj", + "update_connection": "Reconectare Notion", + "update_connection_tooltip": "Recuperați integrarea pentru a include bazele de date adăugate recent. Integrările dvs. existente vor rămâne intacte." + }, + "notion_integration_description": "Trimiteți datele în baza de date Notion", + "please_select_a_survey_error": "Vă rugăm să selectați un sondaj", + "select_at_least_one_question_error": "Vă rugăm să selectați cel puțin o întrebare", + "slack": { + "already_connected_another_survey": "Ați conectat deja un alt chestionar la acest canal.", + "channel_name": "Nume Canal", + "connect_with_slack": "Conectează-te cu Slack", + "connect_your_first_slack_channel": "Conectează-ți primul canal Slack pentru a începe.", + "connected_with_team": "Conectat cu {team}", + "create_at_least_one_channel_error": "Trebuie să creezi cel puțin un canal pentru a putea configura această integrare", + "dont_see_your_channel": "Nu vezi canalul tău?", + "link_channel": "Conectează canalul", + "link_slack_channel": "Conectează canalul Slack", + "please_select_a_channel": "Vă rugăm să selectați un canal", + "select_channel": "Selectare Canal", + "slack_integration": "Integrarea Slack", + "slack_integration_description": "Trimiteți răspunsurile direct la Slack.", + "slack_integration_is_not_configured": "Integrarea Slack nu este configurată în instanța dvs. de Formbricks.", + "slack_logo": "Logo Slack", + "slack_reconnect_button": "Reconectare", + "slack_reconnect_button_description": "Notă: Am schimbat recent integrarea noastră Slack pentru a susține și canalele private. Vă rugăm să reconectați spațiul de lucru Slack." + }, + "slack_integration_description": "Conectați instantaneu Workspace-ul dvs. Slack cu Formbricks", + "to_configure_it": "pentru a-l configura.", + "webhook_integration_description": "Declanșează Webhook-uri pe baza acțiunilor din chestionarele tale", + "webhooks": { + "add_webhook": "Adaugă Webhook", + "add_webhook_description": "Trimite datele de răspuns ale chestionarului la un punct final personalizat", + "all_current_and_new_surveys": "Toate chestionarele curente și noi", + "created_by_third_party": "Creat de o Parte Terță", + "discord_webhook_not_supported": "Webhook-urile Discord nu sunt în prezent suportate.", + "empty_webhook_message": "Webhook-urile tale vor apărea aici de îndată ce le vei adăuga. ⏲️", + "endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!", + "endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!", + "please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii", + "please_enter_a_url": "Vă rugăm să introduceți un URL", + "response_created": "Răspuns creat", + "response_finished": "Răspuns finalizat", + "response_updated": "Răspuns actualizat", + "source": "Sursă", + "test_endpoint": "Punct final de test", + "triggers": "Declanșatori", + "webhook_added_successfully": "Webhook adăugat cu succes", + "webhook_delete_confirmation": "Sigur doriți să ștergeți acest Webhook? Acest lucru va opri trimiterea oricăror notificări viitoare.", + "webhook_deleted_successfully": "Webhook șters cu succes", + "webhook_name_placeholder": "Opțional: Etichetează webhook-ul pentru identificare ușoară", + "webhook_test_failed_due_to": "Eșec Testare Webhook din cauza", + "webhook_updated_successfully": "Webhook actualizat cu succes", + "webhook_url_placeholder": "Introdu URL-ul pe care vrei să se declanșeze evenimentul" + }, + "website_or_app_integration_description": "Integrează Formbricks în website-ul sau aplicația ta", + "zapier_integration_description": "Integrează Formbricks cu peste 5000 de aplicații prin Zapier" + }, + "project": { + "api_keys": { + "access_control": "Control acces", + "add_api_key": "Adaugă Cheie API", + "api_key": "Cheie API", + "api_key_copied_to_clipboard": "Cheia API a fost copiată în clipboard", + "api_key_created": "Cheie API creată", + "api_key_deleted": "Cheie API ștearsă", + "api_key_label": "Etichetă Cheie API", + "api_key_security_warning": "Din motive de securitate, cheia API va fi afișată o singură dată după creare. Vă rugăm să o copiați imediat la destinație.", + "api_key_updated": "Cheie API actualizată", + "duplicate_access": "Accesul dublu la proiect nu este permis", + "no_api_keys_yet": "Nu aveți încă chei API", + "no_env_permissions_found": "Nu s-au găsit permisiuni pentru mediu", + "organization_access": "Accesul Organizației", + "organization_access_description": "Selectează privilegii de citire sau scriere pentru resursele la nivel de organizație.", + "permissions": "Permisiuni", + "project_access": "Acces proiect", + "secret": "Secret", + "unable_to_delete_api_key": "Imposibil de șters cheia API" + }, + "app-connection": { + "api_host_description": "Acesta este URL-ul backend-ului tău Formbricks.", + "app_connection": "Conectare aplicație", + "app_connection_description": "Conectează aplicația ta la Formbricks.", + "cache_update_delay_description": "Când faci actualizări la sondaje, contacte, acțiuni sau alte date, poate dura până la 5 minute pentru ca aceste modificări să apară în aplicația locală care rulează SDK Formbricks. Această întârziere se datorează unei limitări în sistemul nostru actual de caching. Revedem activ cache-ul și vom lansa o soluție în Formbricks 4.0.", + "cache_update_delay_title": "Modificările vor fi reflectate după 5 minute datorită memorării în cache", + "check_out_the_docs": "Consultați documentația.", + "dive_into_the_docs": "Accesați documentația.", + "does_your_widget_work": "Funcționează widgetul dvs.?", + "environment_id": "ID-ul Mediului Dvs.", + "environment_id_description": "Acest id identifică în mod unic acest mediu Formbricks.", + "environment_id_description_with_environment_id": "Folosit pentru a identifica mediul corect: {environmentId} este al tău.", + "formbricks_sdk": "SDK Formbricks", + "formbricks_sdk_connected": "SDK Formbricks este conectat", + "formbricks_sdk_not_connected": "Formbricks SDK nu este încă conectat.", + "formbricks_sdk_not_connected_description": "Conectează-ți site-ul sau aplicația cu Formbricks", + "have_a_problem": "Aveți o problemă?", + "how_to_setup": "Cum să configurezi", + "how_to_setup_description": "Urmează acești pași pentru a configura widget-ul Formbricks în aplicația ta.", + "identifying_your_users": "identificarea utilizatorilor tăi", + "if_you_are_planning_to": "Dacă planifici să", + "insert_this_code_into_the": "Insereză acest cod în", + "need_a_more_detailed_setup_guide_for": "Aveți nevoie de un ghid de configurare mai detaliat pentru", + "not_working": "Nu funcționează?", + "open_an_issue_on_github": "Deschideți o problemă pe GitHub", + "open_the_browser_console_to_see_the_logs": "Deschide consola browserului pentru a vedea jurnalele.", + "receiving_data": "Recepționare date \uD83D\uDC83\uD83D\uDD7A", + "recheck": "Re-verifică", + "scroll_to_the_top": "Derulați în partea de sus!", + "step_1": "Pasul 1: Instalează cu pnpm, npm sau yarn", + "step_2": "Pasul 2: Inițializează widget-ul", + "step_2_description": "Importați Formbricks și inițializați widgetul în componenta dumneavoastră (de exemplu, App.tsx):", + "step_3": "Pasul 3: Modul de depanare", + "switch_on_the_debug_mode_by_appending": "Activează modul de depanare prin adăugare", + "tag_of_your_app": "eticheta aplicației tale", + "to_the_url_where_you_load_the": "la adresa URL de unde încarci", + "want_to_learn_how_to_add_user_attributes": "Doriți să aflați cum să adăugați atribute ale utilizatorului, evenimente personalizate și altele?", + "you_are_done": "Ai terminat \uD83C\uDF89", + "you_can_set_the_user_id_with": "poți seta ID-ul utilizatorului cu", + "your_app_now_communicates_with_formbricks": "Aplicația ta comunică acum cu Formbricks - trimite evenimente și încarcă automat sondajele!" + }, + "general": { + "cannot_delete_only_project": "Acesta este singurul tău proiect, nu poate fi șters. Creează mai întâi un proiect nou.", + "delete_project": "Șterge proiect", + "delete_project_confirmation": "Ești sigur că dorești să ștergi {projectName}? Această acțiune nu poate fi anulată.", + "delete_project_name_includes_surveys_responses_people_and_more": "Șterge {projectName} incl. toate sondajele, răspunsurile, persoanele, acțiunile și atributele.", + "delete_project_settings_description": "Șterge proiectul cu toate sondajele, răspunsurile, persoanele, acțiunile și atributele. Aceasta nu poate fi anulată.", + "error_saving_project_information": "Eroare la salvarea informațiilor proiectului", + "only_owners_or_managers_can_delete_projects": "Doar proprietarii sau managerii pot șterge proiectele", + "project_deleted_successfully": "Proiect șters cu succes!", + "project_name_settings_description": "Schimbați numele proiectului.", + "project_name_updated_successfully": "Numele proiectului actualizat cu succes", + "recontact_waiting_time": "Timp de așteptare până la recontactare", + "recontact_waiting_time_settings_description": "Controlează cât de des pot fi utilizatorii chestionați în toate sondajele din aplicație.", + "this_action_cannot_be_undone": "Această acțiune nu poate fi anulată.", + "wait_x_days_before_showing_next_survey": "Așteaptă X zile înainte de a afișa următorul sondaj:", + "waiting_period_updated_successfully": "Perioada de așteptare actualizată cu succes", + "whats_your_project_called": "Cum se numește proiectul tău?" + }, + "languages": { + "add_language": "Adaugă limba", + "alias": "Pseudonim", + "alias_tooltip": "Aliasul este un nume alternativ pentru a identifica limba în sondajele link și SDK (opțional)", + "cannot_remove_language_warning": "Nu puteți elimina această limbă deoarece încă este folosită în aceste sondaje:", + "conflict_between_identifier_and_alias": "Există un conflict între identificatorul unei limbi adăugate și unul pentru aliasurile tale. Aliasurile și identificatorii nu pot fi identici.", + "conflict_between_selected_alias_and_another_language": "Există un conflict între aliasul selectat și o altă limbă care are acest identificator. Vă rugăm să adăugați limba cu acest identificator la proiectul dvs. pentru a evita inconsistenețele.", + "delete_language_confirmation": "Ești sigur că dorești să ștergi această limbă? Această acțiune nu poate fi anulată.", + "duplicate_language_or_language_id": "Limbă duplicată sau ID-ul limbii", + "edit_languages": "Editează limbi", + "identifier": "Identificator (ISO)", + "incomplete_translations": "Traduceri incomplete", + "language": "Limba", + "language_deleted_successfully": "Limbă ștearsă cu succes.", + "languages_updated_successfully": "Limbi actualizate cu succes", + "multi_language_surveys": "Chestionare Multilingve", + "multi_language_surveys_description": "Adăugați limbi pentru a crea chestionare multilingve.", + "no_language_found": "Nicio limbă găsită. Adăugați prima limbă mai jos.", + "please_select_a_language": "Vă rugăm să selectați o limbă", + "remove_language": "Elimină Limba", + "remove_language_from_surveys_to_remove_it_from_project": "Vă rugăm să eliminați limba din aceste chestionare pentru a o elimină din proiect.", + "search_items": "Cautare articole", + "translate": "Tradu" + }, + "look": { + "add_background_color": "Adaugă culoare fundal", + "add_background_color_description": "Adaugă o culoare de fundal containerului logo-ului.", + "app_survey_placement": "Amplasarea sondajului în aplicație", + "app_survey_placement_settings_description": "Schimbați locul în care sondajele vor fi afișate în aplicația sau site-ul dvs. web.", + "centered_modal_overlay_color": "Culoare suprapunere modală centralizată", + "email_customization": "Personalizare Email", + "email_customization_description": "Schimbați aspectul și interfața emailurilor pe care Formbricks le trimite în numele dumneavoastră.", + "enable_custom_styling": "Activează stilizarea personalizată", + "enable_custom_styling_description": "Permite utilizatorilor să suprascrie această temă în editorul de chestionare.", + "failed_to_remove_logo": "Nu s-a reușit ștergerea logo-ului", + "failed_to_update_logo": "Nu s-a reușit actualizarea logo-ului", + "formbricks_branding": "Brandingul Formbricks", + "formbricks_branding_hidden": "Brandingul Formbricks este ascuns.", + "formbricks_branding_settings_description": "Ne place să ne susțineți, dar înțelegem dacă îl dezactivați.", + "formbricks_branding_shown": "Brandingul Formbricks este afișat.", + "logo_removed_successfully": "Sigla a fost eliminată cu succes", + "logo_settings_description": "Încărcați logo-ul companiei dvs. pentru a crea brand-ul sondajelor și previzualizările linkurilor.", + "logo_updated_successfully": "Sigla a fost actualizată cu succes", + "logo_upload_failed": "Încărcarea siglei a eșuat. Vă rugăm să încercați din nou.", + "placement_updated_successfully": "Plasament actualizat cu succes", + "remove_branding_with_a_higher_plan": "Eliminați marcajul cu un plan superior", + "remove_logo": "Înlătură Logo", + "remove_logo_confirmation": "Sigur doriți să eliminați sigla?", + "replace_logo": "Înlocuiește Logo", + "reset_styling": "Resetați stilizarea", + "reset_styling_confirmation": "Sigur doriți să resetați stilul la setările implicite?", + "show_formbricks_branding_in": "Afișează Brandingul Formbricks în sondajele {type}", + "show_powered_by_formbricks": "Afișează semnătura „Susținut de Formbricks”", + "styling_updated_successfully": "Stil actualizat cu succes", + "theme": "Temă", + "theme_settings_description": "Creează o temă de stil pentru toate chestionarele. Poți activa stilizarea personalizată pentru fiecare chestionar." + }, + "tags": { + "add": "Adaugă", + "add_tag": "Adaugă Etichetă", + "count": "Număr", + "delete_tag_confirmation": "Sigur doriți să ștergeți această etichetă?", + "empty_message": "Marcați o trimitere pentru a găsi lista de etichete aici.", + "manage_tags": "Gestionați etichetele", + "manage_tags_description": "Îmbinați și eliminați etichetele de răspuns.", + "merge": "Îmbinare", + "no_tag_found": "Niciun etichetă găsită", + "search_tags": "Caută etichete...", + "tag": "Etichetă", + "tag_already_exists": "Eticheta există deja", + "tag_deleted": "Etichetă ștearsă", + "tag_updated": "Eticheta actualizată", + "tags_merged": "Etichete îmbinate" + }, + "teams": { + "manage_teams": "Gestionați echipele", + "no_teams_found": "Nicio echipă găsită", + "only_organization_owners_and_managers_can_manage_teams": "Doar proprietarii de organizație și managerii pot gestiona echipele.", + "permission": "Permisiune", + "team_name": "Nume echipă", + "team_settings_description": "Vezi care echipe pot accesa acest proiect." + } + }, + "projects_environments_organizations_not_found": "Proiecte, medii sau organizații nu găsite", + "segments": { + "add_filter_below": "Adăugați un filtru mai jos", + "add_your_first_filter_to_get_started": "Adăugați primul dvs. filtru pentru a începe", + "cannot_delete_segment_used_in_surveys": "Nu puteți șterge acest segment deoarece încă este folosit în aceste sondaje:", + "clone_and_edit_segment": "Clonează & Editează Segment", + "create_group": "Creează grup", + "create_your_first_segment": "Creați primul dvs. Segment pentru a începe", + "delete_segment": "Șterge Segment", + "desktop": "Desktop", + "devices": "Dispozitive", + "edit_segment": "Editează Segment", + "error_resetting_filters": "Eroare la resetarea filtrelor", + "error_saving_segment": "Eroare la salvarea segmentului", + "ex_fully_activated_recurring_users": "Ex. Utilizatori recurenți complet activați", + "ex_power_users": "Ex. Utilizatori avansați", + "filters_reset_successfully": "Filtre resetate cu succes", + "here": "aici", + "hide_filters": "Ascunde filtrele", + "identifying_users": "identificarea utilizatorilor", + "invalid_segment": "Segment nevalid", + "invalid_segment_filters": "Filtre invalide. Verificați filtrele și încercați din nou.", + "load_segment": "Încarcă Segment", + "most_active_users_in_the_last_30_days": "Cei mai activi utilizatori din ultimele 30 de zile", + "no_attributes_yet": "Niciun atribut încă!", + "no_filters_yet": "Nu există filtre încă!", + "no_segments_yet": "În prezent nu aveți segmente salvate.", + "person_and_attributes": "Persoană & Atribute", + "phone": "Telefon", + "please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Vă rugăm să eliminați segmentul din aceste chestionare pentru a-l șterge.", + "pre_segment_users": "Pre-segmentați utilizatorii cu filtre de atribute.", + "remove_all_filters": "Eliminați toate filtrele", + "reset_all_filters": "Resetați toate filtrele", + "save_as_new_segment": "Salvează ca segment nou", + "save_your_filters_as_a_segment_to_use_it_in_other_surveys": "Salvați filtrele dvs. ca Segment pentru a le utiliza în alte sondaje", + "segment_created_successfully": "Segment creat cu succes!", + "segment_deleted_successfully": "Segment șters cu succes!", + "segment_id": "ID segment", + "segment_saved_successfully": "Segment salvat cu succes", + "segment_updated_successfully": "Segment actualizat cu succes!", + "segments_help_you_target_users_with_same_characteristics_easily": "Segmentele vă ajută să vizați utilizatorii cu aceleași caracteristici ușor", + "target_audience": "Audiență Țintă", + "this_action_resets_all_filters_in_this_survey": "Acțiunea aceasta resetează toate filtrele în acest sondaj.", + "this_segment_is_used_in_other_surveys": "Acest segment este utilizat în alte sondaje. Faceți modificări", + "title_is_required": "Titlul este obligatoriu.", + "unknown_filter_type": "Tip de filtru necunoscut", + "unlock_segments_description": "Organizează contactele în segmente pentru a viza grupuri de utilizatori specifici", + "unlock_segments_title": "Deblocați segmentele cu un plan superior.", + "user_targeting_is_currently_only_available_when": "Targetarea utilizatorilor este disponibilă în prezent doar atunci când", + "value_cannot_be_empty": "Valoarea nu poate fi goală.", + "value_must_be_a_number": "Valoarea trebuie să fie un număr.", + "view_filters": "Vizualizați filtrele", + "where": "Unde", + "with_the_formbricks_sdk": "cu SDK Formbricks" + }, + "settings": { + "api_keys": { + "add_api_key": "Adaugă Cheie API", + "add_permission": "Adaugă permisiune", + "api_keys_description": "Gestionați cheile API pentru a accesa API-urile de administrare Formbricks" + }, + "billing": { + "1000_monthly_responses": "1.000 Răspunsuri Lunare", + "1_project": "1 Proiect", + "2000_contacts": "2.000 Contacte", + "3_projects": "3 Proiecte", + "5000_monthly_responses": "5.000 Răspunsuri Lunare", + "7500_contacts": "7.500 Contacte", + "all_integrations": "Toate integrațiile", + "annually": "Anual", + "api_webhooks": "API & Webhook-uri", + "app_surveys": "Sondaje de Aplicație", + "attribute_based_targeting": "Targetare bazată pe atribute", + "current": "Curent", + "current_plan": "Plan curent", + "current_tier_limit": "Limită curentă a nivelului", + "custom": "Personalizat & Scalare", + "custom_contacts_limit": "Limit Personalizat Contacte", + "custom_project_limit": "Limit Personalizat Proiect", + "custom_response_limit": "Limit Personalizat Răspunsuri", + "email_embedded_surveys": "Sondaje încorporate în email", + "email_follow_ups": "Urmăriri Email", + "enterprise_description": "Suport Premium și limite personalizate.", + "everybody_has_the_free_plan_by_default": "Toată lumea are planul gratuit implicit!", + "everything_in_free": "Totul în Gratuit", + "everything_in_startup": "Totul în Startup", + "free": "Gratuit", + "free_description": "Sondaje Nelimitate, Membri În Echipă și altele.", + "get_2_months_free": "Primește 2 luni gratuite", + "get_in_touch": "Contactați-ne", + "hosted_in_frankfurt": "Găzduit în Frankfurt", + "ios_android_sdks": "SDK iOS & Android pentru sondaje mobile", + "link_surveys": "Sondaje Link (Distribuibil)", + "logic_jumps_hidden_fields_recurring_surveys": "Salturi Logice, Câmpuri Ascunse, Sondaje Recurente, etc.", + "manage_card_details": "Gestionați Detaliile Cardului", + "manage_subscription": "Gestionați Abonamentul", + "monthly": "Lunar", + "monthly_identified_users": "Utilizatori Identificați Lunar", + "per_month": "pe lună", + "per_year": "pe an", + "plan_upgraded_successfully": "Planul a fost upgradat cu succes", + "premium_support_with_slas": "Suport premium cu SLA-uri", + "remove_branding": "Eliminare Branding", + "startup": "Pornire", + "startup_description": "Totul din versiunea gratuită cu funcții suplimentare.", + "switch_plan": "Schimbă Planul", + "switch_plan_confirmation_text": "Sigur doriți să treceți la planul {plan}? Vi se va percepe {price} {period}.", + "team_access_roles": "Roluri Acces Echipă", + "unable_to_upgrade_plan": "Nu se poate upgrada planul", + "unlimited_miu": "MIU Nelimitat", + "unlimited_projects": "Proiecte Nelimitate", + "unlimited_responses": "Răspunsuri nelimitate", + "unlimited_surveys": "Sondaje Nelimitate", + "unlimited_team_members": "Membri Nelimitați În Echipă", + "upgrade": "Actualizare", + "uptime_sla_99": "Disponibilitate SLA (99%)", + "website_surveys": "Sondaje ale Site-ului" + }, + "enterprise": { + "audit_logs": "Jurnale de audit", + "coming_soon": "În curând", + "contacts_and_segments": "Gestionare contacte & segmente", + "enterprise_features": "Funcții Enterprise", + "get_an_enterprise_license_to_get_access_to_all_features": "Obțineți o licență Enterprise pentru a avea acces la toate funcționalitățile.", + "keep_full_control_over_your_data_privacy_and_security": "Mențineți controlul complet asupra confidențialității și securității datelor dumneavoastră.", + "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nicio apel necesar, fără obligații: Solicitați o licență de probă gratuită de 30 de zile pentru a testa toate funcțiile prin completarea acestui formular:", + "no_credit_card_no_sales_call_just_test_it": "Nu este nevoie de card de credit. Fără apeluri de vânzări. Doar testează-l :)", + "on_request": "La cerere", + "organization_roles": "Roluri organizaționale (Administrator, Editor, Dezvoltator, etc.)", + "questions_please_reach_out_to": "Întrebări? Vă rugăm să trimiteți mesaj către", + "request_30_day_trial_license": "Solicitați o licență de încercare de 30 de zile", + "saml_sso": "SAML SSO", + "service_level_agreement": "Acord privind nivelul de servicii", + "soc2_hipaa_iso_27001_compliance_check": "Verificare conformitate SOC2, HIPAA, ISO 27001", + "sso": "SSO (Google, Microsoft, OpenID Connect)", + "teams": "Echipe & Roluri de Acces (Citiți, Citiți și Scrieți, Gestionați)", + "unlock_the_full_power_of_formbricks_free_for_30_days": "Deblocați puterea completă a Formbricks. Gratuit timp de 30 de zile.", + "your_enterprise_license_is_active_all_features_unlocked": "Licența dvs. Enterprise este activă. Toate funcțiile sunt deblocate." + }, + "general": { + "bulk_invite_warning_description": "În planul gratuit, toți membrii organizației sunt întotdeauna alocați rolului „Proprietar”.", + "cannot_delete_only_organization": "Aceasta este singura ta organizație, nu poate fi ștearsă. Creează mai întâi o nouă organizație.", + "cannot_leave_only_organization": "Nu poți părăsi această organizație deoarece este singura ta organizație. Creează mai întâi o nouă organizație.", + "copy_invite_link_to_clipboard": "Copiază linkul de invitație în clipboard", + "create_new_organization": "Creează organizație nouă", + "create_new_organization_description": "Creați o organizație nouă pentru a gestiona un alt set de proiecte.", + "customize_email_with_a_higher_plan": "Personalizați emailul cu un plan superior", + "delete_member_confirmation": "Membrii șterși vor pierde accesul la toate proiectele și sondajele organizației tale.", + "delete_organization": "Șterge Organizație", + "delete_organization_description": "Șterge organizația cu toate proiectele ei, incluzând toate sondajele, răspunsurile, persoanele, acțiunile și atributele.", + "delete_organization_warning": "Înainte de a continua cu ștergerea acestei organizații, vă rugăm să fiți conștienți de următoarele consecințe:", + "delete_organization_warning_1": "Ștergerea permanentă a tuturor proiectelor legate de această organizație.", + "delete_organization_warning_2": "Această acțiune nu poate fi anulată. Dacă e dispărută, e dispărută.", + "delete_organization_warning_3": "Vă rugăm să introduceți {organizationName} în câmpul următor pentru a confirma ștergerea definitivă a acestei organizații:", + "eliminate_branding_with_whitelabel": "Eliminați brandingul Formbricks și activați opțiuni suplimentare de personalizare white-label.", + "email_customization_preview_email_heading": "Salut {userName}", + "email_customization_preview_email_text": "Acesta este o previzualizare a e-mailului pentru a vă arăta ce logo va fi afișat în e-mailurile.", + "error_deleting_organization_please_try_again": "Eroare la ștergerea organizației. Vă rugăm să încercați din nou.", + "from_your_organization": "din organizația ta", + "invitation_sent_once_more": "Invitație trimisă din nou.", + "invite_deleted_successfully": "Invitație ștearsă cu succes", + "invited_on": "Invitat pe {date}", + "invites_failed": "Invitații eșuate", + "leave_organization": "Părăsește organizația", + "leave_organization_description": "Vei părăsi această organizație și vei pierde accesul la toate sondajele și răspunsurile. Poți să te alături din nou doar dacă ești invitat.", + "leave_organization_ok_btn_text": "Da, părăsește organizația", + "leave_organization_title": "Ești sigur?", + "logo_in_email_header": "Siglă în antetul emailului", + "logo_removed_successfully": "Sigla a fost eliminată cu succes", + "logo_saved_successfully": "Sigla a fost salvată cu succes", + "manage_members": "Gestionați membrii", + "manage_members_description": "Adăugați sau eliminați membri din organizația dvs.", + "member_deleted_successfully": "Membru șters cu succes", + "member_invited_successfully": "Membru invitat cu succes", + "once_its_gone_its_gone": "Odată ce a dispărut, a dispărut.", + "only_org_owner_can_perform_action": "Doar proprietarii organizației pot accesa această setare.", + "organization_created_successfully": "Organizație creată cu succes!", + "organization_deleted_successfully": "Organizație ștearsă cu succes!", + "organization_invite_link_ready": "Linkul de invitație al organizației tale este gata!", + "organization_name": "Nume Organizație", + "organization_name_description": "Oferiți organizației dumneavoastră un nume descriptiv.", + "organization_name_placeholder": "ex. Power Puff Girls", + "organization_name_updated_successfully": "Numele organizației actualizat cu succes", + "organization_settings": "Setări Organizație", + "please_add_a_logo": "Adaugă un logo", + "please_check_csv_file": "Vă rugăm să verificați fișierul CSV și să vă asigurați că este conform formatului nostru", + "please_save_logo_before_sending_test_email": "Vă rugăm să salvați sigla înainte de a trimite un e-mail de test.", + "remove_logo": "Înlătură siglă", + "replace_logo": "Înlocuiește sigla", + "resend_invitation_email": "Retrimite emailul de invitație", + "share_invite_link": "Distribuie Link-ul de Invitație", + "share_this_link_to_let_your_organization_member_join_your_organization": "Distribuie acest link pentru a permite membrului organizației să se alăture organizației tale:", + "test_email_sent_successfully": "Email de test trimis cu succes", + "use_multi_language_surveys_with_a_higher_plan": "Utilizați chestionare multilingve cu un plan superior", + "use_multi_language_surveys_with_a_higher_plan_description": "Sondajul utilizatorilor dvs. în diferite limbi." + }, + "notifications": { + "auto_subscribe_to_new_surveys": "Auto-abonare la sondaje noi", + "email_alerts_surveys": "Alerte email (Sondaje)", + "every_response": "Fiecare răspuns", + "every_response_tooltip": "Trimite răspunsuri complete, fără parțiale.", + "need_slack_or_discord_notifications": "Aveți nevoie de notificări Slack sau Discord", + "notification_settings_updated": "Setările notificărilor actualizate", + "set_up_an_alert_to_get_an_email_on_new_responses": "Configurați o alertă pentru a primi un email la răspunsuri noi", + "use_the_integration": "Folosiți integrarea", + "want_to_loop_in_organization_mates": "Doriți să includeți colegii din organizație", + "you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Nu vei fi abonat automat la sondajele acestei organizații de acum înainte!", + "you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Nu veți primi mai multe e-mailuri pentru răspunsurile la acest sondaj!" + }, + "profile": { + "account_deletion_consequences_warning": "Consecințele ștergerii contului", + "avatar_update_failed": "Actualizarea avatarului a eșuat. Vă rugăm să încercați din nou.", + "backup_code": "Cod de rezervă", + "change_image": "Schimbă imaginea", + "confirm_delete_account": "Șterge contul tău cu toate informațiile personale și datele tale", + "confirm_delete_my_account": "Șterge Contul Meu", + "confirm_your_current_password_to_get_started": "Confirmaţi parola curentă pentru a începe.", + "delete_account": "Șterge Cont", + "disable_two_factor_authentication": "Dezactivează autentificarea în doi pași", + "disable_two_factor_authentication_description": "Dacă este nevoie să dezactivați autentificarea în doi pași, vă recomandăm să o reactivați cât mai curând posibil.", + "each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Fiecare cod de rezervă poate fi utilizat o singură dată pentru a acorda acces fără autentificatorul tău.", + "email_change_initiated": "Cererea dvs. de schimbare a e-mailului a fost inițiată.", + "enable_two_factor_authentication": "Activează autentificarea în doi pași", + "enter_the_code_from_your_authenticator_app_below": "Introduceți codul din aplicația dvs. de autentificare mai jos.", + "file_size_must_be_less_than_10mb": "Dimensiunea fișierului trebuie să fie mai mică de 10MB.", + "invalid_file_type": "Tip de fișier invalid. Sunt permise numai fișiere JPEG, PNG și WEBP.", + "lost_access": "Acces pierdut", + "or_enter_the_following_code_manually": "Sau introduceți manual următorul cod:", + "organization_identification": "Ajutați organizația să vă identifice pe Formbricks", + "organizations_delete_message": "Ești singurul proprietar al acestor organizații, deci ele vor fi șterse și ele.", + "permanent_removal_of_all_of_your_personal_information_and_data": "Ștergerea permanentă a tuturor informațiilor și datelor tale personale", + "personal_information": "Informații personale", + "please_enter_email_to_confirm_account_deletion": "Vă rugăm să introduceți {email} în câmpul următor pentru a confirma ștergerea definitivă a contului dumneavoastră:", + "profile_updated_successfully": "Profilul dvs. a fost actualizat cu succes", + "remove_image": "Șterge imaginea", + "save_the_following_backup_codes_in_a_safe_place": "Salvează următoarele coduri de rezervă într-un loc sigur.", + "scan_the_qr_code_below_with_your_authenticator_app": "Scanați codul QR de mai jos cu aplicația dvs. de autentificare.", + "security_description": "Gestionează parola și alte setări de securitate, precum autentificarea în doi pași (2FA).", + "two_factor_authentication": "Autentificare în doi pași", + "two_factor_authentication_description": "Adăugați un strat suplimentar de securitate la contul dvs. în cazul în care parola este furată.", + "two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autentificare în doi pași activată. Introduceți codul de șase cifre din aplicația dvs. de autentificare.", + "two_factor_code": "Codul cu doi factori", + "unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior", + "update_personal_info": "Actualizează informațiile tale personale", + "upload_image": "Încărcați imagine", + "warning_cannot_delete_account": "Ești singurul proprietar al acestei organizații. Te rugăm să transferi proprietatea către un alt membru mai întâi.", + "warning_cannot_undo": "Aceasta nu poate fi anulată", + "you_must_select_a_file": "Trebuie să selectați un fișier." + }, + "teams": { + "add_members_description": "Adaugă membri în echipă și stabilește rolul lor.", + "add_projects_description": "Controlează la ce proiecte pot accesa membrii echipei.", + "all_members_added": "Toți membrii au fost adăugați la această echipă.", + "all_projects_added": "Toate proiectele au fost adăugate la această echipă.", + "are_you_sure_you_want_to_delete_this_team": "Sigur doriți să ștergeți această echipă? Aceasta va elimina și accesul la toate proiectele și sondajele asociate cu această echipă.", + "billing_role_description": "Au acces doar la informațiile de facturare.", + "bulk_invite": "Invitație în masă", + "contributor": "Contribuitor", + "create": "Creează", + "create_first_team_message": "Trebuie să creezi mai întâi o echipă.", + "create_new_team": "Creează echipă nouă", + "delete_team": "Șterge echipa", + "empty_teams_state": "Creează prima ta echipă.", + "enter_team_name": "Introduceți numele echipei", + "individual": "Individual", + "invite_member": "Invită membru", + "invite_member_description": "Adaugă colegii în această organizație.", + "manage": "Gestionați", + "manage_team": "Gestionați echipa", + "manage_team_disabled": "Doar proprietarii de organizații, managerii și administratorii de echipă pot gestiona echipele.", + "manager_role_description": "Managerii pot accesa toate proiectele și pot adăuga sau elimina membri.", + "member_role_description": "Membrii pot lucra în proiectele selectate.", + "member_role_info_message": "Pentru a oferi membrilor noi acces la un proiect, vă rugăm să-i adăugați la o Echipă mai jos. Cu Echipe puteți gestiona cine are acces la ce proiect.", + "owner_role_description": "Proprietarii au control total asupra organizației.", + "please_fill_all_member_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un nou membru.", + "please_fill_all_project_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un proiect nou.", + "read": "Citește", + "read_write": "Citire & Scriere", + "team_admin": "Administrator Echipe", + "team_created_successfully": "Echipă creată cu succes", + "team_deleted_successfully": "Echipă ștearsă cu succes.", + "team_deletion_not_allowed": "Nu aveți permisiunea să ștergeți această echipă.", + "team_name": "Nume echipă", + "team_name_settings_title": "{teamName} Setări", + "team_select_placeholder": "Caută numele echipei...", + "team_settings_description": "Gestionați membrii echipei, drepturile de acces și altele.", + "team_updated_successfully": "Echipă actualizată cu succes", + "teams": "Echipe", + "teams_description": "Asignează membri în echipe și oferă acces echipelor la proiecte.", + "unlock_teams_description": "Gestionați ce membri ai organizației au acces la proiecte și sondaje specifice.", + "unlock_teams_title": "Deblocați echipele cu un plan superior.", + "upgrade_plan_notice_message": "Deblocați rolurile organizației cu un plan superior.", + "you_are_a_member": "Ești membru" + } + }, + "surveys": { + "all_set_time_to_create_first_survey": "Ești gata! Timp să creezi primul tău chestionar", + "alphabetical": "Alfabetic", + "copy_survey": "Copiază sondajul", + "copy_survey_description": "Copiază acest sondaj într-un alt mediu", + "copy_survey_error": "Nu s-a putut copia sondajul", + "copy_survey_link_to_clipboard": "Copiază linkul chestionarului în clipboard", + "copy_survey_partially_success": "\"{success} sondaje copiate cu succes, {error} eșuate.\"", + "copy_survey_success": "\"Sondaj copiat cu succes!\"", + "delete_survey_and_responses_warning": "Sigur doriți să ștergeți acest sondaj și toate răspunsurile sale?", + "edit": { + "1_choose_the_default_language_for_this_survey": "1. Alege limba implicită pentru acest sondaj:", + "2_activate_translation_for_specific_languages": "2. Activați traducerea pentru anumite limbi:", + "add": "Adaugă +", + "add_a_delay_or_auto_close_the_survey": "Adăugați o întârziere sau închideți automat sondajul", + "add_a_four_digit_pin": "Adăugați un cod PIN din patru cifre", + "add_a_new_question_to_your_survey": "Adaugă o nouă întrebare la sondajul tău", + "add_a_variable_to_calculate": "Adaugă o variabilă pentru calcul", + "add_action_below": "Adăugați acțiune mai jos", + "add_choice_below": "Adaugă opțiunea de mai jos", + "add_color_coding": "Adăugați codificare color", + "add_color_coding_description": "Adăugați coduri de culoare roșu, portocaliu și verde la opțiuni.", + "add_column": "Adăugați coloană", + "add_condition_below": "Adăugați condiție mai jos", + "add_custom_styles": "Adăugați stiluri personalizate", + "add_delay_before_showing_survey": "Adăugați o întârziere înainte de afișarea sondajului", + "add_description": "Adăugați descriere", + "add_ending": "Adaugă finalizare", + "add_ending_below": "Adaugă finalizare mai jos", + "add_fallback": "Adaugă", + "add_fallback_placeholder": "Adaugă un substituent pentru a afișa dacă întrebarea este omisă:", + "add_hidden_field_id": "Adăugați ID câmp ascuns", + "add_highlight_border": "Adaugă bordură evidențiată", + "add_highlight_border_description": "Adaugă o margine exterioară cardului tău de sondaj.", + "add_logic": "Adaugă logică", + "add_option": "Adăugați opțiune", + "add_other": "Adăugați \"Altele\"", + "add_photo_or_video": "Adaugă fotografie sau video", + "add_pin": "Adaugă PIN", + "add_question": "Adaugă întrebare", + "add_question_below": "Adaugă întrebare mai jos", + "add_row": "Adăugați rând", + "add_variable": "Adaugă variabilă", + "address_fields": "Câmpuri Adresă", + "address_line_1": "Adresă Linie 1", + "address_line_2": "Adresă Linie 2", + "adjust_survey_closed_message": "Ajustați mesajul 'Sondaj Închis'", + "adjust_survey_closed_message_description": "Schimbați mesajul pe care îl văd vizitatorii atunci când sondajul este închis.", + "adjust_the_theme_in_the": "Ajustați tema în", + "all_other_answers_will_continue_to": "Toate celelalte răspunsuri vor continua să", + "allow_file_type": "Permite tipul de fișier", + "allow_multi_select": "Permite selectare multiplă", + "allow_multiple_files": "Permite fișiere multiple", + "allow_users_to_select_more_than_one_image": "Permite utilizatorilor să selecteze mai mult de o imagine", + "always_show_survey": "Arată întotdeauna sondajul", + "and_launch_surveys_in_your_website_or_app": "și lansați chestionare pe site-ul sau în aplicația dvs.", + "animation": "Animație", + "app_survey_description": "Incorporați un chestionar în aplicația web sau pe site-ul dvs. pentru a colecta răspunsuri.", + "assign": "Atribuire =", + "audience": "Public", + "auto_close_on_inactivity": "Închidere automată la inactivitate", + "automatically_close_survey_after": "Închideți automat sondajul după", + "automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.", + "automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.", + "automatically_closes_the_survey_at_the_beginning_of_the_day_utc": "Închide automat sondajul la începutul zilei (UTC).", + "automatically_mark_the_survey_as_complete_after": "Marcați automat sondajul ca finalizat după", + "automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Eliberați automat sondajul la începutul zilei (UTC).", + "back_button_label": "Etichetă buton \"Înapoi\"", + "background_styling": "Stilizare fundal", + "brand_color": "Culoarea brandului", + "brightness": "Luminozitate", + "button_label": "Etichetă buton", + "button_to_continue_in_survey": "Buton pentru a continua în sondaj", + "button_to_link_to_external_url": "Buton pentru a face legătura la un URL extern", + "button_url": "URL Buton", + "cal_username": "Utilizator Cal.com sau utilizator/eveniment", + "calculate": "Calculați", + "capture_a_new_action_to_trigger_a_survey_on": "Capturează o acțiune nouă pentru a declanșa un sondaj.", + "capture_new_action": "Capturați acțiune nouă", + "card_arrangement_for_survey_type_derived": "Aranjament de carduri pentru sondaje de tip {surveyTypeDerived}", + "card_background_color": "Culoarea de fundal a cardului", + "card_border_color": "Culoarea bordurii cardului", + "card_styling": "Stilizare card", + "casual": "Casual", + "caution_edit_duplicate": "Duplică & editează", + "caution_edit_published_survey": "Editează un chestionar publicat?", + "caution_explanation_intro": "Înțelegem că s-ar putea să doriți totuși să faceți modificări. Iată ce se întâmplă dacă o faceți:", + "caution_explanation_new_responses_separated": "Răspunsurile înainte de schimbare pot să nu fie sau să fie incluse doar parțial în rezumatul sondajului.", + "caution_explanation_only_new_responses_in_summary": "Toate datele, inclusiv răspunsurile anterioare, rămân disponibile ca descărcare pe pagina de rezumat a sondajului.", + "caution_explanation_responses_are_safe": "Răspunsurile mai vechi și mai noi se amestecă, ceea ce poate duce la rezumate de date înșelătoare.", + "caution_recommendation": "Aceasta poate cauza inconsistențe de date în rezumatul sondajului. Vă recomandăm să duplicați sondajul în schimb.", + "caution_text": "Schimbările vor duce la inconsecvențe", + "centered_modal_overlay_color": "Culoare suprapunere modală centralizată", + "change_anyway": "Schimbă oricum", + "change_background": "Schimbați fundalul", + "change_question_type": "Schimbă tipul întrebării", + "change_survey_type": "Schimbarea tipului chestionarului afectează accesul existent", + "change_the_background_color_of_the_card": "Schimbați culoarea de fundal a cardului.", + "change_the_background_color_of_the_input_fields": "Schimbați culoarea de fundal a câmpurilor de introducere.", + "change_the_background_to_a_color_image_or_animation": "Schimbați fundalul cu o culoare, imagine sau animație.", + "change_the_border_color_of_the_card": "Schimbați culoarea bordurii cardului.", + "change_the_border_color_of_the_input_fields": "Schimbați culoarea bordurii câmpurilor de introducere.", + "change_the_border_radius_of_the_card_and_the_inputs": "Schimbați raza de rotunjire a cardului și a câmpurilor de introducere.", + "change_the_brand_color_of_the_survey": "Schimbați culoarea brandului chestionarului", + "change_the_placement_of_this_survey": "Schimbă amplasarea acestui sondaj.", + "change_the_question_color_of_the_survey": "Schimbați culoarea întrebării chestionarului.", + "changes_saved": "Modificările au fost salvate", + "changing_survey_type_will_remove_existing_distribution_channels": "Schimbarea tipului chestionarului va afecta modul în care acesta poate fi distribuit. Dacă respondenții au deja linkuri de acces pentru tipul curent, aceștia ar putea pierde accesul după schimbare.", + "character_limit_toggle_description": "Limitați cât de scurt sau lung poate fi un răspuns.", + "character_limit_toggle_title": "Adăugați limite de caractere", + "checkbox_label": "Etichetă casetă de selectare", + "choose_the_actions_which_trigger_the_survey": "Alegeți acțiunile care declanșează sondajul.", + "choose_where_to_run_the_survey": "Alegeți unde să rulați chestionarul.", + "city": "Oraș", + "close_survey_on_date": "Închide sondajul la dată", + "close_survey_on_response_limit": "Închideți sondajul la limită de răspunsuri", + "color": "Culoare", + "column_used_in_logic_error": "Această coloană este folosită în logica întrebării {questionIndex}. Vă rugăm să o eliminați din logică mai întâi.", + "columns": "Coloane", + "company": "Companie", + "company_logo": "Sigla companiei", + "completed_responses": "răspunsuri parțiale sau finalizate", + "concat": "Concat +", + "conditional_logic": "Logică condițională", + "confirm_default_language": "Confirmați limba implicită", + "confirm_survey_changes": "Confirmă modificările sondajului", + "contact_fields": "C�mpuri de contact", + "contains": "Conține", + "continue_to_settings": "Continuă către Setări", + "control_which_file_types_can_be_uploaded": "Controlează ce tipuri de fișiere pot fi încărcate.", + "convert_to_multiple_choice": "Convertiți la alegere multiplă", + "convert_to_single_choice": "Convertiți la alegere unică", + "country": "Țară", + "create_group": "Creează grup", + "create_your_own_survey": "Creează-ți propriul chestionar", + "css_selector": "Selector CSS", + "custom_hostname": "Gazdă personalizată", + "darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.", + "date_format": "Format dată", + "days_before_showing_this_survey_again": "zile înainte de a afișa din nou acest sondaj.", + "decide_how_often_people_can_answer_this_survey": "Decide cât de des pot răspunde oamenii la acest sondaj", + "delete_choice": "Șterge alegerea", + "description": "Descriere", + "disable_the_visibility_of_survey_progress": "Dezactivați vizibilitatea progresului sondajului", + "display_an_estimate_of_completion_time_for_survey": "Afișează o estimare a timpului de finalizare pentru sondaj", + "display_number_of_responses_for_survey": "Afișează numărul de răspunsuri pentru sondaj", + "divide": "Împarte /", + "does_not_contain": "Nu conține", + "does_not_end_with": "Nu se termină cu", + "does_not_equal": "Nu este egal", + "does_not_include_all_of": "Nu include toate", + "does_not_include_one_of": "Nu include una dintre", + "does_not_start_with": "Nu începe cu", + "edit_recall": "Editează Amintirea", + "edit_translations": "Editează traducerile {lang}", + "enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite participanților să schimbe limba sondajului în orice moment în timpul sondajului.", + "enable_recaptcha_to_protect_your_survey_from_spam": "Protecția împotriva spamului folosește reCAPTCHA v3 pentru a filtra răspunsurile de spam.", + "enable_spam_protection": "Protecția împotriva spamului", + "end_screen_card": "Ecran final card", + "ending_card": "Cardul de finalizare", + "ending_card_used_in_logic": "Această carte de încheiere este folosită în logica întrebării {questionIndex}.", + "ends_with": "Se termină cu", + "equals": "Egal", + "equals_one_of": "Egal unu dintre", + "error_publishing_survey": "A apărut o eroare în timpul publicării sondajului.", + "error_saving_changes": "Eroare la salvarea modificărilor", + "even_after_they_submitted_a_response_e_g_feedback_box": "Chiar și după ce au furnizat un răspuns (de ex. Cutia de Feedback)", + "everyone": "Toată lumea", + "fallback_for": "Varianta de rezervă pentru", + "fallback_missing": "Rezerva lipsă", + "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.", + "field_name_eg_score_price": "Nume câmp, de exemplu, scor, preț", + "first_name": "Prenume", + "five_points_recommended": "5 puncte (recomandat)", + "follow_ups": "Urmăriri", + "follow_ups_delete_modal_text": "Sigur doriți să ștergeți acest follow-up?", + "follow_ups_delete_modal_title": "Ștergeți urmărirea?", + "follow_ups_empty_description": "Trimite mesaje respondentilor, ție sau colegilor de echipă.", + "follow_ups_empty_heading": "Trimitere automată de urmăriri", + "follow_ups_ending_card_delete_modal_text": "Această cartă de sfârșit este folosită în urmăriri ulterioare. Ștergerea sa o va elimina din toate urmăriri ulterioare. Ești sigur că vrei să o ștergi?", + "follow_ups_ending_card_delete_modal_title": "Șterge cardul de finalizare?", + "follow_ups_hidden_field_error": "Câmpul ascuns este utilizat într-un follow-up. Vă rugăm să îl eliminați mai întâi din follow-up.", + "follow_ups_item_ending_tag": "Finalizare", + "follow_ups_item_issue_detected_tag": "Problemă detectată", + "follow_ups_item_response_tag": "Orice răspuns", + "follow_ups_item_send_email_tag": "Trimite email", + "follow_ups_modal_action_attach_response_data_description": "Adăugați datele răspunsului la sondaj la urmărire", + "follow_ups_modal_action_attach_response_data_label": "Atașează datele răspunsului", + "follow_ups_modal_action_body_label": "Corp", + "follow_ups_modal_action_body_placeholder": "Corpul emailului", + "follow_ups_modal_action_email_content": "Conținut email", + "follow_ups_modal_action_email_settings": "Setări email", + "follow_ups_modal_action_from_description": "Adresă de email de la care se trimite emailul", + "follow_ups_modal_action_from_label": "De la", + "follow_ups_modal_action_label": "Acțiune", + "follow_ups_modal_action_replyTo_description": "Dacă destinatarul apasă pe răspunde, următoarea adresă de e-mail îl va primi", + "follow_ups_modal_action_replyTo_label": "Răspunde la", + "follow_ups_modal_action_subject": "Mulțumim pentru răspunsurile dumneavoastră!", + "follow_ups_modal_action_subject_label": "Subiect", + "follow_ups_modal_action_subject_placeholder": "Subiectul emailului", + "follow_ups_modal_action_to_description": "Adresă de email către care se trimite emailul", + "follow_ups_modal_action_to_label": "Către", + "follow_ups_modal_action_to_warning": "Nu s-a detectat niciun câmp de e-mail în sondaj", + "follow_ups_modal_create_heading": "Creați o nouă urmărire", + "follow_ups_modal_edit_heading": "Editează acest follow-up", + "follow_ups_modal_edit_no_id": "Nu a fost furnizat un ID de urmărire al chestionarului, nu pot actualiza urmărirea chestionarului", + "follow_ups_modal_name_label": "Numele urmăririi", + "follow_ups_modal_name_placeholder": "Denumirea urmăririi tale", + "follow_ups_modal_subheading": "Trimite mesaje respondentilor, ție sau colegilor de echipă", + "follow_ups_modal_trigger_description": "Când ar trebui să fie declanșat acest follow-up?", + "follow_ups_modal_trigger_label": "Declanșator", + "follow_ups_modal_trigger_type_ending": "Respondentul vede un sfârșit specific", + "follow_ups_modal_trigger_type_ending_select": "Selectează finalurile:", + "follow_ups_modal_trigger_type_ending_warning": "Nu s-au găsit finalizări în sondaj!", + "follow_ups_modal_trigger_type_response": "Respondent finalizează sondajul", + "follow_ups_new": "Urmărire nouă", + "follow_ups_upgrade_button_text": "Actualizați pentru a activa urmărările", + "form_styling": "Stilizare formular", + "formbricks_sdk_is_not_connected": "SDK Formbricks nu este conectat", + "four_points": "4 puncte", + "heading": "Titlu", + "hidden_field_added_successfully": "Câmp ascuns adăugat cu succes", + "hide_advanced_settings": "Ascunde setări avansate", + "hide_back_button": "Ascunde butonul 'Înapoi'", + "hide_back_button_description": "Nu afișa butonul Înapoi în sondaj", + "hide_logo": "Ascunde logo", + "hide_progress_bar": "Ascunde bara de progres", + "hide_the_logo_in_this_specific_survey": "Ascunde logo-ul în acest chestionar specific", + "hostname": "Nume gazdă", + "how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Cât de funky doriți să fie cardurile dumneavoastră în sondajele de tip {surveyTypeDerived}", + "if_you_need_more_please": "Dacă aveți nevoie de mai multe, vă rugăm să", + "if_you_really_want_that_answer_ask_until_you_get_it": "Dacă într-adevăr îți dorești acel răspuns, întreabă până îl primești.", + "ignore_waiting_time_between_surveys": "Ignoră perioada de așteptare între sondaje", + "image": "Imagine", + "includes_all_of": "Include toate\",\"contextDescription\":\"Part of a survey completion screen referencing conditions met when all items are included\"}", + "includes_one_of": "Include una dintre", + "initial_value": "Valoare inițială", + "inner_text": "Text Interior", + "input_border_color": "Culoarea graniței câmpului de introducere", + "input_color": "Culoarea câmpului de introducere", + "invalid_targeting": "\"Targetare nevalidă: Vă rugăm să verificați filtrele pentru audiență\"", + "invalid_video_url_warning": "Vă rugăm să introduceți un URL valid de YouTube, Vimeo sau Loom. În prezent nu susținem alți furnizori de găzduire video.", + "invalid_youtube_url": "URL YouTube invalid", + "is_accepted": "Este acceptat", + "is_after": "Este după", + "is_any_of": "Este vreunul dintre", + "is_before": "Este înainte", + "is_booked": "Este rezervat", + "is_clicked": "Este apăsat", + "is_completely_submitted": "Este complet trimis", + "is_empty": "Este gol", + "is_not_empty": "Nu este gol", + "is_not_set": "Nu este setat", + "is_partially_submitted": "Este parțial trimis", + "is_set": "Este setat", + "is_skipped": "Este sărit", + "is_submitted": "Este trimis", + "jump_to_question": "Sări la întrebare", + "keep_current_order": "Păstrați ordinea actuală", + "keep_showing_while_conditions_match": "Continuă să afișezi cât timp condițiile se potrivesc", + "key": "Cheie", + "last_name": "Nume de familie", + "let_people_upload_up_to_25_files_at_the_same_time": "Permiteți utilizatorilor să încarce până la 25 de fișiere simultan.", + "limit_file_types": "Limitare tipuri de fișiere", + "limit_the_maximum_file_size": "Limitează dimensiunea maximă a fișierului", + "limit_upload_file_size_to": "Limitați dimensiunea fișierului de încărcare la", + "link_survey_description": "Partajați un link către o pagină de chestionar sau încorporați-l într-o pagină web sau email.", + "load_segment": "Încarcă segment", + "logic_error_warning": "Schimbarea va provoca erori de logică", + "logic_error_warning_text": "Schimbarea tipului de întrebare va elimina condițiile de logică din această întrebare", + "long_answer": "Răspuns lung", + "lower_label": "Etichetă inferioară", + "manage_languages": "Gestionați Limbile", + "max_file_size": "Dimensiune maximă fișier", + "max_file_size_limit_is": "Limita dimensiunii maxime a fișierului este", + "multiply": "Înmulțire *", + "needed_for_self_hosted_cal_com_instance": "Necesar pentru un exemplu autogăzduit Cal.com", + "next_button_label": "Etichetă buton \"Următorul\"", + "next_question": "Întrebarea următoare", + "no_hidden_fields_yet_add_first_one_below": "Nu există încă câmpuri ascunse. Adăugați primul mai jos.", + "no_images_found_for": "Nicio imagine găsită pentru ''{query}\"", + "no_languages_found_add_first_one_to_get_started": "Nu s-au găsit limbi. Adaugă prima pentru a începe.", + "no_option_found": "Nicio opțiune găsită", + "no_variables_yet_add_first_one_below": "Nu există variabile încă. Adăugați prima mai jos.", + "number": "Număr", + "once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Odată setată, limba implicită pentru acest sondaj poate fi schimbată doar dezactivând opțiunea multi-limbă și ștergând toate traducerile.", + "only_display_the_survey_to_a_subset_of_the_users": "Afișați sondajul doar unui subset al utilizatorilor", + "only_lower_case_letters_numbers_and_underscores_are_allowed": "Sunt permise doar litere mici, numere și caractere de subliniere.", + "only_people_who_match_your_targeting_can_be_surveyed": "Numai persoanele care se potrivesc cu țintirea dvs. pot fi chestionate.", + "option_idx": "Opțiunea {choiceIndex}", + "option_used_in_logic_error": "Această opțiune este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.", + "optional": "Opțional", + "options": "Opțiuni", + "override_theme_with_individual_styles_for_this_survey": "Suprascrie tema cu stiluri individuale pentru acest sondaj.", + "overwrite_placement": "Suprascriere amplasare", + "overwrite_the_global_placement_of_the_survey": "Suprascrie amplasarea globală a sondajului", + "overwrites_waiting_period_between_surveys_to_x_days": "Suprascrie perioada de așteptare dintre sondaje la {days} zi(le).", + "pick_a_background_from_our_library_or_upload_your_own": "Alege un fundal din biblioteca noastră sau încarcă unul propriu.", + "picture_idx": "Poză {idx}", + "pin_can_only_contain_numbers": "PIN-ul poate conține doar numere.", + "pin_must_be_a_four_digit_number": "PIN-ul trebuie să fie un număr de patru cifre", + "please_enter_a_file_extension": "Vă rugăm să introduceți o extensie de fișier.", + "please_set_a_survey_trigger": "Vă rugăm să setați un declanșator sondaj", + "please_specify": "Vă rugăm să specificați", + "prevent_double_submission": "Prevenire trimitere dublă", + "prevent_double_submission_description": "Permite doar 1 răspuns per adresă de email.", + "protect_survey_with_pin": "Protejați sondajul cu un PIN", + "protect_survey_with_pin_description": "Doar utilizatorii care cunosc PIN-ul pot accesa sondajul.", + "publish": "Publică", + "question": "Întrebare", + "question_color": "Culoarea întrebării", + "question_deleted": "Întrebare ștearsă.", + "question_duplicated": "Întrebare duplicată.", + "question_id_updated": "ID întrebare actualizat", + "question_used_in_logic": "Această întrebare este folosită în logica întrebării {questionIndex}.", + "randomize_all": "Randomizează tot", + "randomize_all_except_last": "Randomizează tot cu excepția ultimului", + "range": "Interval", + "recontact_options": "Opțiuni de recontactare", + "redirect_thank_you_card": "Redirecționează cardul de mulțumire", + "redirect_to_url": "Redirecționează către URL", + "redirect_to_url_not_available_on_free_plan": "\"Redirecționarea către URL nu este disponibilă în planul gratuit\"", + "release_survey_on_date": "Eliberați sondajul la dată", + "remove_description": "Eliminați descrierea", + "remove_translations": "Eliminați traducerile", + "require_answer": "Cere Răspuns", + "required": "Obligatoriu", + "reset_to_theme_styles": "Resetare la stilurile temei", + "reset_to_theme_styles_main_text": "Sigur doriți să resetați stilul la stilurile de temă? Acest lucru va elimina toate stilizările personalizate.", + "response_limit_can_t_be_set_to_0": "Limitul de răspunsuri nu poate fi setat la 0", + "response_limit_needs_to_exceed_number_of_received_responses": "Limita răspunsurilor trebuie să depășească numărul de răspunsuri primite ({responseCount}).", + "response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.", + "response_options": "Opțiuni Răspuns", + "roundness": "Rotunjirea", + "row_used_in_logic_error": "Această linie este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.", + "rows": "Rânduri", + "save_and_close": "Salvează & Închide", + "scale": "Scală", + "search_for_images": "Căutare de imagini", + "seconds_after_trigger_the_survey_will_be_closed_if_no_response": "secunde după declanșare sondajul va fi închis dacă nu există niciun răspuns", + "seconds_before_showing_the_survey": "secunde înainte de afișarea sondajului", + "select_or_type_value": "Selectați sau introduceți valoarea", + "select_ordering": "Selectează ordonarea", + "select_saved_action": "Selectați acțiunea salvată", + "select_type": "Selectați tipul", + "send_survey_to_audience_who_match": "Trimiteți sondajul către publicul care se potrivește...", + "send_your_respondents_to_a_page_of_your_choice": "Trimiteți respondenții către o pagină la alegerea dumneavoastră", + "set_the_global_placement_in_the_look_feel_settings": "Setați amplasarea globală în setările Aspect & Stil.", + "seven_points": "7 puncte", + "show_advanced_settings": "Afișați setările avansate", + "show_button": "Afișează butonul", + "show_language_switch": "Afișează comutatorul de limbă", + "show_multiple_times": "Afișează de mai multe ori", + "show_only_once": "Afișează doar o dată", + "show_survey_maximum_of": "Afișează sondajul de maxim", + "show_survey_to_users": "Afișați sondajul la % din utilizatori", + "show_to_x_percentage_of_targeted_users": "Afișați la {percentage}% din utilizatorii vizați", + "simple": "Simplu", + "six_points": "6 puncte", + "skip_button_label": "Etichetă buton \"Omitere\"", + "smiley": "Smiley", + "spam_protection_note": "Protecția împotriva spamului nu funcționează pentru sondajele afișate folosind SDK-urile iOS, React Native și Android. Va întrerupe sondajul.", + "spam_protection_threshold_description": "Setați valoarea între 0 și 1, răspunsurile sub această valoare vor fi respinse.", + "spam_protection_threshold_heading": "Pragul răspunsurilor", + "star": "Stea", + "starts_with": "Începe cu", + "state": "Stare", + "straight": "Drept", + "style_the_question_texts_descriptions_and_input_fields": "Stilizare textele întrebărilor, descrierile și câmpurile de introducere", + "style_the_survey_card": "Stilizare card sondaj", + "styling_set_to_theme_styles": "Stilizare setată la stilurile temei", + "subheading": "Subtitlu", + "subtract": "Scade -", + "suggest_colors": "Sugerați culori", + "survey_completed_heading": "Sondaj Completat", + "survey_completed_subheading": "Acest sondaj gratuit și open-source a fost închis", + "survey_display_settings": "Setări de afișare a sondajului", + "survey_placement": "Amplasarea sondajului", + "survey_trigger": "Declanșator sondaj", + "switch_multi_lanugage_on_to_get_started": "Comutați pe modul multilingv pentru a începe \uD83D\uDC49", + "targeted": "Ţintite", + "ten_points": "10 puncte", + "the_survey_will_be_shown_multiple_times_until_they_respond": "Sondajul va fi afișat de mai multe ori până când vor răspunde", + "the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Sondajul va fi afișat o singură dată, chiar dacă persoana nu răspunde.", + "then": "Apoi", + "this_action_will_remove_all_the_translations_from_this_survey": "Această acțiune va elimina toate traducerile din acest sondaj.", + "this_extension_is_already_added": "Această extensie este deja adăugată.", + "this_file_type_is_not_supported": "Acest tip de fișier nu este acceptat.", + "this_setting_overwrites_your": "Această setare suprascrie", + "three_points": "3 puncte", + "times": "ori", + "to_keep_the_placement_over_all_surveys_consistent_you_can": "Pentru a menține amplasarea consecventă pentru toate sondajele, puteți", + "trigger_survey_when_one_of_the_actions_is_fired": "Declanșați sondajul atunci când una dintre acțiuni este declanșată...", + "try_lollipop_or_mountain": "Încercați „lollipop” sau „mountain”...", + "type_field_id": "ID câmp tip", + "unlock_targeting_description": "Vizează grupuri specifice de utilizatori pe baza atributelor sau a informațiilor despre dispozitiv", + "unlock_targeting_title": "Deblocați țintirea cu un plan superior", + "unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?", + "until_they_submit_a_response": "Până când vor furniza un răspuns", + "upgrade_notice_description": "Creați sondaje multilingve și deblocați multe alte caracteristici", + "upgrade_notice_title": "Deblocați sondajele multilingve cu un plan superior", + "upload": "Încărcați", + "upload_at_least_2_images": "Încărcați cel puțin 2 imagini", + "upper_label": "Etichetă superioară", + "url_filters": "Filtre URL", + "url_not_supported": "URL nesuportat", + "use_with_caution": "Folosește cu precauție", + "variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} este folosit în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.", + "variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.", + "variable_name_must_start_with_a_letter": "Numele variabilei trebuie să înceapă cu o literă.", + "verify_email_before_submission": "Verifică emailul înainte de trimitere", + "verify_email_before_submission_description": "Permite doar persoanelor cu un email real să răspundă.", + "wait": "Așteptați", + "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Așteptați câteva secunde după declanșare înainte de a afișa sondajul", + "waiting_period": "perioada de așteptare", + "welcome_message": "Mesaj de bun venit", + "when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Când condițiile se potrivesc, timpul de așteptare va fi ignorat și sondajul va fi afișat.", + "without_a_filter_all_of_your_users_can_be_surveyed": "Fără un filtru, toți utilizatorii pot fi chestionați.", + "you_have_not_created_a_segment_yet": "Nu ai creat încă un segment", + "you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "Trebuie să aveți două sau mai multe limbi configurate în proiectul dvs. pentru a lucra cu traducerile.", + "your_description_here_recall_information_with": "Descrierea ta aici. Reamintiți informațiile cu @", + "your_question_here_recall_information_with": "Întrebarea ta aici. Reamintiți informațiile cu @", + "your_web_app": "Aplicația dumneavoastră web", + "zip": "Cod Poștal" + }, + "error_deleting_survey": "A apărut o eroare în timpul ștergerii sondajului", + "filter": { + "complete_and_partial_responses": "Răspunsuri complete și parțiale", + "complete_responses": "Răspunsuri complete", + "partial_responses": "Răspunsuri parțiale" + }, + "new_survey": "Chestionar Nou", + "no_surveys_created_yet": "Nu au fost create încă chestionare", + "open_options": "Opțiuni deschise", + "preview_survey_in_a_new_tab": "Previzualizare chestionar în alt tab", + "read_only_user_not_allowed_to_create_survey_warning": "\"Ca utilizator cu acces doar pentru citire, nu aveți voie să creați sondaje. Vă rugăm să solicitați unui utilizator cu acces de editare să creeze un sondaj sau unui manager să vă upgradeze rolul.\"", + "relevance": "Relevanță", + "responses": { + "address_line_1": "Adresă Linie 1", + "address_line_2": "Adresă Linie 2", + "an_error_occurred_creating_a_new_note": "A apărut o eroare la crearea unei noi note", + "an_error_occurred_deleting_the_tag": "A apărut o eroare la ștergerea etichetei", + "an_error_occurred_resolving_a_note": "A apărut o eroare la rezolvarea unei note", + "an_error_occurred_updating_a_note": "A apărut o eroare la actualizarea unei note", + "browser": "Browser", + "city": "Oraș", + "company": "Companie", + "completed": "Finalizat ✅", + "country": "Țară", + "delete_response_confirmation": "Aceasta va șterge răspunsul la sondaj, inclusiv toate răspunsurile, etichetele, documentele atașate și metadatele răspunsului.", + "device": "Dispozitiv", + "device_info": "Informații despre dispozitiv", + "email": "Email", + "error_downloading_responses": "A apărut o eroare la descărcarea răspunsurilor", + "first_name": "Prenume", + "how_to_identify_users": "Cum să identifici utilizatorii", + "last_name": "Nume de familie", + "not_completed": "Necompletat ⏳", + "os": "SO", + "person_attributes": "Atribute persoană", + "phone": "Telefon", + "resolve": "Rezolvă", + "respondent_skipped_questions": "Respondenții au sărit peste aceste întrebări.", + "response_deleted_successfully": "Răspuns șters cu succes.", + "single_use_id": "IdentificatorUnicFolositOdată", + "source": "Sursă", + "state_region": "Stat / Regiune", + "survey_closed": "Chestionar închis", + "tag_already_exists": "Eticheta există deja", + "this_response_is_in_progress": "Acest răspuns este în curs de procesare.", + "zip_post_code": "Cod poștal" + }, + "search_by_survey_name": "Căutare după nume chestionar", + "share": { + "anonymous_links": { + "custom_single_use_id_description": "Dacă nu criptați ID-urile de unică folosință, orice valoare pentru „suid=...” funcționează pentru un răspuns.", + "custom_single_use_id_title": "Puteți seta orice valoare ca ID unic în URL", + "custom_start_point": "Punct de start personalizat", + "data_prefilling": "Precompletare date", + "description": "Răspunsurile provenite de la aceste linkuri vor fi anonime", + "disable_multi_use_link_modal_button": "Dezactivează linkul de utilizare multiplă", + "disable_multi_use_link_modal_description": "Dezactivarea linkului pentru utilizare multiplă va împiedica pe oricine să trimită un răspuns prin link.", + "disable_multi_use_link_modal_description_subtext": "Aceasta va strica, de asemenea, orice încorporare activă pe site-uri web, e-mailuri, rețele sociale și coduri QR care utilizează acest link de utilizare multiplă.", + "disable_multi_use_link_modal_title": "Ești sigur? Asta poate strica încorporările active", + "disable_single_use_link_modal_button": "Dezactivează linkurile de unică folosință", + "disable_single_use_link_modal_description": "Dacă ați distribuit linkuri de unică folosință, participanții nu vor mai putea răspunde la sondaj.", + "generate_and_download_links": "Generează și descarcă legături", + "generate_links_error": "Link-urile de unică folosință nu au putut fi generate. Vă rugăm să lucrați direct cu API-ul", + "multi_use_link": "Link de utilizare multiplă", + "multi_use_link_description": "Colectați răspunsuri multiple de la respondenți anonimi cu un singur link.", + "multi_use_powers_other_channels_description": "Dacă îl dezactivați, aceste alte canale de distribuție vor fi dezactivate și ele.", + "multi_use_powers_other_channels_title": "Acest link alimentează încorporările pe Website, încorporările prin Email, distribuirea pe Social Media și codurile QR.", + "nav_title": "Linkuri anonime", + "number_of_links_label": "Număr de link-uri (1 - 5.000)", + "single_use_link": "Linkuri de unică folosință", + "single_use_link_description": "Permite doar un răspuns per link al chestionarului.", + "single_use_links": "Linkuri de unică folosință", + "source_tracking": "Urmărirea sursei", + "url_encryption_description": "Dezactivați doar dacă trebuie să setați un ID unic personalizat.", + "url_encryption_label": "Criptarea URL pentru ID unic de utilizare" + }, + "dynamic_popup": { + "alert_button": "Editează chestionar", + "alert_description": "Acest sondaj este configurat în prezent ca un sondaj cu link, care nu suportă pop-up-uri dinamice. Puteți schimba acest lucru în fila de setări a editorului de sondaje.", + "alert_title": "Schimbați tipul sondajului la in-app", + "attribute_based_targeting": "Targetare bazată pe atribute", + "code_no_code_triggers": "Declanșatori de tip „cu cod” și „fără cod”", + "description": "Sondajele Formbricks pot fi integrate ca un pop-up, pe baza interacțiunii utilizatorului.", + "nav_title": "Dinamic (Pop-up)", + "recontact_options": "Opțiuni de recontactare" + }, + "embed_on_website": { + "description": "Sondajele Formbricks pot fi integrate ca elemente statice.", + "embed_code_copied_to_clipboard": "Codul de încorporare a fost copiat în clipboard!", + "embed_mode": "Modul de integrare", + "embed_mode_description": "Incorporează sondajul tău cu un design minimalist, eliminând spațiul și fundalul.", + "nav_title": "Incorporare pe site web" + }, + "personal_links": { + "create_and_manage_segments": "Creați și gestionați segmentele dvs. sub Contacte > Segmente", + "description": "Generează linkuri personale pentru un segment și corelează răspunsurile la sondaje cu fiecare contact.", + "expiry_date_description": "Odată ce link-ul expiră, destinatarul nu mai poate răspunde la sondaj.", + "expiry_date_optional": "Dată de expirare (opțional)", + "generate_and_download_links": "Generează și descarcă legături", + "generating_links": "Generarea link-urilor", + "generating_links_toast": "Generarea linkurilor, descărcarea va începe în curând…", + "links_generated_success_toast": "Linkuri generate cu succes, descărcarea va începe în curând.", + "nav_title": "Linkuri personale", + "no_segments_available": "Nu există segmente disponibile", + "select_segment": "Selectați segmentul", + "upgrade_prompt_description": "Generează linkuri personale pentru un segment și leagă răspunsurile la sondaj de fiecare contact.", + "upgrade_prompt_title": "Folosește linkuri personale cu un plan superior", + "work_with_segments": "Linkuri personale funcționează cu segmente." + }, + "send_email": { + "copy_embed_code": "Copiază codul de inserare", + "description": "Inserați sondajul dvs. într-un e-mail pentru a obține răspunsuri de la audiența dvs.", + "email_preview_tab": "Previzualizare Email", + "email_sent": "Email trimis!", + "email_subject_label": "Subiect", + "email_to_label": "Către", + "embed_code_copied_to_clipboard": "Codul de încorporare a fost copiat în clipboard!", + "embed_code_copied_to_clipboard_failed": "Copierea a eșuat, vă rugăm să încercați din nou", + "embed_code_tab": "Cod de integrare", + "formbricks_email_survey_preview": "Previzualizare Chestionar Email Formbricks", + "nav_title": "Incorporare email", + "send_preview": "Trimite previzualizare", + "send_preview_email": "Trimite email de previzualizare" + }, + "share_view_title": "Distribuie prin", + "social_media": { + "description": "Obține răspunsuri de la contactele tale pe diferite rețele sociale.", + "source_tracking_enabled": "Urmărirea sursei activată", + "source_tracking_enabled_alert_description": "Când partajați din acest dialog, rețeaua socială va fi adăugată la link-ul sondajului astfel încât să știți ce răspunsuri au venit prin fiecare rețea.", + "title": "Rețele Sociale" + } + }, + "summary": { + "added_filter_for_responses_where_answer_to_question": "Filtru adăugat pentru răspunsuri unde răspunsul la întrebarea {questionIdx} este {filterComboBoxValue} - {filterValue}", + "added_filter_for_responses_where_answer_to_question_is_skipped": "Filtru adăugat pentru răspunsuri unde răspunsul la întrebarea {questionIdx} este omis", + "all_responses_csv": "Toate răspunsurile (CSV)", + "all_responses_excel": "Toate răspunsurile (Excel)", + "all_time": "Pe parcursul întregii perioade", + "almost_there": "Aproape gata! Instalați widgetul pentru a începe să primiți răspunsuri.", + "average": "Medie", + "completed": "Finalizat", + "completed_tooltip": "Număr de ori când sondajul a fost completat.", + "configure_alerts": "Configurează alertele", + "congrats": "Felicitări! Sondajul dumneavoastră este activ.", + "connect_your_website_or_app_with_formbricks_to_get_started": "Conectează-ți site-ul sau aplicația cu Formbricks pentru a începe.", + "custom_range": "Interval personalizat...", + "delete_all_existing_responses_and_displays": "Șterge toate răspunsurile și afișările existente", + "download_qr_code": "Descărcare cod QR", + "drop_offs": "Renunțări", + "drop_offs_tooltip": "Număr de ori când sondajul a fost început dar nu a fost finalizat.", + "failed_to_copy_link": "Nu s-a putut copia legătura", + "filter_added_successfully": "Filtru adăugat cu succes", + "filter_updated_successfully": "Filtru actualizat cu succes", + "filtered_responses_csv": "Răspunsuri filtrate (CSV)", + "filtered_responses_excel": "Răspunsuri filtrate (Excel)", + "go_to_setup_checklist": "Mergi la Lista de Verificare a Configurării \uD83D\uDC49", + "impressions": "Impresii", + "impressions_tooltip": "Număr de ori când sondajul a fost vizualizat.", + "in_app": { + "connection_description": "Sondajul va fi afișat utilizatorilor site-ului dvs. web, care îndeplinesc criteriile enumerate mai jos", + "connection_title": "SDK Formbricks este conectat", + "description": "Sondajele Formbricks pot fi integrate ca un pop-up, pe baza interacțiunii utilizatorului.", + "display_criteria": "Criterii de afișare", + "display_criteria.audience_description": "Audiență țintă", + "display_criteria.code_trigger": "Acțiune Cod", + "display_criteria.everyone": "Toată lumea", + "display_criteria.no_code_trigger": "Fără Cod", + "display_criteria.overwritten": "Suprascris", + "display_criteria.randomizer": "{percentage}% Randomizare", + "display_criteria.randomizer_description": "Doar {percentage}% dintre persoanele care efectuează acțiunea pot fi chestionate.", + "display_criteria.recontact_description": "Opțiuni de recontactare", + "display_criteria.targeted": "Ţintite", + "display_criteria.time_based_always": "Arată întotdeauna sondajul", + "display_criteria.time_based_day": "Zi", + "display_criteria.time_based_days": "Zile", + "display_criteria.time_based_description": "Timp de așteptare global", + "display_criteria.trigger_description": "Declanșator sondaj", + "documentation_title": "Distribuie sondaje de interceptare pe toate platformele", + "html_embed": "HTML încorporat în ", + "ios_sdk": "SDK iOS pentru aplicațiile Apple", + "javascript_sdk": "JavaScript SDK", + "kotlin_sdk": "SDK Kotlin pentru aplicații Android", + "no_connection_description": "Conectează-ți site-ul sau aplicația cu Formbricks pentru a publica chestionare intercept.", + "no_connection_title": "Nu sunteţi încă conectat!", + "react_native_sdk": "SDK React Native pentru aplicații RN.", + "title": "Setări chestionar de interceptare" + }, + "includes_all": "Include tot", + "includes_either": "Include fie", + "install_widget": "Instalați Widgetul Formbricks", + "is_equal_to": "Este egal cu", + "is_less_than": "Este mai puțin de", + "last_30_days": "Ultimele 30 de zile", + "last_6_months": "Ultimele 6 luni", + "last_7_days": "Ultimele 7 zile", + "last_month": "Ultima lună", + "last_quarter": "Ultimul trimestru", + "last_year": "Anul trecut", + "no_responses_found": "Nu s-au găsit răspunsuri", + "other_values_found": "Alte valori găsite", + "overall": "General", + "qr_code": "Cod QR", + "qr_code_description": "Răspunsurile colectate prin cod QR sunt anonime.", + "qr_code_download_failed": "Descărcarea codului QR a eșuat", + "qr_code_download_with_start_soon": "Descărcarea codului QR va începe în curând", + "qr_code_generation_failed": "A apărut o problemă la încărcarea codului QR al chestionarului. Vă rugăm să încercați din nou.", + "reset_survey": "Resetează chestionarul", + "reset_survey_warning": "Resetarea unui sondaj elimină toate răspunsurile și afișajele asociate cu acest sondaj. Aceasta nu poate fi anulată.", + "selected_responses_csv": "Răspunsuri selectate (CSV)", + "selected_responses_excel": "Răspunsuri selectate (Excel)", + "setup_integrations": "Configurare integrare", + "share_survey": "Distribuie chestionarul", + "show_all_responses_that_match": "Afișează toate răspunsurile care corespund", + "show_all_responses_where": "Afișează toate răspunsurile unde...", + "starts": "Începuturi", + "starts_tooltip": "Număr de ori când sondajul a fost început.", + "survey_reset_successfully": "Resetarea chestionarului realizată cu succes! Au fost șterse {responseCount} răspunsuri și {displayCount} afișări.", + "this_month": "Luna aceasta", + "this_quarter": "Trimestrul acesta", + "this_year": "Anul acesta", + "time_to_complete": "Timp de finalizare", + "ttc_tooltip": "Timp mediu pentru a completa sondajul.", + "unknown_question_type": "Tip de întrebare necunoscut", + "use_personal_links": "Folosește linkuri personale", + "waiting_for_response": "Așteptând un răspuns \uD83E\uDDD8‍♂️", + "whats_next": "Ce urmează?", + "your_survey_is_public": "Sondajul tău este public", + "youre_not_plugged_in_yet": "Nu sunteţi încă conectat!" + }, + "survey_deleted_successfully": "\"Sondaj șters cu succes!\"", + "survey_duplicated_successfully": "\"Sondaj duplicat cu succes!\"", + "survey_duplication_error": "Eșec la duplicarea sondajului.", + "survey_status_tooltip": "Pentru a actualiza starea sondajului, actualizați programarea și setările de închidere în opțiunile de răspuns la sondaj.", + "templates": { + "all_channels": "Toate canalele", + "all_industries": "Toate industriile", + "all_roles": "Toate rolurile", + "create_a_new_survey": "Creează un chestionar nou", + "multiple_industries": "Mai multe industrii", + "use_this_template": "Folosește acest șablon", + "uses_branching_logic": "Acest sondaj folosește logică de ramificare." + } + }, + "xm-templates": { + "ces": "CES", + "ces_description": "Valorificați fiecare punct de contact pentru a înțelege ușurința interacțiunilor cu clienții.", + "csat": "CSAT", + "csat_description": "Implementați cele mai bune practici pentru a măsura satisfacția clienților.", + "enps": "eNPS", + "enps_description": "Feedback universal pentru a înțelege angajamentul și satisfacția angajaților.", + "five_star_rating": "Evaluare cu 5 stele", + "five_star_rating_description": "Soluție universală de feedback pentru a evalua satisfacția generală.", + "headline": "Ce fel de feedback doriți să primiți?", + "nps": "NPS", + "nps_description": "Implementați practici dovedite pentru a înțelege DE CE oamenii cumpără.", + "smileys": "Smileys", + "smileys_description": "Folosiți indicatori vizuali pentru a captura feedback-ul la toate punctele de contact cu clienții." + } + }, + "organizations": { + "landing": { + "no_projects_warning_subtitle": "Contactați proprietarul organizației dumneavoastră pentru a obține acces la proiecte. Sau creați propria dvs. organizație pentru a începe.", + "no_projects_warning_title": "Contul tău nu are încă acces la niciun proiect." + }, + "projects": { + "new": { + "channel": { + "channel_select_subtitle": "Distribuiți un link sau afișați sondajul în aplicații sau pe site-uri web.", + "channel_select_title": "Ce tip de sondaje aveți nevoie?", + "in_product_surveys": "Sondaje în produse", + "in_product_surveys_description": "Încorporat în aplicații sau pe site-uri web.", + "link_and_email_surveys": "Chestionare link și email", + "link_and_email_surveys_description": "Ajungeți la oameni oriunde online." + }, + "mode": { + "formbricks_cx": "Formbricks CX", + "formbricks_cx_description": "Sondaje și rapoarte pentru a înțelege ce au nevoie clienții tăi.", + "formbricks_surveys": "Sondaje Formbricks", + "formbricks_surveys_description": "Platformă de sondaje multi-scop pentru sondaje web, aplicații și email.", + "what_are_you_here_for": "Pentru ce ești aici?" + }, + "settings": { + "brand_color": "Culoarea brandului", + "brand_color_description": "Potrivește culoarea principală a sondajelor cu brandul tău.", + "create_new_team": "Creează echipă nouă", + "project_creation_failed": "Crearea proiectului a eșuat", + "project_name": "Nume produs", + "project_name_description": "Cum se numește produsul tău?", + "project_settings_subtitle": "Când oamenii îți recunosc brandul, sunt mult mai predispuși să înceapă și să finalizeze răspunsurile.", + "project_settings_title": "Anunță respondenții că ești tu", + "team_description": "Cine poate accesa acest proiect?" + } + } + } + }, + "s": { + "check_inbox_or_spam": "Vă rugăm să verificați și folderul de spam dacă nu vedeți emailul în inbox.", + "completed": "Acest chestionar este închis.", + "create_your_own": "Creează-ți propriul chestionar open-source", + "enter_pin": "Acest sondaj este protejat. Introduceți PIN-ul mai jos", + "just_curious": "Doar curios?", + "link_invalid": "Acest sondaj poate fi completat doar pe bază de invitație.", + "paused": "Acest sondaj este temporar întrerupt.", + "please_try_again_with_the_original_link": "Vă rugăm să încercați din nou cu linkul original", + "preview_survey_questions": "Previzualizare întrebări chestionar", + "question_preview": "Previzualizare Întrebare", + "response_already_received": "Am primit deja un răspuns pentru această adresă de email.", + "response_submitted": "Un răspuns legat de acest chestionar și contact există deja", + "survey_already_answered_heading": "Sondajul a fost deja completat.", + "survey_already_answered_subheading": "Puteți folosi acest link doar o dată.", + "survey_sent_to": "Chestionar trimis către {email}", + "this_looks_fishy": "Aceasta pare suspectă.", + "verify_email": "Verifică emailul", + "verify_email_before_submission": "Verificați-vă emailul pentru a răspunde", + "verify_email_before_submission_button": "Verifică", + "verify_email_before_submission_description": "Pentru a răspunde la acest sondaj, vă rugăm să vă verificați emailul", + "want_to_respond": "Dorești să răspunzi?" + }, + "setup": { + "intro": { + "get_started": "Începeți", + "made_with_love_in_kiel": "Creat cu \uD83E\uDD0D în Germania", + "paragraph_1": "Formbricks este o Suită de Management al Experiențelor construită pe baza platformei de sondaje open source care crește cel mai rapid din lume.", + "paragraph_2": "Rulați sondaje direcționate pe site-uri web, în aplicații sau oriunde online. Adunați informații valoroase pentru a crea experiențe irezistibile pentru clienți, utilizatori și angajați.", + "paragraph_3": "Suntem angajați la cel mai înalt grad de confidențialitate a datelor. Găzduirea proprie vă oferă control deplin asupra datelor dumneavoastră.", + "welcome_to_formbricks": "Bine ai venit la Formbricks!" + }, + "invite": { + "add_another_member": "Adaugă încă un membru", + "continue": "Continuă", + "failed_to_invite": "Nu s-a reușit invitarea", + "invitation_sent_to": "Invitație trimisă către", + "invite_your_organization_members": "Invitați membrii organizației voastre", + "life_s_no_fun_alone": "Viața nu este distractivă singur.", + "skip": "Omite", + "smtp_not_configured": "SMTP neconfigurat", + "smtp_not_configured_description": "Invitațiile nu pot fi trimise în acest moment deoarece serviciul de e-mail nu este configurat. Puteți copia linkul de invitație în setările organizației mai târziu." + }, + "organization": { + "create": { + "continue": "Continuă", + "delete_account": "Șterge Cont", + "delete_account_description": "Dacă doriți să vă ștergeți contul, puteți face acest lucru făcând clic pe butonul de mai jos.", + "description": "Fă-o a ta.", + "no_membership_found": "Nu s-a găsit niciun membru!", + "no_membership_found_description": "Nu sunteți membru al niciunei organizații în acest moment. Dacă credeți că aceasta este o greșeală, vă rugăm să contactați proprietarul organizației.", + "title": "Configurează organizația ta" + } + }, + "signup": { + "create_administrator": "Creare Administrator", + "this_user_has_all_the_power": "Acest utilizator are toată puterea." + } + }, + "templates": { + "address": "Adresă", + "address_description": "Cereți o adresă poștală", + "alignment_and_engagement_survey_description": "Evaluează alinierea angajatului cu viziunea, strategia și comunicarea companiei, precum și colaborarea în echipă.", + "alignment_and_engagement_survey_name": "Aliniere și Angajament cu Viziunea Companiei", + "alignment_and_engagement_survey_question_1_headline": "Înțeleg cum rolul meu contribuie la strategia generală a companiei.", + "alignment_and_engagement_survey_question_1_lower_label": "Nicio înțelegere", + "alignment_and_engagement_survey_question_1_upper_label": "Înțelegere completă", + "alignment_and_engagement_survey_question_2_headline": "Simt că valorile mele sunt aliniate cu misiunea și cultura companiei.", + "alignment_and_engagement_survey_question_2_lower_label": "Nealiniat", + "alignment_and_engagement_survey_question_3_headline": "Colaborez eficient cu echipa mea pentru a ne atinge obiectivele.", + "alignment_and_engagement_survey_question_3_lower_label": "Colaborare slabă", + "alignment_and_engagement_survey_question_3_upper_label": "Colaborare excelentă", + "alignment_and_engagement_survey_question_4_headline": "Cum poate îmbunătăți compania alinierea viziunii și strategiei sale?", + "alignment_and_engagement_survey_question_4_placeholder": "Tastează răspunsul aici...", + "back": "Înapoi", + "book_interview": "Rezervă interviu", + "build_product_roadmap_description": "Identificați acel UN lucru pe care îl doresc cel mai mult utilizatorii și construiți-l.", + "build_product_roadmap_name": "Crearea foii de parcurs a produsului", + "build_product_roadmap_question_1_headline": "Cât de mulțumit sunteți de caracteristicile și funcționalitățile $[projectName]?", + "build_product_roadmap_question_1_lower_label": "Deloc mulțumit", + "build_product_roadmap_question_1_upper_label": "Extrem de mulțumit", + "build_product_roadmap_question_2_headline": "Care este O schimbare pe care am putea să o facem pentru a îmbunătăți cel mai mult experiența ta cu $[projectName]?", + "build_product_roadmap_question_2_placeholder": "Tastează răspunsul aici...", + "card_abandonment_survey": "Chestionar de abandonare a coșului", + "card_abandonment_survey_description": "Înțelegeți motivele abandonării coșului în magazinul dvs. online.", + "card_abandonment_survey_question_1_button_label": "Sigur!", + "card_abandonment_survey_question_1_dismiss_button_label": "Nu, mulţumesc", + "card_abandonment_survey_question_1_headline": "Aveți 2 minute pentru a ne ajuta să îmbunătățim?", + "card_abandonment_survey_question_1_html": "

Am observat că ați lăsat câteva articole în coșul dvs. Ne-ar plăcea să înțelegem de ce.

", + "card_abandonment_survey_question_2_choice_1": "Costuri mari de transport", + "card_abandonment_survey_question_2_choice_2": "Am găsit un preț mai bun în altă parte", + "card_abandonment_survey_question_2_choice_3": "Doar navighez", + "card_abandonment_survey_question_2_choice_4": "Am decis să nu cumpăr", + "card_abandonment_survey_question_2_choice_5": "Probleme de plată", + "card_abandonment_survey_question_2_choice_6": "Altele", + "card_abandonment_survey_question_2_headline": "Care a fost motivul principal pentru care nu ați finalizat achiziția?", + "card_abandonment_survey_question_2_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:", + "card_abandonment_survey_question_3_headline": "Vă rugăm să detaliați motivul pentru care nu ați finalizat achiziția:", + "card_abandonment_survey_question_4_headline": "Cum ați evalua experiența generală de cumpărături?", + "card_abandonment_survey_question_4_lower_label": "Foarte nemulțumit", + "card_abandonment_survey_question_4_upper_label": "Foarte mulțumit", + "card_abandonment_survey_question_5_choice_1": "Costuri mai mici de transport", + "card_abandonment_survey_question_5_choice_2": "Reduceri sau promoții", + "card_abandonment_survey_question_5_choice_3": "Mai multe opțiuni de plată", + "card_abandonment_survey_question_5_choice_4": "Descrieri mai bune ale produselor", + "card_abandonment_survey_question_5_choice_5": "Navigare îmbunătățită pe site", + "card_abandonment_survey_question_5_choice_6": "Altele", + "card_abandonment_survey_question_5_headline": "Ce factori v-ar încuraja să finalizați achiziția în viitor?", + "card_abandonment_survey_question_5_subheader": "Vă rugăm să selectați toate opțiunile care se aplică:", + "card_abandonment_survey_question_6_headline": "Ați dori să primiți un cod de reducere prin email?", + "card_abandonment_survey_question_6_label": "Da, vă rog să-mi trimiteți mesaj.", + "card_abandonment_survey_question_7_headline": "Vă rugăm să împărtășiți adresa de email:", + "card_abandonment_survey_question_8_headline": "Comentarii sau sugestii suplimentare?", + "career_development_survey_description": "Evaluează satisfacția angajaților cu privire la oportunitățile de creștere și dezvoltare în carieră.", + "career_development_survey_name": "Sondaj de Dezvoltare a Carierei", + "career_development_survey_question_1_headline": "Sunt mulțumit de oportunitățile pentru creștere personală și profesională oferite de $[projectName].", + "career_development_survey_question_1_lower_label": "Dezacord puternic", + "career_development_survey_question_1_upper_label": "De acord cu tărie", + "career_development_survey_question_2_headline": "Sunt mulțumit de oportunitățile de avansare în carieră disponibile pentru mine la $[projectName].", + "career_development_survey_question_2_lower_label": "Dezacord puternic", + "career_development_survey_question_2_upper_label": "De acord cu tărie", + "career_development_survey_question_3_headline": "Sunt mulțumit de instruirea legată de locul de muncă pe care organizația mea o oferă.", + "career_development_survey_question_3_lower_label": "Dezacord puternic", + "career_development_survey_question_3_upper_label": "De acord cu tărie", + "career_development_survey_question_4_headline": "Sunt mulțumit de investiția pe care organizația mea o face în formare și educație.", + "career_development_survey_question_4_lower_label": "Dezacord puternic", + "career_development_survey_question_4_upper_label": "De acord cu tărie", + "career_development_survey_question_5_choice_1": "Dezvoltare de Produs", + "career_development_survey_question_5_choice_2": "Marketing", + "career_development_survey_question_5_choice_3": "Relații Publice", + "career_development_survey_question_5_choice_4": "Contabilitate", + "career_development_survey_question_5_choice_5": "Operațiuni", + "career_development_survey_question_5_choice_6": "Altele", + "career_development_survey_question_5_headline": "În care funcţie lucrați?", + "career_development_survey_question_5_subheader": "Vă rugăm să selectați una dintre următoarele", + "career_development_survey_question_6_choice_1": "Contribuitor Individual", + "career_development_survey_question_6_choice_2": "Manager", + "career_development_survey_question_6_choice_3": "Manager Senior", + "career_development_survey_question_6_choice_4": "Vicepreședinte", + "career_development_survey_question_6_choice_5": "Executiv", + "career_development_survey_question_6_choice_6": "Altele", + "career_development_survey_question_6_headline": "Care dintre următoarele descrie cel mai bine nivelul actual al locului tău de muncă?", + "career_development_survey_question_6_subheader": "Vă rugăm să selectați una dintre următoarele", + "cess_survey_name": "Chestionar CES", + "cess_survey_question_1_headline": "$[projectName] îmi simplifică [ADD GOAL]", + "cess_survey_question_1_lower_label": "Nu sunt deloc de acord", + "cess_survey_question_1_upper_label": "De acord cu tărie", + "cess_survey_question_2_headline": "Mulțumim! Cum putem să-ți facilităm să [ADD GOAL]?", + "cess_survey_question_2_placeholder": "Tastează răspunsul aici...", + "changing_subscription_experience_description": "Aflați ce se petrece în mintea oamenilor atunci când își schimbă abonamentele.", + "changing_subscription_experience_name": "Schimbarea Experienței Abonamentului", + "changing_subscription_experience_question_1_choice_1": "Extrem de dificil", + "changing_subscription_experience_question_1_choice_2": "A durat o vreme, dar am reușit", + "changing_subscription_experience_question_1_choice_3": "A fost în regulă", + "changing_subscription_experience_question_1_choice_4": "Destul de ușor", + "changing_subscription_experience_question_1_choice_5": "Foarte ușor, ador!", + "changing_subscription_experience_question_1_headline": "Cât de ușor a fost să vă schimbați planul?", + "changing_subscription_experience_question_2_choice_1": "Da, foarte clar.", + "changing_subscription_experience_question_2_choice_2": "La început am fost confuz, dar am găsit ceea ce aveam nevoie.", + "changing_subscription_experience_question_2_choice_3": "Destul de complicat", + "changing_subscription_experience_question_2_headline": "Este ușor de înțeles informația referitoare la prețuri?", + "churn_survey": "Chestionar de Reziliere", + "churn_survey_description": "Aflați de ce oamenii își anulează abonamentele. Aceste informații sunt aur curat!", + "churn_survey_question_1_choice_1": "Dificil de utilizat", + "churn_survey_question_1_choice_2": "E prea scump", + "churn_survey_question_1_choice_3": "Îmi lipsesc funcționalități", + "churn_survey_question_1_choice_4": "Servicii de asistență pentru clienți nesatisfăcătoare", + "churn_survey_question_1_choice_5": "pur și simplu nu mai aveam nevoie de el", + "churn_survey_question_1_headline": "De ce ați anulat abonamentul?", + "churn_survey_question_1_subheader": "Ne pare rău să te vedem plecând. Ajută-ne să fim mai buni:", + "churn_survey_question_2_button_label": "Trimite", + "churn_survey_question_2_headline": "Ce ar fi făcut $[projectName] mai ușor de utilizat?", + "churn_survey_question_3_button_label": "Obțineți 30% reducere", + "churn_survey_question_3_dismiss_button_label": "Omite", + "churn_survey_question_3_headline": "Obțineți 30% reducere pentru anul următor!", + "churn_survey_question_3_html": "

Ne-ar plăcea să rămâi clientul nostru. Suntem bucuroși să îți oferim o reducere de 30% pentru anul următor.

", + "churn_survey_question_4_headline": "Ce funcționalități vă lipsesc?", + "churn_survey_question_5_button_label": "Trimite email către CEO", + "churn_survey_question_5_dismiss_button_label": "Omite", + "churn_survey_question_5_headline": "Îmi pare rău să aud \uD83D\uDE14 Vorbiți direct cu CEO-ul nostru!", + "churn_survey_question_5_html": "

Ne străduim să oferim cel mai bun serviciu pentru clienți. Vă rugăm să trimiteți un e-mail CEO-ului nostru și ea va rezolva personal problema dumneavoastră.

", + "collect_feedback_description": "Colectați feedback complet despre produsul sau serviciul dumneavoastră.", + "collect_feedback_name": "Colectați feedback", + "collect_feedback_question_1_headline": "Cum evaluați experiența generală?", + "collect_feedback_question_1_lower_label": "Nu este bine", + "collect_feedback_question_1_subheader": "Nu vă faceți griji, fiți sinceri.", + "collect_feedback_question_1_upper_label": "Foarte bun", + "collect_feedback_question_2_headline": "Minunat! Ce ți-a plăcut la ea?", + "collect_feedback_question_2_placeholder": "Tastează răspunsul aici...", + "collect_feedback_question_3_headline": "Mulțumim pentru impartășire! Ce nu ți-a plăcut?", + "collect_feedback_question_3_placeholder": "Tastează răspunsul aici...", + "collect_feedback_question_4_headline": "Cum evaluați comunicarea noastră?", + "collect_feedback_question_4_lower_label": "Nu este bine", + "collect_feedback_question_4_upper_label": "Foarte bun", + "collect_feedback_question_5_headline": "Mai dorești să împărtășești altceva cu echipa noastră?", + "collect_feedback_question_5_placeholder": "Tastează răspunsul aici...", + "collect_feedback_question_6_choice_1": "Google", + "collect_feedback_question_6_choice_2": "Rețele Sociale", + "collect_feedback_question_6_choice_3": "Prieteni", + "collect_feedback_question_6_choice_4": "Podcast", + "collect_feedback_question_6_choice_5": "Altele", + "collect_feedback_question_6_headline": "Cum ai aflat despre noi?", + "collect_feedback_question_7_headline": "În cele din urmă, ne-ar plăcea să răspundem feedback-ului dvs. Vă rugăm să ne împărtășiți emailul:", + "collect_feedback_question_7_placeholder": "exemplu@e-mail.com", + "consent": "Consimţământ", + "consent_description": "Cereți să fiți de acord cu termenii, condițiile sau utilizarea datelor", + "contact_info": "Informații de contact", + "contact_info_description": "Solicitați numele, prenumele, emailul, numărul de telefon și compania împreună", + "csat_description": "Măsurați Scorul de Satisfacție a Clientului al produsului sau serviciului dumneavoastră.", + "csat_name": "Scorul de Satisfacție a Clientului (CSAT)", + "csat_question_10_headline": "Aveți alte comentarii, întrebări sau preocupări?", + "csat_question_10_placeholder": "Tastează răspunsul aici...", + "csat_question_1_headline": "Cât de probabil este ca să recomandați acest $[projectName] unui prieten sau coleg?", + "csat_question_1_lower_label": "Puțin probabil", + "csat_question_1_upper_label": "Foarte probabil", + "csat_question_2_choice_1": "Puțin mulțumit", + "csat_question_2_choice_2": "Foarte mulțumit", + "csat_question_2_choice_3": "Nici mulțumit, nici nemulțumit", + "csat_question_2_choice_4": "Ușor nemulțumit", + "csat_question_2_choice_5": "Foarte nemulțumit", + "csat_question_2_headline": "Per total, cât de mulțumit sau nemulțumit sunteți de $[projectName]", + "csat_question_2_subheader": "Vă rugăm să selectați una:", + "csat_question_3_choice_1": "Ineficient", + "csat_question_3_choice_10": "Unic", + "csat_question_3_choice_2": "Util", + "csat_question_3_choice_3": "Nepractic", + "csat_question_3_choice_4": "Peste preţ", + "csat_question_3_choice_5": "Calitate ridicată", + "csat_question_3_choice_6": "Fiabil", + "csat_question_3_choice_7": "Valoare bună pentru bani", + "csat_question_3_choice_8": "Calitate slabă", + "csat_question_3_choice_9": "Nesigur", + "csat_question_3_headline": "Care dintre următoarele cuvinte ați folosi pentru a descrie aplicația noastră $[projectName]?", + "csat_question_3_subheader": "Selectați toate opțiunile care se aplică:", + "csat_question_4_choice_1": "Foarte bine", + "csat_question_4_choice_2": "Foarte bine", + "csat_question_4_choice_3": "Destul de bine", + "csat_question_4_choice_4": "Nu prea bine", + "csat_question_4_choice_5": "Deloc bine", + "csat_question_4_headline": "Cât de bine $[projectName] îndeplinește nevoile tale?", + "csat_question_4_subheader": "Selectează o opțiune:", + "csat_question_5_choice_1": "Calitate foarte ridicată", + "csat_question_5_choice_2": "Calitate ridicată", + "csat_question_5_choice_3": "Calitate redusă", + "csat_question_5_choice_4": "Calitate foarte redusă", + "csat_question_5_choice_5": "Nici înaltă, nici scăzută", + "csat_question_5_headline": "Cum ai evalua calitatea $[projectName]?", + "csat_question_5_subheader": "Selectează o opțiune:", + "csat_question_6_choice_1": "Excelent", + "csat_question_6_choice_2": "Peste media", + "csat_question_6_choice_3": "Media", + "csat_question_6_choice_4": "Sub medie", + "csat_question_6_choice_5": "Slab", + "csat_question_6_headline": "Cum ai evalua valoarea pentru bani a $[projectName]?", + "csat_question_6_subheader": "Vă rugăm să selectați una:", + "csat_question_7_choice_1": "Extrem de receptiv", + "csat_question_7_choice_2": "Foarte receptiv", + "csat_question_7_choice_3": "Parțial receptiv", + "csat_question_7_choice_4": "Nu foarte receptiv", + "csat_question_7_choice_5": "Deloc receptiv", + "csat_question_7_headline": "Cât de receptivi am fost la întrebările dumneavoastră despre serviciile noastre?", + "csat_question_7_subheader": "Vă rugăm să selectați una:", + "csat_question_8_choice_1": "Aceasta este prima mea achiziție", + "csat_question_8_choice_2": "Mai puțin de șase luni", + "csat_question_8_choice_3": "Șase luni până la un an", + "csat_question_8_choice_4": "1 - 2 ani", + "csat_question_8_choice_5": "3 sau mai mulți ani", + "csat_question_8_headline": "De cât timp sunteți client al proiectului $[projectName]?", + "csat_question_8_subheader": "Vă rugăm să selectați una:", + "csat_question_9_choice_1": "Foarte probabil", + "csat_question_9_choice_2": "Foarte probabil", + "csat_question_9_choice_3": "Cât de probabil", + "csat_question_9_choice_4": "Puțin probabil", + "csat_question_9_choice_5": "Deloc probabil", + "csat_question_9_headline": "Cât de probabil este să achiziționați din nou vreunul dintre $[projectName]?", + "csat_question_9_subheader": "Selectează o opțiune:", + "csat_survey_name": "$[projectName] CSAT", + "csat_survey_question_1_headline": "Cât de mulțumit sunteți de experiența dumneavoastră cu $[projectName]?", + "csat_survey_question_1_lower_label": "Extrem de nemulțumit", + "csat_survey_question_1_upper_label": "Extrem de mulțumit", + "csat_survey_question_2_headline": "Minunat! Există ceva ce putem face pentru a-ți îmbunătăți experiența?", + "csat_survey_question_2_placeholder": "Tastează răspunsul aici...", + "csat_survey_question_3_headline": "Of, îmi pare rău! Există ceva ce putem face pentru a-ți îmbunătăți experiența?", + "csat_survey_question_3_placeholder": "Tastează răspunsul aici...", + "cta_description": "Afișează informații și solicită utilizatorilor să ia o acțiune specifică", + "custom_survey_description": "Creează un sondaj fără șablon.", + "custom_survey_name": "Începe de la zero", + "custom_survey_question_1_headline": "Ce ați dori să știți?", + "custom_survey_question_1_placeholder": "Tastează răspunsul aici...", + "customer_effort_score_description": "Determinați cât de ușor este de folosit o caracteristică.", + "customer_effort_score_name": "Scorul efortului clientului (CES)", + "customer_effort_score_question_1_headline": "$[projectName] îmi simplifică [ADD GOAL]", + "customer_effort_score_question_1_lower_label": "Nu sunt deloc de acord", + "customer_effort_score_question_1_upper_label": "De acord cu tărie", + "customer_effort_score_question_2_headline": "Mulțumim! Cum am putea să-ți ușurăm să [ADD GOAL]?", + "customer_effort_score_question_2_placeholder": "Tastează răspunsul aici...", + "date": "Dată", + "date_description": "Cere o selecție de dată", + "default_ending_card_button_label": "Creează-ți propriul sondaj", + "default_ending_card_headline": "Mulțumim!", + "default_ending_card_subheader": "Apărecem feedback-ul tău.", + "default_welcome_card_button_label": "Următorul", + "default_welcome_card_headline": "Bun venit!", + "default_welcome_card_html": "Mulțumesc pentru feedback-ul dvs - să începem!", + "docs_feedback_description": "Măsurați cât de clară este fiecare pagină a documentației dumneavoastră pentru dezvoltatori.", + "docs_feedback_name": "Feedback Documente", + "docs_feedback_question_1_choice_1": "Da \uD83D\uDC4D", + "docs_feedback_question_1_choice_2": "Nu \uD83D\uDC4E", + "docs_feedback_question_1_headline": "A fost această pagină utilă?", + "docs_feedback_question_2_headline": "Te rog să detaliezi:", + "docs_feedback_question_3_headline": "URL pagină", + "earned_advocacy_score_description": "EAS este o variație a NPS, dar cere comportamente reale trecute în loc de intenții mărețe.", + "earned_advocacy_score_name": "Scor de Advocacy Obținut (EAS)", + "earned_advocacy_score_question_1_choice_1": "Da", + "earned_advocacy_score_question_1_choice_2": "Nu", + "earned_advocacy_score_question_1_headline": "Ai recomandat activ $[projectName] altora?", + "earned_advocacy_score_question_2_headline": "De ce ne-ai recomandat?", + "earned_advocacy_score_question_2_placeholder": "Tastează răspunsul aici...", + "earned_advocacy_score_question_3_headline": "Și totuși, de ce nu?", + "earned_advocacy_score_question_3_placeholder": "Tastează răspunsul aici...", + "earned_advocacy_score_question_4_choice_1": "Da", + "earned_advocacy_score_question_4_choice_2": "Nu", + "earned_advocacy_score_question_4_headline": "Te-ai opus activ ca alții să aleagă $[projectName]?", + "earned_advocacy_score_question_5_headline": "Ce te-a făcut să îi descurajezi?", + "earned_advocacy_score_question_5_placeholder": "Tastează răspunsul aici...", + "employee_satisfaction_description": "Evaluează satisfacția angajaților și identifică domeniile de îmbunătățire.", + "employee_satisfaction_name": "Satisfacție a Angajatului", + "employee_satisfaction_question_1_headline": "Cât de satisfăcut sunteți de rolul dvs. actual?", + "employee_satisfaction_question_1_lower_label": "Nesatisfăcut", + "employee_satisfaction_question_1_upper_label": "Foarte mulțumit", + "employee_satisfaction_question_2_choice_1": "Extrem de semnificativ", + "employee_satisfaction_question_2_choice_2": "Foarte semnificativ", + "employee_satisfaction_question_2_choice_3": "Moderată semnificativă", + "employee_satisfaction_question_2_choice_4": "Ușor semnificativ", + "employee_satisfaction_question_2_choice_5": "Deloc semnificativ", + "employee_satisfaction_question_2_headline": "Cât de semnificativă consideri munca ta?", + "employee_satisfaction_question_3_headline": "Ce îți place cel mai mult la lucru aici?", + "employee_satisfaction_question_3_placeholder": "Tastează răspunsul aici...", + "employee_satisfaction_question_5_headline": "Evaluează suportul pe care îl primești de la managerul tău.", + "employee_satisfaction_question_5_lower_label": "Slab", + "employee_satisfaction_question_5_upper_label": "Excelent", + "employee_satisfaction_question_6_headline": "Ce îmbunătățiri ați sugera pentru locul nostru de muncă?", + "employee_satisfaction_question_6_placeholder": "Tastează răspunsul aici...", + "employee_satisfaction_question_7_choice_1": "Foarte probabil", + "employee_satisfaction_question_7_choice_2": "Foarte probabil", + "employee_satisfaction_question_7_choice_3": "Probabil moderat", + "employee_satisfaction_question_7_choice_4": "Puțin probabil", + "employee_satisfaction_question_7_choice_5": "Deloc probabil", + "employee_satisfaction_question_7_headline": "Cât de probabil este să recomandați compania noastră unui prieten?", + "employee_well_being_description": "Evaluează bunăstarea angajatului prin echilibrul între muncă și viață, volumul de muncă și mediul de lucru.", + "employee_well_being_name": "Bunăstarea Angajatului", + "employee_well_being_question_1_headline": "Simt că am un echilibru bun între viața mea profesională și cea personală.", + "employee_well_being_question_1_lower_label": "Echilibru foarte slab", + "employee_well_being_question_1_upper_label": "Echilibru excelent", + "employee_well_being_question_2_headline": "Volumul meu de muncă este gestionabil, permițându-mi să rămân productiv fără să mă simt copleșit.", + "employee_well_being_question_2_lower_label": "Volum de muncă copleșitor", + "employee_well_being_question_2_upper_label": "Perfect gestionabil", + "employee_well_being_question_3_headline": "Mediul de lucru îmi susține starea de bine fizică și mentală.", + "employee_well_being_question_3_lower_label": "Nesusținut", + "employee_well_being_question_3_upper_label": "Extrem de susținător", + "employee_well_being_question_4_headline": "Ce schimbări, dacă există, ar putea îmbunătăți bunăstarea dumneavoastră generală la locul de muncă?", + "employee_well_being_question_4_placeholder": "Tastează răspunsul aici...", + "enps_survey_name": "Chestionar eNPS", + "enps_survey_question_1_headline": "Cât de probabil este să recomandați să lucrați la această companie unui prieten sau coleg?", + "enps_survey_question_1_lower_label": "Deloc probabil", + "enps_survey_question_1_upper_label": "Foarte probabil", + "enps_survey_question_2_headline": "Pentru a ne ajuta să ne îmbunătățim, puteți descrie motiv(o)ele pentru evaluarea dvs?", + "enps_survey_question_3_headline": "Alte comentarii, feedback sau preocupări?", + "evaluate_a_product_idea_description": "Sondaj utilizatorilor despre idei de produse sau caracteristici. Obține rapid feedback.", + "evaluate_a_product_idea_name": "Evaluează o Idee de Produs", + "evaluate_a_product_idea_question_1_button_label": "S-o facem!", + "evaluate_a_product_idea_question_1_dismiss_button_label": "Omite", + "evaluate_a_product_idea_question_1_headline": "Ne place cum folosești $[projectName]! Am dori să discutăm despre o idee de funcționalitate. Ai un minut?", + "evaluate_a_product_idea_question_1_html": "

Respectăm timpul dumneavoastră și am păstrat-o scurt \uD83E\uDD38

", + "evaluate_a_product_idea_question_2_headline": "Mulțumesc! Cât de dificil sau ușor este pentru tine să [PROBLEM AREA] astăzi?", + "evaluate_a_product_idea_question_2_lower_label": "Foarte dificil", + "evaluate_a_product_idea_question_2_upper_label": "Foarte ușor", + "evaluate_a_product_idea_question_3_headline": "Ce este cel mai dificil pentru tine când vine vorba de [PROBLEM AREA]?", + "evaluate_a_product_idea_question_3_placeholder": "Tastează răspunsul aici...", + "evaluate_a_product_idea_question_4_button_label": "Următorul", + "evaluate_a_product_idea_question_4_dismiss_button_label": "Omite", + "evaluate_a_product_idea_question_4_headline": "Lucrăm la o idee pentru a ajuta cu [PROBLEM AREA].", + "evaluate_a_product_idea_question_4_html": "

Introduceți aici conceptul scurt. Adăugați detalii necesare, dar păstrați-l concis și ușor de înțeles.

", + "evaluate_a_product_idea_question_5_headline": "Cât de valoroasă ar fi această funcționalitate pentru tine?", + "evaluate_a_product_idea_question_5_lower_label": "Nevaloros", + "evaluate_a_product_idea_question_5_upper_label": "Foarte valoros", + "evaluate_a_product_idea_question_6_headline": "Am înțeles. De ce nu ar fi valoroasă această funcționalitate pentru tine?", + "evaluate_a_product_idea_question_6_placeholder": "Tastează răspunsul aici...", + "evaluate_a_product_idea_question_7_headline": "Ce ți-ar fi cel mai valoros la această funcționalitate?", + "evaluate_a_product_idea_question_7_placeholder": "Tastează răspunsul aici...", + "evaluate_a_product_idea_question_8_headline": "Altceva de care ar trebui să ținem cont?", + "evaluate_a_product_idea_question_8_placeholder": "Tastează răspunsul aici...", + "evaluate_content_quality_description": "Măsurați dacă conținutul dvs. de marketing atinge scopul.", + "evaluate_content_quality_name": "Evaluare calitatea conținutului", + "evaluate_content_quality_question_1_headline": "Cât de bine a abordat acest articol ceea ce sperai să înveți?", + "evaluate_content_quality_question_1_lower_label": "Deloc bine", + "evaluate_content_quality_question_1_upper_label": "Foarte bine", + "evaluate_content_quality_question_2_headline": "Hmpft! Ce sperai?", + "evaluate_content_quality_question_2_placeholder": "Tastează răspunsul aici...", + "evaluate_content_quality_question_3_headline": "Minunat! Este altceva ce ați dori să acoperim?", + "evaluate_content_quality_question_3_placeholder": "Subiecte, tendințe, tutoriale...", + "fake_door_follow_up_description": "Urmărirea utilizatorilor care au întâlnit unul dintre experimentele tale de Fake Door.", + "fake_door_follow_up_name": "Urmărire False Door", + "fake_door_follow_up_question_1_headline": "Cât de importantă este această funcție pentru tine?", + "fake_door_follow_up_question_1_lower_label": "Neimportant", + "fake_door_follow_up_question_1_upper_label": "Foarte important", + "fake_door_follow_up_question_2_choice_1": "Aspectul 1", + "fake_door_follow_up_question_2_choice_2": "Aspectul 2", + "fake_door_follow_up_question_2_choice_3": "Aspectul 3", + "fake_door_follow_up_question_2_choice_4": "Aspectul 4", + "fake_door_follow_up_question_2_headline": "Ce ar trebui să includem cu siguranță în construirea acestuia?", + "feature_chaser_description": "Urmăriți utilizatorii care tocmai au folosit o funcție specifică.", + "feature_chaser_name": "Urmăritor de Funcționalități", + "feature_chaser_question_1_headline": "Cât de importantă este [ADD FEATURE] pentru tine?", + "feature_chaser_question_1_lower_label": "Neimportant", + "feature_chaser_question_1_upper_label": "Foarte important", + "feature_chaser_question_2_choice_1": "Aspectul 1", + "feature_chaser_question_2_choice_2": "Aspectul 2", + "feature_chaser_question_2_choice_3": "Aspectul 3", + "feature_chaser_question_2_choice_4": "Aspectul 4", + "feature_chaser_question_2_headline": "Ce aspect este cel mai important?", + "feedback_box_description": "Oferește-le utilizatorilor tăi posibilitatea de a împărtăși fără efort ce au pe suflet.", + "feedback_box_name": "Cutia de Feedback", + "feedback_box_question_1_choice_1": "Raport de eroare \uD83D\uDC1E", + "feedback_box_question_1_choice_2": "Solicitare funcționalitate \uD83D\uDCA1", + "feedback_box_question_1_headline": "Ce ai pe suflet, șefule?", + "feedback_box_question_1_subheader": "Mulțumim pentru împărtășire. Vom reveni la tine cât mai curând posibil.", + "feedback_box_question_2_headline": "Ce nu merge bine?", + "feedback_box_question_2_subheader": "Cu cât mai multe detalii, cu atât mai bine :)", + "feedback_box_question_3_button_label": "Da, notificați-mă", + "feedback_box_question_3_dismiss_button_label": "Nu, mulţumesc", + "feedback_box_question_3_headline": "Vrei să fii în temă?", + "feedback_box_question_3_html": "

Vom remedia această problemă cât mai curând posibil. Doriți să fiți notificat când am făcut-o?

", + "feedback_box_question_4_button_label": "Solicitare funcționalitate", + "feedback_box_question_4_headline": "Minunat, spuneți-ne mai multe!", + "feedback_box_question_4_placeholder": "Tastează răspunsul aici...", + "feedback_box_question_4_subheader": "Ce problemă doriți să rezolvăm?", + "file_upload": "Încărcare fișier", + "file_upload_description": "Permite respondenților să încarce documente, imagini sau alte fișiere", + "finish": "Finalizează", + "follow_ups_modal_action_body": "

Salut \uD83D\uDC4B

Mulțumim că ați răspuns, vom reveni în curând cu un mesaj.

Să aveți o zi grozavă!

", + "free_text": "Text liber", + "free_text_description": "Colectați feedback deschis", + "free_text_placeholder": "Tastează răspunsul aici...", + "gauge_feature_satisfaction_description": "Evaluați gradul de satisfactie al caracteristicilor specifice ale produsului dumneavoastră.", + "gauge_feature_satisfaction_name": "Calitativitatea caracteristicilor", + "gauge_feature_satisfaction_question_1_headline": "Cât de ușor a fost să realizezi ... ?", + "gauge_feature_satisfaction_question_1_lower_label": "Nu este ușor", + "gauge_feature_satisfaction_question_1_upper_label": "Foarte ușor", + "gauge_feature_satisfaction_question_2_headline": "Care este acel lucru pe care l-am putea îmbunătăți?", + "identify_customer_goals_description": "Înțelegeți mai bine dacă mesajele voastre creează așteptările corecte privind valoarea pe care o oferă produsul vostru.", + "identify_customer_goals_name": "Identifică Obiectivele Clienților", + "identify_sign_up_barriers_description": "Oferiți o reducere pentru a obține informații despre barierele de înscriere.", + "identify_sign_up_barriers_name": "Identificați Barierele de Înscriere", + "identify_sign_up_barriers_question_1_button_label": "Obține reducere de 10%", + "identify_sign_up_barriers_question_1_dismiss_button_label": "Nu, mulţumesc", + "identify_sign_up_barriers_question_1_headline": "Răspunde acestui scurt sondaj, primește 10% reducere!", + "identify_sign_up_barriers_question_1_html": "Se pare că sunteți pe cale să vă înregistrați. Răspundeți la patru întrebări și obțineți 10% reducere la orice plan.", + "identify_sign_up_barriers_question_2_headline": "Cât de probabil este să vă înscrieți pentru $[projectName]?", + "identify_sign_up_barriers_question_2_lower_label": "Deloc probabil", + "identify_sign_up_barriers_question_2_upper_label": "Foarte probabil", + "identify_sign_up_barriers_question_3_choice_1_label": "S-ar putea să nu găsesc ce caut", + "identify_sign_up_barriers_question_3_choice_2_label": "Încă compar opțiuni", + "identify_sign_up_barriers_question_3_choice_3_label": "Pare complicat", + "identify_sign_up_barriers_question_3_choice_4_label": "Prețul este o preocupare", + "identify_sign_up_barriers_question_3_choice_5_label": "Altceva", + "identify_sign_up_barriers_question_3_headline": "Ce te reține să încerci $[projectName]?", + "identify_sign_up_barriers_question_4_headline": "Ce ai nevoie, dar $[projectName] nu oferă?", + "identify_sign_up_barriers_question_4_placeholder": "Tastează răspunsul aici...", + "identify_sign_up_barriers_question_5_headline": "Ce opțiuni aveți în vedere?", + "identify_sign_up_barriers_question_5_placeholder": "Tastează răspunsul aici...", + "identify_sign_up_barriers_question_6_headline": "Ce ți se pare complicat?", + "identify_sign_up_barriers_question_6_placeholder": "Tastează răspunsul aici...", + "identify_sign_up_barriers_question_7_headline": "Care sunt preocupările tale legate de prețuri?", + "identify_sign_up_barriers_question_7_placeholder": "Tastează răspunsul aici...", + "identify_sign_up_barriers_question_8_headline": "Vă rugăm să explicați:", + "identify_sign_up_barriers_question_8_placeholder": "Tastează răspunsul aici...", + "identify_sign_up_barriers_question_9_button_label": "Înregistrare", + "identify_sign_up_barriers_question_9_dismiss_button_label": "Sari pentru moment", + "identify_sign_up_barriers_question_9_headline": "Mulțumim! Iată codul tău: SIGNUPNOW10", + "identify_sign_up_barriers_question_9_html": "

Mulțumim mult pentru că ai luat timp pentru a împărtăși feedback \uD83D\uDE4F

", + "identify_upsell_opportunities_description": "Aflați cât timp economisește produsul dumneavoastră pentru utilizatori. Folosiți această informație pentru a face upsell.", + "identify_upsell_opportunities_name": "Identificați oportunitățile de upsell", + "identify_upsell_opportunities_question_1_choice_1": "Mai puțin de 1 oră", + "identify_upsell_opportunities_question_1_choice_2": "1 până la 2 ore", + "identify_upsell_opportunities_question_1_choice_3": "3 până la 5 ore", + "identify_upsell_opportunities_question_1_choice_4": "5+ ore", + "identify_upsell_opportunities_question_1_headline": "Câte ore economisește echipa dumneavoastră pe săptămână folosind $[projectName]?", + "improve_activation_rate_description": "Identifică punctele slabe în fluxul de onboarding pentru a crește activarea utilizatorilor.", + "improve_activation_rate_name": "Îmbunătățește Rata de Activare", + "improve_activation_rate_question_1_choice_1": "Nu părea util pentru mine", + "improve_activation_rate_question_1_choice_2": "Dificil de configurat sau utilizat", + "improve_activation_rate_question_1_choice_3": "Lipsit de funcții/funcționalități", + "improve_activation_rate_question_1_choice_4": "Pur și simplu nu am avut timp", + "improve_activation_rate_question_1_choice_5": "Altceva", + "improve_activation_rate_question_1_headline": "Care este principalul motiv pentru care nu ai terminat configurarea $[projectName]?", + "improve_activation_rate_question_2_headline": "Ce te-a făcut să crezi că $[projectName] nu ar fi util?", + "improve_activation_rate_question_2_placeholder": "Tastează răspunsul aici...", + "improve_activation_rate_question_3_headline": "Ce a fost dificil în configurarea sau utilizarea $[projectName]?", + "improve_activation_rate_question_3_placeholder": "Tastează răspunsul aici...", + "improve_activation_rate_question_4_headline": "Ce caracteristici sau funcționalități au lipsit?", + "improve_activation_rate_question_4_placeholder": "Tastează răspunsul aici...", + "improve_activation_rate_question_5_headline": "Cum am putea să-ți ușurăm începutul?", + "improve_activation_rate_question_5_placeholder": "Tastează răspunsul aici...", + "improve_activation_rate_question_6_headline": "Ce a fost? Vă rugăm să explicați:", + "improve_activation_rate_question_6_placeholder": "Tastează răspunsul aici...", + "improve_activation_rate_question_6_subheader": "Suntem nerăbdători să îl rezolvăm cât mai repede posibil.", + "improve_newsletter_content_description": "Află cum apreciază abonații tăi conținutul newsletterului.", + "improve_newsletter_content_name": "Îmbunătățește Conținutul Newsletterului", + "improve_newsletter_content_question_1_headline": "Cum ați evalua newsletterul din această săptămână?", + "improve_newsletter_content_question_1_lower_label": "Însă", + "improve_newsletter_content_question_1_upper_label": "Groza", + "improve_newsletter_content_question_2_headline": "Ce ar fi făcut ca newsletter-ul din această săptămână să fie mai util?", + "improve_newsletter_content_question_2_placeholder": "Tastează răspunsul aici...", + "improve_newsletter_content_question_3_button_label": "Bucuros să ajut!", + "improve_newsletter_content_question_3_dismiss_button_label": "Găsește-ți proprii prieteni", + "improve_newsletter_content_question_3_headline": "Mulțumim! ❤️ Răspândește iubirea către UN prieten.", + "improve_newsletter_content_question_3_html": "

Cine gândește ca tine? Ne-ai face o mare favoare dacă ai împărtăși episodul acestei săptămâni cu prietenul tău de creier!

", + "improve_trial_conversion_description": "Află de ce oamenii au încetat perioada de încercare. Aceste informații te ajută să îți îmbunătățești procesul de achiziție.", + "improve_trial_conversion_name": "Îmbunătățește Conversia În Proba", + "improve_trial_conversion_question_1_choice_1": "Nu am obținut prea multă valoare din el", + "improve_trial_conversion_question_1_choice_2": "M-am așteptat la altceva", + "improve_trial_conversion_question_1_choice_3": "E prea scump pentru ce face", + "improve_trial_conversion_question_1_choice_4": "Îmi lipseşte o funcționalitate", + "improve_trial_conversion_question_1_choice_5": "Doar mă uitam în jur", + "improve_trial_conversion_question_1_headline": "De ce ați oprit perioada de încercare?", + "improve_trial_conversion_question_1_subheader": "Ajută-ne să te înțelegem mai bine:", + "improve_trial_conversion_question_2_button_label": "Următorul", + "improve_trial_conversion_question_2_headline": "Ne pare rău să auzim asta. Care a fost cea mai mare problemă folosind $[projectName]?", + "improve_trial_conversion_question_4_button_label": "Obțineți 20% reducere", + "improve_trial_conversion_question_4_dismiss_button_label": "Omite", + "improve_trial_conversion_question_4_headline": "Ne pare rău să auzim asta! Obțineți 20% reducere în primul an.", + "improve_trial_conversion_question_4_html": "

Suntem bucuroși să vă oferim o reducere de 20% la un plan anual.

", + "improve_trial_conversion_question_5_button_label": "Următorul", + "improve_trial_conversion_question_5_headline": "Ce ați dori să obțineți?", + "improve_trial_conversion_question_5_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:", + "improve_trial_conversion_question_6_headline": "Cum rezolvați acum problema dumneavoastră?", + "improve_trial_conversion_question_6_subheader": "Vă rugăm să numiți soluțiile alternative:", + "integration_setup_survey_description": "Evaluați cât de ușor pot utilizatorii să adauge integrări la produsul dvs. Identificați punctele oarbe.", + "integration_setup_survey_name": "Sondaj de Utilizare a Integrării", + "integration_setup_survey_question_1_headline": "Cât de ușor a fost să configurați această integrare?", + "integration_setup_survey_question_1_lower_label": "Nu este ușor", + "integration_setup_survey_question_1_upper_label": "Foarte ușor", + "integration_setup_survey_question_2_headline": "De ce a fost dificil?", + "integration_setup_survey_question_2_placeholder": "Tastează răspunsul aici...", + "integration_setup_survey_question_3_headline": "Ce alte instrumente ați dori să utilizați cu $[projectName]?", + "integration_setup_survey_question_3_subheader": "Continuăm să dezvoltăm integrări, a ta poate fi următoarea:", + "interview_prompt_description": "Invită un subset specific de utilizatori să programeze un interviu cu echipa ta de produs.", + "interview_prompt_name": "Întrebare Interviu", + "interview_prompt_question_1_button_label": "Rezervă intervalul", + "interview_prompt_question_1_headline": "Ai 15 minute să discuți cu noi? \uD83D\uDE4F", + "interview_prompt_question_1_html": "Ești unul dintre utilizatorii noștri frecvenți. Ne-ar plăcea să te intervievăm pe scurt!", + "long_term_retention_check_in_description": "Evaluează satisfacția utilizatorilor pe termen lung, loialitatea și domeniile de îmbunătățire pentru a păstra utilizatorii loiali.", + "long_term_retention_check_in_name": "Evaluare Retenție pe Termen Lung", + "long_term_retention_check_in_question_10_headline": "Feedback sau comentarii suplimentare?", + "long_term_retention_check_in_question_10_placeholder": "Împărtășiți orice gânduri sau feedback care ne-ar putea ajuta să ne îmbunătățim...", + "long_term_retention_check_in_question_1_headline": "Cât de mulțumit sunteți de $[projectName] per total?", + "long_term_retention_check_in_question_1_lower_label": "Nesatisfăcut", + "long_term_retention_check_in_question_1_upper_label": "Foarte mulțumit", + "long_term_retention_check_in_question_2_headline": "Ce considerați cel mai valoros la $[projectName]?", + "long_term_retention_check_in_question_2_placeholder": "Descrie caracteristica sau beneficiul pe care îl prețuiești cel mai mult...", + "long_term_retention_check_in_question_3_choice_1": "Caracteristici", + "long_term_retention_check_in_question_3_choice_2": "Asistență clienți", + "long_term_retention_check_in_question_3_choice_3": "Experiența utilizatorului", + "long_term_retention_check_in_question_3_choice_4": "Prețuri", + "long_term_retention_check_in_question_3_choice_5": "Fiabilitate și disponibilitate", + "long_term_retention_check_in_question_3_headline": "Ce aspect al $[projectName] consideri că este esențial pentru experiența ta?", + "long_term_retention_check_in_question_4_headline": "În ce măsură $[projectName] îndeplinește așteptările tale?", + "long_term_retention_check_in_question_4_lower_label": "Nu îndeplinește", + "long_term_retention_check_in_question_4_upper_label": "Depășește așteptările", + "long_term_retention_check_in_question_5_headline": "Ce provocări sau frustrări ați întâmpinat în timp ce utilizați $[projectName]?", + "long_term_retention_check_in_question_5_placeholder": "Descrieți provocările sau îmbunătățirile pe care ați dori să le vedeți...", + "long_term_retention_check_in_question_6_headline": "Cât de probabil este să recomandați $[projectName] unui prieten sau coleg?", + "long_term_retention_check_in_question_6_lower_label": "Puțin probabil", + "long_term_retention_check_in_question_6_upper_label": "Foarte probabil", + "long_term_retention_check_in_question_7_choice_1": "Funcții noi și îmbunătățiri", + "long_term_retention_check_in_question_7_choice_2": "Suport clienți îmbunătățit", + "long_term_retention_check_in_question_7_choice_3": "Opțiuni mai bune de preț", + "long_term_retention_check_in_question_7_choice_4": "Mai multe integrări", + "long_term_retention_check_in_question_7_choice_5": "Îmbunătățiri ale experienței utilizatorilor", + "long_term_retention_check_in_question_7_headline": "Ce te-ar face mai predispus să rămâi utilizator pe termen lung?", + "long_term_retention_check_in_question_8_headline": "Dacă ai putea schimba un lucru la $[projectName], care ar fi acela?", + "long_term_retention_check_in_question_8_placeholder": "Împărtășește orice modificări sau funcții pe care ai dori să le luăm în considerare...", + "long_term_retention_check_in_question_9_headline": "Cât de mulțumit ești de actualizările și frecvența produsului nostru?", + "long_term_retention_check_in_question_9_lower_label": "Nemulțumit", + "long_term_retention_check_in_question_9_upper_label": "Foarte mulțumit", + "market_attribution_description": "Aflați cum au auzit utilizatorii pentru prima dată despre produsul dumneavoastră.", + "market_attribution_name": "Atribuirea Marketingului", + "market_attribution_question_1_choice_1": "Recomandare", + "market_attribution_question_1_choice_2": "Rețele Sociale", + "market_attribution_question_1_choice_3": "Reclame", + "market_attribution_question_1_choice_4": "Căutare Google", + "market_attribution_question_1_choice_5": "Într-un Podcast", + "market_attribution_question_1_headline": "Cum ați aflat pentru prima dată despre noi?", + "market_attribution_question_1_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:", + "market_site_clarity_description": "Identificați utilizatorii care părăsesc site-ul dvs. de marketing. Îmbunătățiți mesajele dvs.", + "market_site_clarity_name": "Claritate Site de Marketing", + "market_site_clarity_question_1_choice_1": "Da, complet", + "market_site_clarity_question_1_choice_2": "Un fel de...", + "market_site_clarity_question_1_choice_3": "Nu, deloc", + "market_site_clarity_question_1_headline": "Aveți toate informațiile necesare pentru a încerca $[projectName]?", + "market_site_clarity_question_2_headline": "Ce lipsește sau ce nu este clar pentru tine despre $[projectName]?", + "market_site_clarity_question_3_button_label": "Obține reducere", + "market_site_clarity_question_3_headline": "Mulțumim pentru răspuns! Beneficiezi de 25% reducere pentru primele 6 luni:", + "matrix": "Matrice", + "matrix_description": "Creați un tabel pentru a evalua mai multe elemente pe baza aceluiași set de criterii", + "measure_search_experience_description": "Măsurați cât de relevante sunt rezultatele căutării dvs.", + "measure_search_experience_name": "Măsurați experiența căutării", + "measure_search_experience_question_1_headline": "Cât de relevante sunt aceste rezultate ale căutării?", + "measure_search_experience_question_1_lower_label": "Deloc relevant", + "measure_search_experience_question_1_upper_label": "Foarte relevant", + "measure_search_experience_question_2_headline": "Uf! Ce face ca rezultatele să fie irelevante pentru tine?", + "measure_search_experience_question_2_placeholder": "Tastează răspunsul aici...", + "measure_search_experience_question_3_headline": "Minunat! Există ceva ce putem face pentru a-ți îmbunătăți experiența?", + "measure_search_experience_question_3_placeholder": "Tastează răspunsul aici...", + "measure_task_accomplishment_description": "Verificați dacă oamenii își îndeplinesc sarcina. Oamenii de succes sunt clienți mai buni.", + "measure_task_accomplishment_name": "Măsurarea îndeplinirii sarcinilor", + "measure_task_accomplishment_question_1_headline": "Ai reușit să îndeplinești ceea ce ai venit să faci aici astăzi?", + "measure_task_accomplishment_question_1_option_1_label": "Da", + "measure_task_accomplishment_question_1_option_2_label": "Lucrez la asta, șefule", + "measure_task_accomplishment_question_1_option_3_label": "Nu", + "measure_task_accomplishment_question_2_headline": "Cât de ușor a fost să atingi obiectivul tău?", + "measure_task_accomplishment_question_2_lower_label": "Foarte dificil", + "measure_task_accomplishment_question_2_upper_label": "Foarte ușor", + "measure_task_accomplishment_question_3_headline": "Ce a făcut să fie dificil?", + "measure_task_accomplishment_question_3_placeholder": "Tastează răspunsul aici...", + "measure_task_accomplishment_question_4_button_label": "Trimite", + "measure_task_accomplishment_question_4_headline": "Minunat! Ce ai venit să faci astăzi aici?", + "measure_task_accomplishment_question_5_button_label": "Trimite", + "measure_task_accomplishment_question_5_headline": "Ce te-a oprit?", + "measure_task_accomplishment_question_5_placeholder": "Tastează răspunsul aici...", + "multi_select": "Selectare multiplă", + "multi_select_description": "Cereți respondenților să aleagă una sau mai multe opțiuni", + "new_integration_survey_description": "Aflați ce integrări ar dori utilizatorii dvs. să vadă în continuare.", + "new_integration_survey_name": "Nou Sondaj de Integrare", + "new_integration_survey_question_1_choice_1": "PostHog", + "new_integration_survey_question_1_choice_2": "Segment", + "new_integration_survey_question_1_choice_3": "Hubspot", + "new_integration_survey_question_1_choice_4": "Twilio", + "new_integration_survey_question_1_choice_5": "Altele", + "new_integration_survey_question_1_headline": "Ce alte instrumente folosiți?", + "next": "Următorul", + "nps": "Scorul Net Promoter (NPS)", + "nps_description": "Măsurați Net-Promoter-Score (0-10)", + "nps_lower_label": "Deloc probabil", + "nps_name": "Scorul Net Promoter (NPS)", + "nps_question_1_headline": "Cât de probabil este să recomandați $[projectName] unui prieten sau coleg?", + "nps_question_1_lower_label": "Puțin probabil", + "nps_question_1_upper_label": "Foarte probabil", + "nps_question_2_headline": "Ce v-a determinat să dați acea evaluare?", + "nps_survey_name": "Chestionar NPS", + "nps_survey_question_1_headline": "Cât de probabil este să recomandați $[projectName] unui prieten sau coleg?", + "nps_survey_question_1_lower_label": "Deloc probabil", + "nps_survey_question_1_upper_label": "Foarte probabil", + "nps_survey_question_2_headline": "Pentru a ne ajuta să ne îmbunătățim, puteți descrie motiv(o)ele pentru evaluarea dvs?", + "nps_survey_question_3_headline": "Alte comentarii, feedback sau preocupări?", + "nps_upper_label": "Foarte probabil", + "onboarding_segmentation": "Segmentare la Înscriere", + "onboarding_segmentation_description": "Aflați mai multe despre cine s-a înscris la produsul dvs. și de ce.", + "onboarding_segmentation_question_1_choice_1": "Fondator", + "onboarding_segmentation_question_1_choice_2": "Executiv", + "onboarding_segmentation_question_1_choice_3": "Manager de Produs", + "onboarding_segmentation_question_1_choice_4": "Proprietar al Produsului", + "onboarding_segmentation_question_1_choice_5": "Inginer Software", + "onboarding_segmentation_question_1_headline": "Care este rolul tău?", + "onboarding_segmentation_question_1_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:", + "onboarding_segmentation_question_2_choice_1": "doar eu", + "onboarding_segmentation_question_2_choice_2": "1-5 angajați", + "onboarding_segmentation_question_2_choice_3": "6-10 angajați", + "onboarding_segmentation_question_2_choice_4": "11-100 angajați", + "onboarding_segmentation_question_2_choice_5": "peste 100 de angajați", + "onboarding_segmentation_question_2_headline": "Care este dimensiunea companiei dumneavoastră?", + "onboarding_segmentation_question_2_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:", + "onboarding_segmentation_question_3_choice_1": "Recomandare", + "onboarding_segmentation_question_3_choice_2": "Rețele Sociale", + "onboarding_segmentation_question_3_choice_3": "Reclame", + "onboarding_segmentation_question_3_choice_4": "Căutare Google", + "onboarding_segmentation_question_3_choice_5": "Într-un Podcast", + "onboarding_segmentation_question_3_headline": "Cum ai aflat pentru prima dată despre noi?", + "onboarding_segmentation_question_3_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:", + "picture_selection": "Selecție Poze", + "picture_selection_description": "Cereți respondenților să aleagă una sau mai multe imagini", + "preview_survey_ending_card_description": "Vă rugăm să continuați onboarding-ul.", + "preview_survey_ending_card_headline": "Ai reușit!", + "preview_survey_name": "Previzualizare Chestionar", + "preview_survey_question_1_headline": "Cum ai evalua {projectName}?", + "preview_survey_question_1_lower_label": "Nu este bine", + "preview_survey_question_1_subheader": "Aceasta este o previzualizare a chestionarului.", + "preview_survey_question_1_upper_label": "Foarte bun", + "preview_survey_question_2_back_button_label": "Înapoi", + "preview_survey_question_2_choice_1_label": "Da, ține-mă informat.", + "preview_survey_question_2_choice_2_label": "Nu, mulţumesc!", + "preview_survey_question_2_headline": "Vrei să fii în temă?", + "preview_survey_welcome_card_headline": "Bun venit!", + "preview_survey_welcome_card_html": "Mulțumesc pentru feedback-ul dvs - să începem!", + "prioritize_features_description": "Identificați caracteristicile de care utilizatorii dumneavoastră au cel mai mult și cel mai puțin nevoie.", + "prioritize_features_name": "Prioritizați Caracteristicile", + "prioritize_features_question_1_choice_1": "Caracteristica 1", + "prioritize_features_question_1_choice_2": "Caracteristica 2", + "prioritize_features_question_1_choice_3": "Caracteristica 3", + "prioritize_features_question_1_choice_4": "Altele", + "prioritize_features_question_1_headline": "Care dintre aceste funcționalități ți-ar fi cea mai valoroasă?", + "prioritize_features_question_2_choice_1": "Caracteristica 1", + "prioritize_features_question_2_choice_2": "Caracteristica 2", + "prioritize_features_question_2_choice_3": "Caracteristica 3", + "prioritize_features_question_2_headline": "Care dintre aceste funcționalități ți-ar fi cea mai puțin valoroasă?", + "prioritize_features_question_3_headline": "Cum altfel am putea îmbunătăți experiența dumneavoastră cu $[projectName]?", + "prioritize_features_question_3_placeholder": "Tastează răspunsul aici...", + "product_market_fit_short_description": "Măsurați PMF evaluând cât de dezamăgiți ar fi utilizatorii dacă produsul dvs. ar dispărea.", + "product_market_fit_short_name": "Sondaj Fit Piață Produs (Scurt)", + "product_market_fit_short_question_1_choice_1": "Deloc dezamăgit", + "product_market_fit_short_question_1_choice_2": "Puțin dezamăgit", + "product_market_fit_short_question_1_choice_3": "Foarte dezamăgit", + "product_market_fit_short_question_1_headline": "Cât de dezamăgit ai fi dacă nu ai mai putea folosi $[projectName]?", + "product_market_fit_short_question_1_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:", + "product_market_fit_short_question_2_headline": "Cum putem îmbunătăți $[projectName] pentru dumneavoastră?", + "product_market_fit_short_question_2_subheader": "Vă rugăm să fiți cât mai specific posibil.", + "product_market_fit_superhuman": "Product Market Fit (Superhuman)", + "product_market_fit_superhuman_description": "Măsurați PMF evaluând cât de dezamăgiți ar fi utilizatorii dacă produsul dvs. ar dispărea.", + "product_market_fit_superhuman_question_1_button_label": "Bucuros să ajut!", + "product_market_fit_superhuman_question_1_dismiss_button_label": "Nu, mulţumesc", + "product_market_fit_superhuman_question_1_headline": "Ești unul dintre utilizatorii noștri fideli! Ai 5 minute?", + "product_market_fit_superhuman_question_1_html": "

Ne-ar plăcea să înțelegem mai bine experiența dvs. ca utilizator. Împărtășirea opiniei dvs. ajută foarte mult.

", + "product_market_fit_superhuman_question_2_choice_1": "Deloc dezamăgit", + "product_market_fit_superhuman_question_2_choice_2": "Puțin dezamăgit", + "product_market_fit_superhuman_question_2_choice_3": "Foarte dezamăgit", + "product_market_fit_superhuman_question_2_headline": "Cât de dezamăgit ai fi dacă nu ai mai putea folosi $[projectName]?", + "product_market_fit_superhuman_question_2_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:", + "product_market_fit_superhuman_question_3_choice_1": "Fondator", + "product_market_fit_superhuman_question_3_choice_2": "Executiv", + "product_market_fit_superhuman_question_3_choice_3": "Manager de Produs", + "product_market_fit_superhuman_question_3_choice_4": "Proprietar al Produsului", + "product_market_fit_superhuman_question_3_choice_5": "Inginer Software", + "product_market_fit_superhuman_question_3_headline": "Care este rolul tău?", + "product_market_fit_superhuman_question_3_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:", + "product_market_fit_superhuman_question_4_headline": "Ce tip de persoane crezi că ar beneficia cel mai mult de la $[projectName]?", + "product_market_fit_superhuman_question_5_headline": "Care este beneficiul principal pe care îl primiți de la $[projectName]?", + "product_market_fit_superhuman_question_6_headline": "Cum putem îmbunătăți $[projectName] pentru dumneavoastră?", + "product_market_fit_superhuman_question_6_subheader": "Vă rugăm să fiți cât mai specific posibil.", + "professional_development_growth_survey_description": "Evaluează satisfacția angajaților cu privire la oportunitățile de dezvoltare și creștere profesională.", + "professional_development_growth_survey_name": "Sondaj de Creștere și Dezvoltare Profesională", + "professional_development_growth_survey_question_1_headline": "Simt că am oportunități să cresc și să-mi dezvolt abilitățile la muncă.", + "professional_development_growth_survey_question_1_lower_label": "Nicio oportunitate de creștere", + "professional_development_growth_survey_question_1_upper_label": "Multe oportunități de creștere", + "professional_development_growth_survey_question_2_headline": "Am suficientă autonomie pentru a lua decizii cu privire la modul în care îmi fac treaba.", + "professional_development_growth_survey_question_2_lower_label": "Fără autonomie", + "professional_development_growth_survey_question_2_upper_label": "Autonomie completă", + "professional_development_growth_survey_question_3_headline": "Obiectivele mele de la locul de muncă sunt clare și aliniate cu dezvoltarea mea.", + "professional_development_growth_survey_question_3_lower_label": "Obiective neclare", + "professional_development_growth_survey_question_3_upper_label": "Obiective clare și aliniate", + "professional_development_growth_survey_question_4_headline": "Ce ar putea fi îmbunătățit pentru a sprijini creșterea dvs. profesională?", + "professional_development_growth_survey_question_4_placeholder": "Tastează răspunsul aici...", + "professional_development_survey_description": "Evaluează satisfacția angajaților cu privire la oportunitățile de dezvoltare și creștere profesională.", + "professional_development_survey_name": "Sondaj de Dezvoltare Profesională", + "professional_development_survey_question_1_choice_1": "Da", + "professional_development_survey_question_1_choice_2": "Nu", + "professional_development_survey_question_1_headline": "Ești interesat de activități de dezvoltare profesională?", + "professional_development_survey_question_2_choice_1": "Evenimente de networking", + "professional_development_survey_question_2_choice_2": "Conferințe sau seminarii", + "professional_development_survey_question_2_choice_3": "Cursuri sau ateliere", + "professional_development_survey_question_2_choice_4": "Mentorat", + "professional_development_survey_question_2_choice_5": "Cercetare individuală", + "professional_development_survey_question_2_choice_6": "Altele", + "professional_development_survey_question_2_headline": "Ce tipuri de activități de dezvoltare profesională credeți că ar fi cele mai valoroase pentru creșterea dumneavoastră?", + "professional_development_survey_question_2_subheader": "Selectați toate opțiunile care se aplică", + "professional_development_survey_question_3_choice_1": "Da", + "professional_development_survey_question_3_choice_2": "Nu", + "professional_development_survey_question_3_headline": "Ați dedicat timp pentru dezvoltarea dumneavoastră profesională în trecut?", + "professional_development_survey_question_4_headline": "Cât de susținut vă simțiți la locul de muncă în privința dezvoltării profesionale?", + "professional_development_survey_question_4_lower_label": "Deloc susținut", + "professional_development_survey_question_4_upper_label": "Extrem de susţinut", + "professional_development_survey_question_5_choice_1": "Pentru cunoștințele mele proprii", + "professional_development_survey_question_5_choice_2": "Să obțin mai multe responsabilități", + "professional_development_survey_question_5_choice_3": "Îmbunătățirea abilităților mele", + "professional_development_survey_question_5_choice_4": "Progres pe actuala mea cale de carieră", + "professional_development_survey_question_5_choice_5": "În căutarea unui nou loc de muncă", + "professional_development_survey_question_5_choice_6": "Altele", + "professional_development_survey_question_5_headline": "Care sunt principalele tale motive pentru a dori să-ți dedici timp dezvoltării profesionale?", + "ranking": "Clasament", + "ranking_description": "Întrebați respondenții să ordoneze elementele după preferință sau importanță", + "rate_checkout_experience_description": "Permiteți clienților să evalueze experiența de finalizare a cumpărăturilor pentru a ajusta conversia.", + "rate_checkout_experience_name": "Evaluare experiența de finalizare a cumpărăturilor", + "rate_checkout_experience_question_1_headline": "Cât de ușor sau dificil a fost să finalizați procesul de checkout?", + "rate_checkout_experience_question_1_lower_label": "Foarte dificil", + "rate_checkout_experience_question_1_upper_label": "Foarte ușor", + "rate_checkout_experience_question_2_headline": "Ne pare rău pentru asta! Ce te-ar fi ajutat să fie mai ușor?", + "rate_checkout_experience_question_2_placeholder": "Tastează răspunsul aici...", + "rate_checkout_experience_question_3_headline": "Minunat! Există ceva ce putem face pentru a-ți îmbunătăți experiența?", + "rate_checkout_experience_question_3_placeholder": "Tastează răspunsul aici...", + "rating": "Evaluare", + "rating_description": "Solicitați respondenților să ofere o evaluare (stele, emoticoane, numere)", + "rating_lower_label": "Nu este bine", + "rating_upper_label": "Foarte bun", + "recognition_and_reward_survey_description": "Evaluează satisfacția angajaților cu privire la recunoaștere, recompense, sprijinul conducerii și libertatea de exprimare.", + "recognition_and_reward_survey_name": "Recunoaștere și Răsplată", + "recognition_and_reward_survey_question_1_headline": "Când mă descurc bine, contribuțiile mele sunt recunoscute de organizație.", + "recognition_and_reward_survey_question_1_lower_label": "Deloc recunoscută", + "recognition_and_reward_survey_question_1_upper_label": "Foarte bine recunoscută", + "recognition_and_reward_survey_question_2_headline": "Mă simt bine recompensat pentru munca pe care o fac.", + "recognition_and_reward_survey_question_2_lower_label": "Nerecompensat corect", + "recognition_and_reward_survey_question_2_upper_label": "Foarte bine recompensat", + "recognition_and_reward_survey_question_3_headline": "Mă simt confortabil să îmi exprim deschis opiniile la serviciu.", + "recognition_and_reward_survey_question_3_lower_label": "Inconfortabil", + "recognition_and_reward_survey_question_3_upper_label": "Foarte confortabil", + "recognition_and_reward_survey_question_4_headline": "Cum ar putea organizația să îmbunătățească recunoașterea și recompensele?", + "recognition_and_reward_survey_question_4_placeholder": "Tastează răspunsul aici...", + "review_prompt_description": "Invită utilizatorii care iubesc produsul tău să-l revizuiască public.", + "review_prompt_name": "Sugestie de Revizuire", + "review_prompt_question_1_headline": "Cum îți place $[projectName]?", + "review_prompt_question_1_lower_label": "Nu este bine", + "review_prompt_question_1_upper_label": "Foarte mulțumit", + "review_prompt_question_2_button_label": "Scrie recenzie", + "review_prompt_question_2_headline": "Bucuros să aud \uD83D\uDE4F Vă rugăm să scrieți o recenzie pentru noi!", + "review_prompt_question_2_html": "

Acest lucru ne ajută foarte mult.

", + "review_prompt_question_3_button_label": "Trimite", + "review_prompt_question_3_headline": "Ne pare rău să auzim asta! Care este SINGURUL lucru pe care îl putem îmbunătăți?", + "review_prompt_question_3_placeholder": "Tastează răspunsul aici...", + "review_prompt_question_3_subheader": "Ajută-ne să îmbunătățim experiența ta.", + "schedule_a_meeting": "Programați o întâlnire", + "schedule_a_meeting_description": "Solicitați respondenților să rezerve un interval orar pentru întâlniri sau apeluri", + "single_select": "Selectare unică", + "single_select_description": "Oferă o listă de opțiuni (alege una)", + "site_abandonment_survey": "Chestionar de abandonare a site-ului", + "site_abandonment_survey_description": "Înțelegeți motivele abandonării site-ului în magazinul dvs. online.", + "site_abandonment_survey_question_1_html": "

Am observat că părăsiți site-ul nostru fără să faceți o achiziție. Ne-ar plăcea să înțelegem de ce.

", + "site_abandonment_survey_question_2_button_label": "Desigur!", + "site_abandonment_survey_question_2_dismiss_button_label": "Nu, mulţumesc", + "site_abandonment_survey_question_2_headline": "Ai un minut?", + "site_abandonment_survey_question_3_choice_1": "Nu găsesc ce caut", + "site_abandonment_survey_question_3_choice_2": "Găsit un site mai bun", + "site_abandonment_survey_question_3_choice_3": "Site-ul este prea lent", + "site_abandonment_survey_question_3_choice_4": "Doar navighez", + "site_abandonment_survey_question_3_choice_5": "Am găsit un preț mai bun în altă parte", + "site_abandonment_survey_question_3_choice_6": "Altele", + "site_abandonment_survey_question_3_headline": "Care este motivul principal pentru care părăsiți site-ul nostru?", + "site_abandonment_survey_question_3_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:", + "site_abandonment_survey_question_4_headline": "Vă rugăm să detaliați motivul pentru care ați părăsit site-ul:", + "site_abandonment_survey_question_5_headline": "Cum ați evalua experiența generală pe site-ul nostru?", + "site_abandonment_survey_question_5_lower_label": "Foarte nemulțumit", + "site_abandonment_survey_question_5_upper_label": "Foarte mulțumit", + "site_abandonment_survey_question_6_choice_1": "Timp de încărcare mai rapid", + "site_abandonment_survey_question_6_choice_2": "Funcționalitate mai bună de căutare a produselor", + "site_abandonment_survey_question_6_choice_3": "Mai multă varietate de produse", + "site_abandonment_survey_question_6_choice_4": "Design îmbunătățit al site-ului", + "site_abandonment_survey_question_6_choice_5": "Mai multe recenzii de la clienți", + "site_abandonment_survey_question_6_headline": "Ce îmbunătățiri te-ar încuraja să rămâi mai mult timp pe site-ul nostru?", + "site_abandonment_survey_question_6_subheader": "Vă rugăm să selectați toate opțiunile care se aplică:", + "site_abandonment_survey_question_7_headline": "Ați dori să primiți informații despre produse noi și promoții?", + "site_abandonment_survey_question_7_label": "Da, vă rog să-mi trimiteți mesaj.", + "site_abandonment_survey_question_8_headline": "Vă rugăm să împărtășiți adresa de email:", + "site_abandonment_survey_question_9_headline": "Comentarii sau sugestii suplimentare?", + "skip": "Omite", + "smileys_survey_name": "Sondaj Smileys", + "smileys_survey_question_1_headline": "Cum îți place $[projectName]?", + "smileys_survey_question_1_lower_label": "Nu este bine", + "smileys_survey_question_1_upper_label": "Foarte mulțumit", + "smileys_survey_question_2_button_label": "Scrie recenzie", + "smileys_survey_question_2_headline": "Bucuros să aud \uD83D\uDE4F Vă rugăm să scrieți o recenzie pentru noi!", + "smileys_survey_question_2_html": "

Acest lucru ne ajută foarte mult.

", + "smileys_survey_question_3_button_label": "Trimite", + "smileys_survey_question_3_headline": "Ne pare rău să auzim asta! Care este SINGURUL lucru pe care îl putem îmbunătăți?", + "smileys_survey_question_3_placeholder": "Tastează răspunsul aici...", + "smileys_survey_question_3_subheader": "Ajută-ne să îmbunătățim experiența ta.", + "star_rating_survey_name": "Chestionar de evaluare a proiectului $[projectName]", + "star_rating_survey_question_1_headline": "Cum îți place $[projectName]?", + "star_rating_survey_question_1_lower_label": "Extrem de nemulțumit", + "star_rating_survey_question_1_upper_label": "Extrem de mulțumit", + "star_rating_survey_question_2_button_label": "Scrie recenzie", + "star_rating_survey_question_2_headline": "Bucuros să aud \uD83D\uDE4F Vă rugăm să scrieți o recenzie pentru noi!", + "star_rating_survey_question_2_html": "

Acest lucru ne ajută foarte mult.

", + "star_rating_survey_question_3_button_label": "Trimite", + "star_rating_survey_question_3_headline": "Ne pare rău să auzim asta! Care este SINGURUL lucru pe care îl putem îmbunătăți?", + "star_rating_survey_question_3_placeholder": "Tastează răspunsul aici...", + "star_rating_survey_question_3_subheader": "Ajută-ne să îmbunătățim experiența ta.", + "statement_call_to_action": "Declarație (Îndemn la acțiune)", + "strongly_agree": "De acord cu tărie", + "strongly_disagree": "Dezacord puternic", + "supportive_work_culture_survey_description": "Evaluează percepțiile angajaților asupra sprijinului oferit de conducerea companiei, comunicării și mediului de lucru în general.", + "supportive_work_culture_survey_name": "Cultură de lucru suportivă", + "supportive_work_culture_survey_question_1_headline": "Managerul meu îmi oferă sprijinul de care am nevoie pentru a-mi îndeplini munca.", + "supportive_work_culture_survey_question_1_lower_label": "Neacceptat", + "supportive_work_culture_survey_question_1_upper_label": "Foarte susținut", + "supportive_work_culture_survey_question_2_headline": "Comunicarea în cadrul organizației este deschisă și eficientă.", + "supportive_work_culture_survey_question_2_lower_label": "Comunicație slabă", + "supportive_work_culture_survey_question_2_upper_label": "Comunicație excelentă", + "supportive_work_culture_survey_question_3_headline": "Mediul de lucru este pozitiv și îmi susține starea de bine.", + "supportive_work_culture_survey_question_3_lower_label": "Nesusținut", + "supportive_work_culture_survey_question_3_upper_label": "Foarte susținător", + "supportive_work_culture_survey_question_4_headline": "Cum ar putea fi îmbunătățită cultura organizațională pentru a vă susține mai bine?", + "supportive_work_culture_survey_question_4_placeholder": "Tastează răspunsul aici...", + "uncover_strengths_and_weaknesses_description": "Află ce le place și ce nu le place utilizatorilor despre produsul sau oferta ta.", + "uncover_strengths_and_weaknesses_name": "Descoperă Puncte Tarie & Slăbiciuni", + "uncover_strengths_and_weaknesses_question_1_choice_1": "Ușurința în utilizare", + "uncover_strengths_and_weaknesses_question_1_choice_2": "Raport calitate-preț bun", + "uncover_strengths_and_weaknesses_question_1_choice_3": "Este open-source", + "uncover_strengths_and_weaknesses_question_1_choice_4": "Fondatorii sunt drăguți", + "uncover_strengths_and_weaknesses_question_1_choice_5": "Altele", + "uncover_strengths_and_weaknesses_question_1_headline": "Ce apreciați cel mai mult la $[projectName]?", + "uncover_strengths_and_weaknesses_question_2_choice_1": "Documentație", + "uncover_strengths_and_weaknesses_question_2_choice_2": "Personalizabilitate", + "uncover_strengths_and_weaknesses_question_2_choice_3": "Prețuri", + "uncover_strengths_and_weaknesses_question_2_choice_4": "Altele", + "uncover_strengths_and_weaknesses_question_2_headline": "La ce ar trebui să facem îmbunătățiri?", + "uncover_strengths_and_weaknesses_question_2_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:", + "uncover_strengths_and_weaknesses_question_3_headline": "Ați dori să adăugați ceva?", + "uncover_strengths_and_weaknesses_question_3_subheader": "Exprimați-vă liber opinia, și noi facem la fel.", + "understand_low_engagement_description": "Identifică motivele pentru implicarea redusă pentru a îmbunătăți adoptarea utilizatorilor.", + "understand_low_engagement_name": "Întelege Implicarea Redusă", + "understand_low_engagement_question_1_choice_1": "Dificil de utilizat", + "understand_low_engagement_question_1_choice_2": "Găsit o alternativă mai bună", + "understand_low_engagement_question_1_choice_3": "Nu am avut pur și simplu timp", + "understand_low_engagement_question_1_choice_4": "Lipsesc funcționalitățile de care am nevoie", + "understand_low_engagement_question_1_choice_5": "Altele", + "understand_low_engagement_question_1_headline": "Care este principalul motiv pentru care nu te-ai întors recent la $[projectName]?", + "understand_low_engagement_question_2_headline": "Ce este dificil în utilizarea $[projectName]?", + "understand_low_engagement_question_2_placeholder": "Tastează răspunsul aici...", + "understand_low_engagement_question_3_headline": "Am înțeles. Ce alternativă folosiți în continuare?", + "understand_low_engagement_question_3_placeholder": "Tastează răspunsul aici...", + "understand_low_engagement_question_4_headline": "Am înțeles. Cum am putea să-ți ușurăm începutul?", + "understand_low_engagement_question_4_placeholder": "Tastează răspunsul aici...", + "understand_low_engagement_question_5_headline": "Am înțeles. Ce caracteristici sau funcționalități au lipsit?", + "understand_low_engagement_question_5_placeholder": "Tastează răspunsul aici...", + "understand_low_engagement_question_6_headline": "Vă rugăm să adăugați mai multe detalii:", + "understand_low_engagement_question_6_placeholder": "Tastează răspunsul aici...", + "understand_purchase_intention_description": "Află cât de aproape sunt vizitatorii de a cumpăra sau de a se abona.", + "understand_purchase_intention_name": "Înțelegerea intenției de cumpărare", + "understand_purchase_intention_question_1_headline": "Cât de probabil este să cumpărați de la noi astăzi?", + "understand_purchase_intention_question_1_lower_label": "Deloc probabil", + "understand_purchase_intention_question_1_upper_label": "Foarte probabil", + "understand_purchase_intention_question_2_headline": "Am înțeles. Care este motivul principal pentru vizita ta astăzi?", + "understand_purchase_intention_question_2_placeholder": "Tastează răspunsul aici...", + "understand_purchase_intention_question_3_headline": "Care, dacă este ceva, te reține să faci o achiziție azi?", + "understand_purchase_intention_question_3_placeholder": "Tastează răspunsul aici...", + "usability_question_10_headline": "A trebuit să învăț multe înainte de a putea începe să folosesc sistemul corect.", + "usability_question_1_headline": "Probabil aș folosi acest sistem des.", + "usability_question_2_headline": "Sistemul părea mai complicat decât trebuia să fie.", + "usability_question_3_headline": "Sistemul a fost ușor de înțeles.", + "usability_question_4_headline": "Cred că aș avea nevoie de ajutorul unui expert tehnic pentru a utiliza acest sistem.", + "usability_question_5_headline": "Totul din sistem părea să funcționeze bine împreună.", + "usability_question_6_headline": "Sistemul părea inconsecvent în modul de funcționare.", + "usability_question_7_headline": "Cred că majoritatea oamenilor ar putea învăța rapid să folosească acest sistem.", + "usability_question_8_headline": "Folosirea sistemului a părut o bătaie de cap.", + "usability_question_9_headline": "M-am simțit încrezător în timp ce utilizam sistemul.", + "usability_rating_description": "Măsurați uzabilitatea percepută cerând utilizatorilor să își evalueze experiența cu produsul dumneavoastră folosind un chestionar standardizat din 10 întrebări.", + "usability_score_name": "Scor de Uzabilitate al Sistemului (SUS)" + } +} diff --git a/packages/lib/messages/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json similarity index 90% rename from packages/lib/messages/zh-Hant-TW.json rename to apps/web/locales/zh-Hant-TW.json index 86e79eb1f9f6..dcdec5d7fc01 100644 --- a/packages/lib/messages/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -1,12 +1,23 @@ { "auth": { - "continue_with_azure": "使用 Azure 繼續", + "continue_with_azure": "繼續使用 Microsoft", "continue_with_email": "使用電子郵件繼續", "continue_with_github": "使用 GitHub 繼續", "continue_with_google": "使用 Google 繼續", "continue_with_oidc": "使用 '{'oidcDisplayName'}' 繼續", "continue_with_openid": "使用 OpenID 繼續", "continue_with_saml": "使用 SAML SSO 繼續", + "email-change": { + "confirm_password_description": "在更改您的電子郵件地址之前,請確認您的密碼", + "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": "舊 電子郵件" + }, "forgot-password": { "back_to_login": "返回登入", "email-sent": { @@ -23,7 +34,8 @@ "text": "您現在可以使用新密碼登入" } }, - "reset_password": "重設密碼" + "reset_password": "重設密碼", + "reset_password_description": "您將被登出以重設您的密碼。" }, "invite": { "create_account": "建立帳戶", @@ -68,7 +80,7 @@ }, "signup_without_verification_success": { "user_successfully_created": "使用者建立成功", - "user_successfully_created_description": "您的新使用者已成功建立。請點擊下方按鈕並登入您的帳戶。" + "user_successfully_created_info": "我們已檢查與 {email} 相關聯的帳戶。如果不存在,我們已為您建立一個。如果帳戶已存在,則未進行任何更改。請在下方登入以繼續。" }, "testimonial_1": "我們在同一個平台上測量文件的清晰度,並從客戶流失中學習。很棒的產品,團隊反應非常迅速!", "testimonial_all_features_included": "包含所有功能", @@ -78,12 +90,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": "驗證電子郵件已成功發送。請檢查您的收件匣。", - "we_sent_an_email_to": "我們已發送一封電子郵件至 '{'email'}'。", + "verification_email_resent_successfully": "驗證電子郵件已發送!請檢查您的收件箱。", + "verification_email_successfully_sent_info": "如果有一個帳戶與 {email} 相關聯,我們已發送驗證連結至該地址。請檢查您的收件箱以完成註冊。", "you_didnt_receive_an_email_or_your_link_expired": "您沒有收到電子郵件或您的連結已過期?" }, "verify": { @@ -96,6 +108,10 @@ "thanks_for_upgrading": "非常感謝您升級您的 Formbricks 訂閱。", "upgrade_successful": "升級成功" }, + "c": { + "link_expired": "您 的 連結 已過期。", + "link_expired_description": "您 使用 的 連結 已無效。" + }, "common": { "accepted": "已接受", "account": "帳戶", @@ -108,6 +124,7 @@ "add_action": "新增操作", "add_filter": "新增篩選器", "add_logo": "新增標誌", + "add_member": "新增成員", "add_project": "新增專案", "add_to_team": "新增至團隊", "all": "全部", @@ -123,7 +140,6 @@ "app_survey": "應用程式問卷", "apply_filters": "套用篩選器", "are_you_sure": "您確定嗎?", - "are_you_sure_this_action_cannot_be_undone": "您確定嗎?此操作無法復原。", "attributes": "屬性", "avatar": "頭像", "back": "返回", @@ -149,11 +165,13 @@ "connect_formbricks": "連線 Formbricks", "connected": "已連線", "contacts": "聯絡人", + "copied": "已 複製", "copied_to_clipboard": "已複製到剪貼簿", "copy": "複製", "copy_code": "複製程式碼", "copy_link": "複製連結", "create_new_organization": "建立新組織", + "create_project": "建立專案", "create_segment": "建立區隔", "create_survey": "建立問卷", "created": "已建立", @@ -180,13 +198,10 @@ "e_commerce": "電子商務", "edit": "編輯", "email": "電子郵件", - "embed": "嵌入", "enterprise_license": "企業授權", "environment_not_found": "找不到環境", "environment_notice": "您目前在 '{'environment'}' 環境中。", "error": "錯誤", - "error_component_description": "此資源不存在或您沒有存取權限。", - "error_component_title": "載入資源錯誤", "expand_rows": "展開列", "finish": "完成", "follow_these": "按照這些步驟", @@ -209,7 +224,6 @@ "in_progress": "進行中", "inactive_surveys": "停用中的問卷", "input_type": "輸入類型", - "insights": "洞察", "integration": "整合", "integrations": "整合", "invalid_date": "無效日期", @@ -225,7 +239,6 @@ "limits_reached": "已達上限", "link": "連結", "link_and_email": "連結與電子郵件", - "link_copied": "連結已複製到剪貼簿!", "link_survey": "連結問卷", "link_surveys": "連結問卷", "load_more": "載入更多", @@ -246,8 +259,6 @@ "move_up": "上移", "multiple_languages": "多種語言", "name": "名稱", - "negative": "負面", - "neutral": "中性", "new": "新增", "new_survey": "新增問卷", "new_version_available": "Formbricks '{'version'}' 已推出。立即升級!", @@ -269,6 +280,8 @@ "on": "開啟", "only_one_file_allowed": "僅允許一個檔案", "only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。", + "option_id": "選項 ID", + "option_ids": "選項 IDs", "or": "或", "organization": "組織", "organization_id": "組織 ID", @@ -285,32 +298,34 @@ "phone": "電話", "photo_by": "照片來源:", "pick_a_date": "選擇日期", + "picture": "圖片", "placeholder": "提示文字", "please_select_at_least_one_survey": "請選擇至少一個問卷", "please_select_at_least_one_trigger": "請選擇至少一個觸發器", "please_upgrade_your_plan": "請升級您的方案。", - "positive": "正面", "preview": "預覽", "preview_survey": "預覽問卷", "privacy": "隱私權政策", - "privacy_policy": "隱私權政策", "product_manager": "產品經理", "profile": "個人資料", - "project": "專案", + "profile_id": "個人資料 ID", "project_configuration": "專案組態", + "project_creation_description": "組織調查 在 專案中以便更好地存取控制。", "project_id": "專案 ID", "project_name": "專案名稱", + "project_name_placeholder": "例如 Formbricks", "project_not_found": "找不到專案", "project_permission_not_found": "找不到專案權限", "projects": "專案", - "projects_limit_reached": "已達到專案上限", "question": "問題", "question_id": "問題 ID", "questions": "問題", "read_docs": "閱讀文件", + "recipients": "收件者", "remove": "移除", "reorder_and_hide_columns": "重新排序和隱藏欄位", "report_survey": "報告問卷", + "request_pricing": "請求定價", "request_trial_license": "請求試用授權", "reset_to_default": "重設為預設值", "response": "回應", @@ -330,6 +345,7 @@ "select": "選擇", "select_all": "全選", "select_survey": "選擇問卷", + "select_teams": "選擇 團隊", "selected": "已選取", "selected_questions": "選取的問題", "selection": "選取", @@ -346,6 +362,7 @@ "skipped": "已跳過", "skips": "跳過次數", "some_files_failed_to_upload": "部分檔案上傳失敗", + "something_went_wrong": "發生錯誤", "something_went_wrong_please_try_again": "發生錯誤。請再試一次。", "sort_by": "排序方式", "start_free_trial": "開始免費試用", @@ -372,6 +389,7 @@ "targeting": "目標設定", "team": "團隊", "team_access": "團隊存取權限", + "team_id": "團隊 ID", "team_name": "團隊名稱", "teams": "存取控制", "teams_not_found": "找不到團隊", @@ -404,9 +422,7 @@ "website_and_app_connection": "網站與應用程式連線", "website_app_survey": "網站與應用程式問卷", "website_survey": "網站問卷", - "weekly_summary": "每週摘要", "welcome_card": "歡迎卡片", - "yes": "是", "you": "您", "you_are_downgraded_to_the_community_edition": "您已降級至社群版。", "you_are_not_authorised_to_perform_this_action": "您未獲授權執行此操作。", @@ -446,41 +462,13 @@ "invite_email_text_par1": "您的同事", "invite_email_text_par2": "邀請您加入 Formbricks。若要接受邀請,請點擊以下連結:", "invite_member_email_subject": "您被邀請協作 Formbricks!", - "live_survey_notification_completed": "已完成", - "live_survey_notification_draft": "草稿", - "live_survey_notification_in_progress": "進行中", - "live_survey_notification_no_new_response": "本週沒有收到新的回應 \uD83D\uDD75️", - "live_survey_notification_no_responses_yet": "尚無回應!", - "live_survey_notification_paused": "已暫停", - "live_survey_notification_scheduled": "已排程", - "live_survey_notification_view_more_responses": "檢視另外 '{'responseCount'}' 個回應", - "live_survey_notification_view_previous_responses": "檢視先前的回應", - "live_survey_notification_view_response": "檢視回應", - "notification_footer_all_the_best": "祝您一切順利,", - "notification_footer_in_your_settings": "在您的設定中 \uD83D\uDE4F", - "notification_footer_please_turn_them_off": "請關閉它們", - "notification_footer_the_formbricks_team": "Formbricks 團隊 \uD83E\uDD0D", - "notification_footer_to_halt_weekly_updates": "若要停止每週更新,", - "notification_header_hey": "嗨 \uD83D\uDC4B", - "notification_header_weekly_report_for": "每週報告,適用於", - "notification_insight_completed": "已完成", - "notification_insight_completion_rate": "完成率 %", - "notification_insight_displays": "顯示次數", - "notification_insight_responses": "回應數", - "notification_insight_surveys": "問卷數", - "onboarding_invite_email_button_label": "加入 {inviterName} 的組織", - "onboarding_invite_email_connect_formbricks": "在幾分鐘內透過 HTML 片段或 NPM 將 Formbricks 連接到您的應用程式或網站。", - "onboarding_invite_email_create_account": "建立帳戶以加入 '{'inviterName'}' 的組織。", - "onboarding_invite_email_done": "完成 ✅", - "onboarding_invite_email_get_started_in_minutes": "在幾分鐘內開始使用", - "onboarding_invite_email_heading": "嗨 ", - "onboarding_invite_email_subject": "{inviterName} 需要幫忙設置 Formbricks。你能幫忙嗎?", + "new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:", "password_changed_email_heading": "密碼已變更", "password_changed_email_text": "您的密碼已成功變更。", "password_reset_notify_email_subject": "您的 Formbricks 密碼已變更", - "powered_by_formbricks": "由 Formbricks 提供技術支援", "privacy_policy": "隱私權政策", "reject": "拒絕", + "render_email_response_value_file_upload_response_link_not_included": "由於資料隱私原因,未包含上傳檔案的連結", "response_finished_email_subject": "{surveyName} 的回應已完成 ✅", "response_finished_email_subject_with_email": "{personEmail} 剛剛完成了您的 {surveyName} 調查 ✅", "schedule_your_meeting": "安排你的會議", @@ -505,14 +493,9 @@ "verification_email_thanks": "感謝您驗證您的電子郵件!", "verification_email_to_fill_survey": "若要填寫問卷,請點擊下方的按鈕:", "verification_email_verify_email": "驗證電子郵件", - "verified_link_survey_email_subject": "您的 survey 已準備好填寫。", - "weekly_summary_create_reminder_notification_body_cal_slot": "在我們 CEO 的日曆中選擇一個 15 分鐘的時段", - "weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "不要讓一週過去而沒有了解您的使用者:", - "weekly_summary_create_reminder_notification_body_need_help": "需要協助找到適合您產品的問卷嗎?", - "weekly_summary_create_reminder_notification_body_reply_email": "或回覆此電子郵件 :)", - "weekly_summary_create_reminder_notification_body_setup_a_new_survey": "設定新的問卷", - "weekly_summary_create_reminder_notification_body_text": "我們很樂意向您發送每週摘要,但目前 '{'projectName'}' 沒有正在執行的問卷。", - "weekly_summary_email_subject": "{projectName} 用戶洞察 - 上週 by Formbricks" + "verification_new_email_subject": "電子郵件更改驗證", + "verification_security_notice": "如果您沒有要求更改此電子郵件,請忽略此電子郵件或立即聯繫支援。", + "verified_link_survey_email_subject": "您的 survey 已準備好填寫。" }, "environments": { "actions": { @@ -525,21 +508,21 @@ "action_with_key_already_exists": "金鑰為 '{'key'}' 的操作已存在", "action_with_name_already_exists": "名稱為 '{'name'}' 的操作已存在", "add_css_class_or_id": "新增 CSS 類別或 ID", + "add_regular_expression_here": "新增正則表達式在此", "add_url": "新增網址", "click": "點擊", "contains": "包含", "create_action": "建立操作", "css_selector": "CSS 選取器", "delete_action_text": "您確定要刪除此操作嗎?這也會從您的所有問卷中移除此操作作為觸發器。", - "display_name": "顯示名稱", "does_not_contain": "不包含", "does_not_exactly_match": "不完全相符", "eg_clicked_download": "例如,點擊下載", "eg_download_cta_click_on_home": "例如,download_cta_click_on_home", "eg_install_app": "例如,安裝應用程式", - "eg_user_clicked_download_button": "例如,使用者點擊了下載按鈕", "ends_with": "結尾為", "enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "輸入網址以查看造訪該網址的使用者是否會被追蹤。", + "enter_url": "例如 https://app.com/dashboard", "exactly_matches": "完全相符", "exit_intent": "離開意圖", "fifty_percent_scroll": "50% 捲動", @@ -548,9 +531,14 @@ "if_a_user_clicks_a_button_with_a_specific_text": "如果使用者點擊具有特定文字的按鈕", "in_your_code_read_more_in_our": "在您的程式碼中。在我們的文件中閱讀更多內容", "inner_text": "內部文字", + "invalid_action_type_code": "對程式碼操作的操作類型無效", + "invalid_action_type_no_code": "使用無程式碼操作的操作類型無效", "invalid_css_selector": "無效的 CSS 選取器", + "invalid_match_type": "所選擇的選項不適用。", + "invalid_regex": "請使用有效的正規表示式。", "limit_the_pages_on_which_this_action_gets_captured": "限制擷取此操作的頁面", "limit_to_specific_pages": "限制為特定頁面", + "matches_regex": "符合 正則 表達式", "on_all_pages": "在所有頁面上", "page_filter": "頁面篩選器", "page_view": "頁面檢視", @@ -570,7 +558,9 @@ "user_clicked_download_button": "使用者點擊了下載按鈕", "what_did_your_user_do": "您的使用者做了什麼?", "what_is_the_user_doing": "使用者正在做什麼?", - "you_can_track_code_action_anywhere_in_your_app_using": "您可以使用以下方式在您的應用程式中的任何位置追蹤程式碼操作" + "you_can_track_code_action_anywhere_in_your_app_using": "您可以使用以下方式在您的應用程式中的任何位置追蹤程式碼操作", + "your_survey_would_be_shown_on_this_url": "您的問卷將顯示在此網址。", + "your_survey_would_not_be_shown": "您的問卷將不會顯示。" }, "connect": { "congrats": "恭喜!", @@ -587,8 +577,8 @@ "contact_deleted_successfully": "聯絡人已成功刪除", "contact_not_found": "找不到此聯絡人", "contacts_table_refresh": "重新整理聯絡人", - "contacts_table_refresh_error": "重新整理聯絡人時發生錯誤,請再試一次", "contacts_table_refresh_success": "聯絡人已成功重新整理", + "delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。", "first_name": "名字", "last_name": "姓氏", "no_responses_found": "找不到回應", @@ -616,33 +606,6 @@ "upload_contacts_modal_preview": "這是您的資料預覽。", "upload_contacts_modal_upload_btn": "上傳聯絡人" }, - "experience": { - "all": "全部", - "all_time": "全部時間", - "analysed_feedbacks": "已分析的自由文字答案", - "category": "類別", - "category_updated_successfully": "類別已成功更新!", - "complaint": "投訴", - "did_you_find_this_insight_helpful": "您覺得此洞察有幫助嗎?", - "failed_to_update_category": "更新類別失敗", - "feature_request": "請求", - "good_afternoon": "\uD83C\uDF24️ 午安", - "good_evening": "\uD83C\uDF19 晚安", - "good_morning": "☀️ 早安", - "insights_description": "從您所有問卷的回應中產生的所有洞察", - "insights_for_project": "'{'projectName'}' 的洞察", - "new_responses": "回應數", - "no_insights_for_this_filter": "此篩選器沒有洞察", - "no_insights_found": "找不到洞察。收集更多問卷回應或為您現有的問卷啟用洞察以開始使用。", - "praise": "讚美", - "sentiment_score": "情緒分數", - "templates_card_description": "選擇一個範本或從頭開始", - "templates_card_title": "衡量您的客戶體驗", - "this_month": "本月", - "this_quarter": "本季", - "this_week": "本週", - "today": "今天" - }, "formbricks_logo": "Formbricks 標誌", "integrations": { "activepieces_integration_description": "立即將 Formbricks 與熱門應用程式連接,以在無需編碼的情況下自動執行任務。", @@ -652,6 +615,7 @@ "airtable_integration": "Airtable 整合", "airtable_integration_description": "直接與 Airtable 同步回應。", "airtable_integration_is_not_configured": "尚未設定 Airtable 整合", + "airtable_logo": "Airtable 標誌", "connect_with_airtable": "連線 Airtable", "link_airtable_table": "連結 Airtable 表格", "link_new_table": "連結新表格", @@ -719,7 +683,6 @@ "select_a_database": "選取資料庫", "select_a_field_to_map": "選取要對應的欄位", "select_a_survey_question": "選取問卷問題", - "sync_responses_with_a_notion_database": "與 Notion 資料庫同步回應", "update_connection": "重新連線 Notion", "update_connection_tooltip": "重新連接整合以包含新添加的資料庫。您現有的整合將保持不變。" }, @@ -741,6 +704,7 @@ "slack_integration": "Slack 整合", "slack_integration_description": "直接將回應傳送至 Slack。", "slack_integration_is_not_configured": "您的 Formbricks 執行個體中尚未設定 Slack 整合。", + "slack_logo": "Slack 標誌", "slack_reconnect_button": "重新連線", "slack_reconnect_button_description": "注意:我們最近變更了我們的 Slack 整合以支援私人頻道。請重新連線您的 Slack 工作區。" }, @@ -777,6 +741,7 @@ }, "project": { "api_keys": { + "access_control": "存取控制", "add_api_key": "新增 API 金鑰", "api_key": "API 金鑰", "api_key_copied_to_clipboard": "API 金鑰已複製到剪貼簿", @@ -784,9 +749,12 @@ "api_key_deleted": "API 金鑰已刪除", "api_key_label": "API 金鑰標籤", "api_key_security_warning": "為安全起見,API 金鑰僅在建立後顯示一次。請立即將其複製到您的目的地。", + "api_key_updated": "API 金鑰已更新", "duplicate_access": "不允許重複的 project 存取", "no_api_keys_yet": "您還沒有任何 API 金鑰", + "no_env_permissions_found": "找不到環境權限", "organization_access": "組織 Access", + "organization_access_description": "選擇組織範圍資源的讀取或寫入權限。", "permissions": "權限", "project_access": "專案存取", "secret": "密碼", @@ -796,6 +764,8 @@ "api_host_description": "這是您 Formbricks 後端的網址。", "app_connection": "應用程式連線", "app_connection_description": "將您的應用程式連線至 Formbricks。", + "cache_update_delay_description": "當您對調查、聯絡人、操作或其他資料進行更新時,可能需要長達 5 分鐘這些變更才能顯示在執行 Formbricks SDK 的本地應用程式中。此延遲是因我們目前快取系統的限制。我們正積極重新設計快取,並將在 Formbricks 4.0 中發佈修補程式。", + "cache_update_delay_title": "更改將於 5 分鐘後因快取而反映", "check_out_the_docs": "查看文件。", "dive_into_the_docs": "深入瞭解文件。", "does_your_widget_work": "您的小工具運作嗎?", @@ -921,8 +891,7 @@ "tag_already_exists": "標籤已存在", "tag_deleted": "標籤已刪除", "tag_updated": "標籤已更新", - "tags_merged": "標籤已合併", - "unique_constraint_failed_on_the_fields": "欄位上唯一性限制失敗" + "tags_merged": "標籤已合併" }, "teams": { "manage_teams": "管理團隊", @@ -970,6 +939,7 @@ "save_your_filters_as_a_segment_to_use_it_in_other_surveys": "將您的篩選器儲存為區隔,以便在其他問卷中使用", "segment_created_successfully": "區隔已成功建立!", "segment_deleted_successfully": "區隔已成功刪除!", + "segment_id": "區隔 ID", "segment_saved_successfully": "區隔已成功儲存", "segment_updated_successfully": "區隔已成功更新!", "segments_help_you_target_users_with_same_characteristics_easily": "區隔可協助您輕鬆針對具有相同特徵的使用者", @@ -991,67 +961,56 @@ "api_keys": { "add_api_key": "新增 API 金鑰", "add_permission": "新增權限", - "api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API", - "only_organization_owners_and_managers_can_manage_api_keys": "只有組織擁有者和管理員才能管理 API 金鑰" + "api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API" }, "billing": { - "10000_monthly_responses": "10000 個每月回應", - "1500_monthly_responses": "1500 個每月回應", - "2000_monthly_identified_users": "2000 個每月識別使用者", - "30000_monthly_identified_users": "30000 個每月識別使用者", + "1000_monthly_responses": "1000 個每月回應", + "1_project": "1 個專案", + "2000_contacts": "2000 個聯絡人", "3_projects": "3 個專案", "5000_monthly_responses": "5000 個每月回應", - "5_projects": "5 個專案", - "7500_monthly_identified_users": "7500 個每月識別使用者", - "advanced_targeting": "進階目標設定", + "7500_contacts": "7500 個聯絡人", "all_integrations": "所有整合", - "all_surveying_features": "所有調查功能", "annually": "每年", "api_webhooks": "API 和 Webhook", "app_surveys": "應用程式問卷", - "contact_us": "聯絡我們", + "attribute_based_targeting": "基於屬性的定位", "current": "目前", "current_plan": "目前方案", "current_tier_limit": "目前層級限制", - "custom_miu_limit": "自訂 MIU 上限", + "custom": "自訂 & 規模", + "custom_contacts_limit": "自訂聯絡人上限", "custom_project_limit": "自訂專案上限", - "customer_success_manager": "客戶成功經理", + "custom_response_limit": "自訂回應上限", "email_embedded_surveys": "電子郵件嵌入式問卷", - "email_support": "電子郵件支援", - "enterprise": "企業版", + "email_follow_ups": "電子郵件後續追蹤", "enterprise_description": "頂級支援和自訂限制。", "everybody_has_the_free_plan_by_default": "每個人預設都有免費方案!", "everything_in_free": "免費方案中的所有功能", - "everything_in_scale": "進階方案中的所有功能", "everything_in_startup": "啟動方案中的所有功能", "free": "免費", "free_description": "無限問卷、團隊成員等。", "get_2_months_free": "免費獲得 2 個月", "get_in_touch": "取得聯繫", + "hosted_in_frankfurt": "託管在 Frankfurt", + "ios_android_sdks": "iOS 和 Android SDK 用於行動問卷", "link_surveys": "連結問卷(可分享)", "logic_jumps_hidden_fields_recurring_surveys": "邏輯跳躍、隱藏欄位、定期問卷等。", "manage_card_details": "管理卡片詳細資料", "manage_subscription": "管理訂閱", "monthly": "每月", "monthly_identified_users": "每月識別使用者", - "multi_language_surveys": "多語言問卷", "per_month": "每月", "per_year": "每年", "plan_upgraded_successfully": "方案已成功升級", "premium_support_with_slas": "具有 SLA 的頂級支援", - "priority_support": "優先支援", "remove_branding": "移除品牌", - "say_hi": "打個招呼!", - "scale": "進階版", - "scale_description": "用於擴展業務的進階功能。", "startup": "啟動版", "startup_description": "免費方案中的所有功能以及其他功能。", "switch_plan": "切換方案", "switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。", "team_access_roles": "團隊存取角色", - "technical_onboarding": "技術新手上路", "unable_to_upgrade_plan": "無法升級方案", - "unlimited_apps_websites": "無限應用程式和網站", "unlimited_miu": "無限 MIU", "unlimited_projects": "無限專案", "unlimited_responses": "無限回應", @@ -1062,7 +1021,6 @@ "website_surveys": "網站問卷" }, "enterprise": { - "ai": "AI 分析", "audit_logs": "稽核記錄", "coming_soon": "即將推出", "contacts_and_segments": "聯絡人管理和區隔", @@ -1091,6 +1049,7 @@ "create_new_organization": "建立新組織", "create_new_organization_description": "建立新組織以處理一組不同的專案。", "customize_email_with_a_higher_plan": "使用更高等級的方案自訂電子郵件", + "delete_member_confirmation": "刪除的成員將失去存取您組織的所有專案和問卷的權限。", "delete_organization": "刪除組織", "delete_organization_description": "刪除包含所有專案的組織,包括所有問卷、回應、人員、操作和屬性", "delete_organization_warning": "在您繼續刪除此組織之前,請注意以下後果:", @@ -1100,13 +1059,7 @@ "eliminate_branding_with_whitelabel": "消除 Formbricks 品牌並啟用其他白標自訂選項。", "email_customization_preview_email_heading": "嗨,'{'userName'}'", "email_customization_preview_email_text": "這是電子郵件預覽,向您展示電子郵件中將呈現哪個標誌。", - "enable_formbricks_ai": "啟用 Formbricks AI", "error_deleting_organization_please_try_again": "刪除組織時發生錯誤。請再試一次。", - "formbricks_ai": "Formbricks AI", - "formbricks_ai_description": "使用 Formbricks AI 從您的問卷回應中取得個人化洞察", - "formbricks_ai_disable_success_message": "已成功停用 Formbricks AI。", - "formbricks_ai_enable_success_message": "已成功啟用 Formbricks AI。", - "formbricks_ai_privacy_policy_text": "藉由啟用 Formbricks AI,您同意更新後的", "from_your_organization": "來自您的組織", "invitation_sent_once_more": "已再次發送邀請。", "invite_deleted_successfully": "邀請已成功刪除", @@ -1153,10 +1106,8 @@ "need_slack_or_discord_notifications": "需要 Slack 或 Discord 通知嗎?", "notification_settings_updated": "通知設定已更新", "set_up_an_alert_to_get_an_email_on_new_responses": "設定警示以在收到新回應時收到電子郵件", - "stay_up_to_date_with_a_Weekly_every_Monday": "每週一使用每週摘要保持最新資訊", "use_the_integration": "使用整合", "want_to_loop_in_organization_mates": "想要讓組織夥伴也參與嗎?", - "weekly_summary_projects": "每週摘要(專案)", "you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "您將不會再自動訂閱此組織的問卷!", "you_will_not_receive_any_more_emails_for_responses_on_this_survey": "您將不會再收到此問卷回應的電子郵件!" }, @@ -1172,6 +1123,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。", @@ -1252,8 +1204,9 @@ "copy_survey_description": "將此問卷複製到另一個環境", "copy_survey_error": "無法複製問卷", "copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿", + "copy_survey_partially_success": "{success} 個問卷已成功複製,{error} 個失敗。", "copy_survey_success": "問卷已成功複製!", - "delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?此操作無法復原。", + "delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?", "edit": { "1_choose_the_default_language_for_this_survey": "1. 選擇此問卷的預設語言:", "2_activate_translation_for_specific_languages": "2. 啟用特定語言的翻譯:", @@ -1273,6 +1226,8 @@ "add_description": "新增描述", "add_ending": "新增結尾", "add_ending_below": "在下方新增結尾", + "add_fallback": "新增", + "add_fallback_placeholder": "新增用于顯示問題被跳過時的佔位符", "add_hidden_field_id": "新增隱藏欄位 ID", "add_highlight_border": "新增醒目提示邊框", "add_highlight_border_description": "在您的問卷卡片新增外邊框。", @@ -1311,8 +1266,6 @@ "automatically_release_the_survey_at_the_beginning_of_the_day_utc": "在指定日期(UTC時間)自動發佈問卷。", "back_button_label": "「返回」按鈕標籤", "background_styling": "背景樣式設定", - "blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "如果已存在具有單次使用 ID (suId) 的提交,則封鎖問卷。", - "blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "如果問卷網址沒有單次使用 ID (suId),則封鎖問卷。", "brand_color": "品牌顏色", "brightness": "亮度", "button_label": "按鈕標籤", @@ -1326,14 +1279,21 @@ "card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列", "card_background_color": "卡片背景顏色", "card_border_color": "卡片邊框顏色", - "card_shadow_color": "卡片陰影顏色", "card_styling": "卡片樣式設定", "casual": "隨意", + "caution_edit_duplicate": "複製 & 編輯", + "caution_edit_published_survey": "編輯已發佈的調查?", + "caution_explanation_intro": "我們了解您可能仍然想要進行更改。如果您這樣做,將會發生以下情況:", + "caution_explanation_new_responses_separated": "更改前的回應可能未被納入或只有部分包含在調查摘要中。", + "caution_explanation_only_new_responses_in_summary": "所有數據,包括過去的回應,仍可在調查摘要頁面下載。", + "caution_explanation_responses_are_safe": "較舊和較新的回應會混在一起,可能導致數據摘要失準。", + "caution_recommendation": "這可能導致調查摘要中的數據不一致。我們建議複製這個調查。", "caution_text": "變更會導致不一致", "centered_modal_overlay_color": "置中彈窗覆蓋顏色", "change_anyway": "仍然變更", "change_background": "變更背景", "change_question_type": "變更問題類型", + "change_survey_type": "切換問卷類型會影響現有訪問", "change_the_background_color_of_the_card": "變更卡片的背景顏色。", "change_the_background_color_of_the_input_fields": "變更輸入欄位的背景顏色。", "change_the_background_to_a_color_image_or_animation": "將背景變更為顏色、圖片或動畫。", @@ -1343,8 +1303,8 @@ "change_the_brand_color_of_the_survey": "變更問卷的品牌顏色。", "change_the_placement_of_this_survey": "變更此問卷的位置。", "change_the_question_color_of_the_survey": "變更問卷的問題顏色。", - "change_the_shadow_color_of_the_card": "變更卡片的陰影顏色。", "changes_saved": "已儲存變更。", + "changing_survey_type_will_remove_existing_distribution_channels": "更改問卷類型會影響其共享方式。如果受訪者已擁有當前類型的存取連結,則在切換後可能會失去存取權限。", "character_limit_toggle_description": "限制答案的長度或短度。", "character_limit_toggle_title": "新增字元限制", "checkbox_label": "核取方塊標籤", @@ -1354,10 +1314,11 @@ "close_survey_on_date": "在指定日期關閉問卷", "close_survey_on_response_limit": "在回應次數上限關閉問卷", "color": "顏色", + "column_used_in_logic_error": "此 column 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。", "columns": "欄位", "company": "公司", "company_logo": "公司標誌", - "completed_responses": "完成的回應。", + "completed_responses": "部分或完整答复。", "concat": "串連 +", "conditional_logic": "條件邏輯", "confirm_default_language": "確認預設語言", @@ -1391,8 +1352,9 @@ "does_not_start_with": "不以...開頭", "edit_recall": "編輯回憶", "edit_translations": "編輯 '{'language'}' 翻譯", - "enable_encryption_of_single_use_id_suid_in_survey_url": "啟用問卷網址中單次使用 ID (suId) 的加密。", "enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。", + "enable_recaptcha_to_protect_your_survey_from_spam": "垃圾郵件保護使用 reCAPTCHA v3 過濾垃圾回應。", + "enable_spam_protection": "垃圾郵件保護", "end_screen_card": "結束畫面卡片", "ending_card": "結尾卡片", "ending_card_used_in_logic": "此結尾卡片用於問題 '{'questionIndex'}' 的邏輯中。", @@ -1403,6 +1365,7 @@ "error_saving_changes": "儲存變更時發生錯誤", "even_after_they_submitted_a_response_e_g_feedback_box": "即使他們提交回應之後(例如,意見反應方塊)", "everyone": "所有人", + "fallback_for": "備用 用於 ", "fallback_missing": "遺失的回退", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。", "field_name_eg_score_price": "欄位名稱,例如:分數、價格", @@ -1420,6 +1383,8 @@ "follow_ups_item_issue_detected_tag": "偵測到問題", "follow_ups_item_response_tag": "任何回應", "follow_ups_item_send_email_tag": "發送電子郵件", + "follow_ups_modal_action_attach_response_data_description": "將調查回應的數據添加到後續", + "follow_ups_modal_action_attach_response_data_label": "附加 response data", "follow_ups_modal_action_body_label": "內文", "follow_ups_modal_action_body_placeholder": "電子郵件內文", "follow_ups_modal_action_email_content": "電子郵件內容", @@ -1450,9 +1415,6 @@ "follow_ups_new": "新增後續追蹤", "follow_ups_upgrade_button_text": "升級以啟用後續追蹤", "form_styling": "表單樣式設定", - "formbricks_ai_description": "描述您的問卷並讓 Formbricks AI 為您建立問卷", - "formbricks_ai_generate": "產生", - "formbricks_ai_prompt_placeholder": "輸入問卷資訊(例如,要涵蓋的關鍵主題)", "formbricks_sdk_is_not_connected": "Formbricks SDK 未連線", "four_points": "4 分", "heading": "標題", @@ -1465,7 +1427,6 @@ "hide_the_logo_in_this_specific_survey": "在此特定問卷中隱藏標誌", "hostname": "主機名稱", "how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "您希望 '{'surveyTypeDerived'}' 問卷中的卡片有多酷炫", - "how_it_works": "運作方式", "if_you_need_more_please": "如果您需要更多,請", "if_you_really_want_that_answer_ask_until_you_get_it": "如果您真的想要該答案,請詢問直到您獲得它。", "ignore_waiting_time_between_surveys": "忽略問卷之間的等待時間", @@ -1481,10 +1442,13 @@ "invalid_youtube_url": "無效的 YouTube 網址", "is_accepted": "已接受", "is_after": "在之後", + "is_any_of": "是任何一個", "is_before": "在之前", "is_booked": "已預訂", "is_clicked": "已點擊", "is_completely_submitted": "已完全提交", + "is_empty": "是空的", + "is_not_empty": "不是空的", "is_not_set": "未設定", "is_partially_submitted": "已部分提交", "is_set": "已設定", @@ -1500,7 +1464,6 @@ "limit_the_maximum_file_size": "限制最大檔案大小", "limit_upload_file_size_to": "限制上傳檔案大小為", "link_survey_description": "分享問卷頁面的連結或將其嵌入網頁或電子郵件中。", - "link_used_message": "已使用連結", "load_segment": "載入區隔", "logic_error_warning": "變更將導致邏輯錯誤", "logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件", @@ -1516,6 +1479,7 @@ "no_hidden_fields_yet_add_first_one_below": "尚無隱藏欄位。在下方新增第一個隱藏欄位。", "no_images_found_for": "找不到「'{'query'}'」的圖片", "no_languages_found_add_first_one_to_get_started": "找不到語言。新增第一個語言以開始使用。", + "no_option_found": "找不到選項", "no_variables_yet_add_first_one_below": "尚無變數。在下方新增第一個變數。", "number": "數字", "once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "設定後,此問卷的預設語言只能藉由停用多語言選項並刪除所有翻譯來變更。", @@ -1567,6 +1531,7 @@ "response_limits_redirections_and_more": "回應限制、重新導向等。", "response_options": "回應選項", "roundness": "圓角", + "row_used_in_logic_error": "此 row 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。", "rows": "列", "save_and_close": "儲存並關閉", "scale": "比例", @@ -1590,10 +1555,12 @@ "show_survey_to_users": "將問卷顯示給 % 的使用者", "show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者", "simple": "簡單", - "single_use_survey_links": "單次使用問卷連結", - "single_use_survey_links_description": "每個問卷連結只允許 1 個回應。", + "six_points": "6 分", "skip_button_label": "「跳過」按鈕標籤", "smiley": "表情符號", + "spam_protection_note": "垃圾郵件保護不適用於使用 iOS、React Native 和 Android SDK 顯示的問卷。它會破壞問卷。", + "spam_protection_threshold_description": "設置值在 0 和 1 之間,低於此值的回應將被拒絕。", + "spam_protection_threshold_heading": "回應閾值", "star": "星形", "starts_with": "開頭為", "state": "州/省", @@ -1604,8 +1571,6 @@ "subheading": "副標題", "subtract": "減 -", "suggest_colors": "建議顏色", - "survey_already_answered_heading": "問卷已回答。", - "survey_already_answered_subheading": "您只能使用此連結一次。", "survey_completed_heading": "問卷已完成", "survey_completed_subheading": "此免費且開源的問卷已關閉", "survey_display_settings": "問卷顯示設定", @@ -1636,7 +1601,6 @@ "upload": "上傳", "upload_at_least_2_images": "上傳至少 2 張圖片", "upper_label": "上標籤", - "url_encryption": "網址加密", "url_filters": "網址篩選器", "url_not_supported": "不支援網址", "use_with_caution": "謹慎使用", @@ -1649,7 +1613,6 @@ "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "在觸發後等待幾秒鐘再顯示問卷", "waiting_period": "等待時間", "welcome_message": "歡迎訊息", - "when": "何時", "when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "當條件符合時,等待時間將被忽略且顯示問卷。", "without_a_filter_all_of_your_users_can_be_surveyed": "如果沒有篩選器,則可以調查您的所有使用者。", "you_have_not_created_a_segment_yet": "您尚未建立區隔", @@ -1660,9 +1623,11 @@ "zip": "郵遞區號" }, "error_deleting_survey": "刪除問卷時發生錯誤", - "failed_to_copy_link_to_results": "無法複製結果連結", - "failed_to_copy_url": "無法複製網址:不在瀏覽器環境中。", - "new_single_use_link_generated": "已產生新的單次使用連結", + "filter": { + "complete_and_partial_responses": "完整 和 部分 回應", + "complete_responses": "完整回應", + "partial_responses": "部分回應" + }, "new_survey": "新增問卷", "no_surveys_created_yet": "尚未建立任何問卷", "open_options": "開啟選項", @@ -1681,9 +1646,11 @@ "company": "公司", "completed": "已完成 ✅", "country": "國家/地區", + "delete_response_confirmation": "這將刪除調查響應,包括所有回答、標籤、附件文件以及響應元數據。", "device": "裝置", "device_info": "裝置資訊", "email": "電子郵件", + "error_downloading_responses": "下載回應時發生錯誤", "first_name": "名字", "how_to_identify_users": "如何識別使用者", "last_name": "姓氏", @@ -1702,8 +1669,91 @@ "this_response_is_in_progress": "此回應正在進行中。", "zip_post_code": "郵遞區號" }, - "results_unpublished_successfully": "結果已成功取消發布。", "search_by_survey_name": "依問卷名稱搜尋", + "share": { + "anonymous_links": { + "custom_single_use_id_description": "如果您不加密 使用一次 的 ID,任何“ suid=...”的值都能用于 一次回應", + "custom_single_use_id_title": "您可以在 URL 中設置任何值 作為 一次性使用 ID", + "custom_start_point": "自訂 開始 點", + "data_prefilling": "資料預先填寫", + "description": "從 這些 連結 獲得 的 回應 將是 匿名 的", + "disable_multi_use_link_modal_button": "禁用 多 重 使用 連結", + "disable_multi_use_link_modal_description": "停用多次使用連結將阻止任何人通過該連結提交回應。", + "disable_multi_use_link_modal_description_subtext": "這也會破壞在 網頁 、 電子郵件 、社交媒體 和 QR碼上使用此多次使用連結的任何 活動 嵌入 。", + "disable_multi_use_link_modal_title": "您確定嗎?這可能會破壞 活動 嵌入 ", + "disable_single_use_link_modal_button": "停用 單次使用連結", + "disable_single_use_link_modal_description": "如果您共享了單次使用連結,參與者將不再能夠回應此問卷。", + "generate_and_download_links": "生成 & 下載 連結", + "generate_links_error": "無法生成單次使用連結。請直接使用 API", + "multi_use_link": "多 重 使用 連結", + "multi_use_link_description": "收集 多位 匿名 受訪者 的 多次 回應 , 使用 一個 連結", + "multi_use_powers_other_channels_description": "如果您停用它,這些其他分發管道也會被停用", + "multi_use_powers_other_channels_title": "這個 連結 支援 網站 嵌入 、 電子郵件 嵌入 、 社交 媒體 分享 和 QR 碼", + "nav_title": "匿名 連結", + "number_of_links_label": "連結數量 (1 - 5,000)", + "single_use_link": "單次使用連結", + "single_use_link_description": "只允許 1 個回應每個問卷連結。", + "single_use_links": "單次使用連結", + "source_tracking": "來源追蹤", + "url_encryption_description": "僅在需要設定自訂一次性 ID 時停用", + "url_encryption_label": "單次使用 ID 的 URL 加密" + }, + "dynamic_popup": { + "alert_button": "編輯 問卷", + "alert_description": "此 問卷 目前 被 設定 為 連結 問卷,不 支援 動態 彈出窗口。您 可 在 問卷 編輯器 的 設定 標籤 中 進行 更改。", + "alert_title": "更改問卷類型為 in-app", + "attribute_based_targeting": "屬性 基於 的 定位", + "code_no_code_triggers": "程式碼 及 無程式碼 觸發器", + "description": "Formbricks 調查 可以 嵌入 為 彈出 式 樣 式 , 根據 使用者 互動 。", + "nav_title": "動態(彈窗)", + "recontact_options": "重新聯絡選項" + }, + "embed_on_website": { + "description": "Formbricks 調查可以 作為 靜態 元素 嵌入。", + "embed_code_copied_to_clipboard": "嵌入程式碼已複製到剪貼簿!", + "embed_mode": "嵌入模式", + "embed_mode_description": "以簡約設計嵌入您的問卷,捨棄邊距和背景。", + "nav_title": "嵌入網站" + }, + "personal_links": { + "create_and_manage_segments": "在 聯絡人 > 分段 中建立和管理您的分段", + "description": "為 一個 群組 生成 個人 連結,並 將 調查 回應 對應 到 每個 聯絡人。", + "expiry_date_description": "一旦連結過期,收件者將無法再回應 survey。", + "expiry_date_optional": "到期日 (可選)", + "generate_and_download_links": "生成 & 下載 連結", + "generating_links": "生成 連結", + "generating_links_toast": "生成 連結,下載 將 會 很快 開始…", + "links_generated_success_toast": "連結 成功 生成,您的 下載 將 會 很快 開始。", + "nav_title": "個人 連結", + "no_segments_available": "沒有可用的區段", + "select_segment": "選擇 區隔", + "upgrade_prompt_description": "為一個群組生成個人連結,並將調查回應連結到每個聯絡人。", + "upgrade_prompt_title": "使用 個人 連結 與 更高 的 計劃", + "work_with_segments": "個人 連結 可 與 分段 一起 使用" + }, + "send_email": { + "copy_embed_code": "複製嵌入程式碼", + "description": "將 你的 調查 嵌入 在 電子郵件 中 以 獲得 觀眾 的 回應。", + "email_preview_tab": "電子郵件預覽", + "email_sent": "已發送電子郵件!", + "email_subject_label": "主旨", + "email_to_label": "收件者", + "embed_code_copied_to_clipboard": "嵌入程式碼已複製到剪貼簿!", + "embed_code_copied_to_clipboard_failed": "複製失敗,請再試一次", + "embed_code_tab": "嵌入程式碼", + "formbricks_email_survey_preview": "Formbricks 電子郵件問卷預覽", + "nav_title": "電子郵件嵌入", + "send_preview": "發送預覽", + "send_preview_email": "發送預覽電子郵件" + }, + "share_view_title": "透過 分享", + "social_media": { + "description": "從 您 的 聯絡人 在 各 種 社交 媒體 網絡 上 獲得 回應。", + "source_tracking_enabled": "來源追蹤已啟用", + "source_tracking_enabled_alert_description": "從 此 對 話 框 共 享 時,社 交 媒 體 網 絡 會 被 附 加 到 調 查鏈 接 下,讓 您 知 道 各 網 絡 的 回 應 來 源。", + "title": "社群媒體" + } + }, "summary": { "added_filter_for_responses_where_answer_to_question": "已新增回應的篩選器,其中問題 '{'questionIdx'}' 的答案為 '{'filterComboBoxValue'}' - '{'filterValue'}'", "added_filter_for_responses_where_answer_to_question_is_skipped": "已新增回應的篩選器,其中問題 '{'questionIdx'}' 的答案被跳過", @@ -1717,52 +1767,50 @@ "configure_alerts": "設定警示", "congrats": "恭喜!您的問卷已上線。", "connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。", - "copy_link_to_public_results": "複製公開結果的連結", - "create_single_use_links": "建立單次使用連結", - "create_single_use_links_description": "每個連結只接受一次提交。以下是如何操作。", - "current_selection_csv": "目前選取 (CSV)", - "current_selection_excel": "目前選取 (Excel)", "custom_range": "自訂範圍...", - "data_prefilling": "資料預先填寫", - "data_prefilling_description": "您想要預先填寫問卷中的某些欄位嗎?以下是如何操作。", - "define_when_and_where_the_survey_should_pop_up": "定義問卷應該在哪裡和何時彈出", + "delete_all_existing_responses_and_displays": "刪除 所有 現有 回應 和 顯示", + "download_qr_code": "下載 QR code", "drop_offs": "放棄", "drop_offs_tooltip": "問卷已開始但未完成的次數。", - "dynamic_popup": "動態(彈窗)", - "email_sent": "已發送電子郵件!", - "embed_code_copied_to_clipboard": "嵌入程式碼已複製到剪貼簿!", - "embed_in_an_email": "嵌入電子郵件中", - "embed_in_app": "嵌入應用程式", - "embed_mode": "嵌入模式", - "embed_mode_description": "以簡約設計嵌入您的問卷,捨棄邊距和背景。", - "embed_on_website": "嵌入網站", - "embed_pop_up_survey_title": "如何在您的網站上嵌入彈出式問卷", - "embed_survey": "嵌入問卷", - "enable_ai_insights_banner_button": "啟用洞察", - "enable_ai_insights_banner_description": "您可以為問卷啟用新的洞察功能,以取得針對您開放文字回應的 AI 洞察。", - "enable_ai_insights_banner_success": "正在為此問卷產生洞察。請稍後再查看。", - "enable_ai_insights_banner_title": "準備好測試 AI 洞察了嗎?", - "enable_ai_insights_banner_tooltip": "請透過 hola@formbricks.com 與我們聯絡,以產生此問卷的洞察", "failed_to_copy_link": "無法複製連結", "filter_added_successfully": "篩選器已成功新增", "filter_updated_successfully": "篩選器已成功更新", - "formbricks_email_survey_preview": "Formbricks 電子郵件問卷預覽", + "filtered_responses_csv": "篩選回應 (CSV)", + "filtered_responses_excel": "篩選回應 (Excel)", "go_to_setup_checklist": "前往設定檢查清單 \uD83D\uDC49", - "hide_embed_code": "隱藏嵌入程式碼", - "how_to_create_a_panel": "如何建立小組", - "how_to_create_a_panel_step_1": "步驟 1:使用 Prolific 建立帳戶", - "how_to_create_a_panel_step_1_description": "使用 Prolific 建立帳戶並驗證您的電子郵件地址。", - "how_to_create_a_panel_step_2": "步驟 2:建立研究", - "how_to_create_a_panel_step_2_description": "在 Prolific 中,您建立一個新的研究,您可以在其中根據數百個特徵選擇您偏好的受眾。", - "how_to_create_a_panel_step_3": "步驟 3:連線您的問卷", - "how_to_create_a_panel_step_3_description": "在您的 Formbricks 問卷中設定隱藏欄位,以追蹤哪個參與者提供了哪個答案。", - "how_to_create_a_panel_step_4": "步驟 4:啟動您的研究", - "how_to_create_a_panel_step_4_description": "設定完成後,您可以啟動您的研究。在幾個小時內,您就會收到第一個回應。", "impressions": "曝光數", "impressions_tooltip": "問卷已檢視的次數。", + "in_app": { + "connection_description": "調查將顯示給符合以下列出條件的網站用戶", + "connection_title": "Formbricks SDK 已連線", + "description": "Formbricks 調查 可以 嵌入 為 彈出 式 樣 式 , 根據 使用者 互動 。", + "display_criteria": "顯示 的 標準", + "display_criteria.audience_description": "目標受眾", + "display_criteria.code_trigger": "程式 行動", + "display_criteria.everyone": "所有人", + "display_criteria.no_code_trigger": "無程式碼", + "display_criteria.overwritten": "被覆寫", + "display_criteria.randomizer": "{percentage} % 隨機器", + "display_criteria.randomizer_description": "只有 {percentage}% 的人執行該動作後可能會被調查。", + "display_criteria.recontact_description": "重新聯絡選項", + "display_criteria.targeted": "目標", + "display_criteria.time_based_always": "始終顯示問卷", + "display_criteria.time_based_day": "天數", + "display_criteria.time_based_days": "天數", + "display_criteria.time_based_description": "全球等待時間", + "display_criteria.trigger_description": "問卷 觸發器", + "documentation_title": "在 所有 平台 上 發布 截取 調查", + "html_embed": " 中的 HTML 嵌入", + "ios_sdk": "適用於 Apple 應用程式的 iOS SDK", + "javascript_sdk": "JavaScript SDK", + "kotlin_sdk": "適用於 Android 應用程式 的 Kotlin SDK", + "no_connection_description": "將您的網站或應用程式與 Formbricks 連線以發布擷取調查。", + "no_connection_title": "您尚未插入任何內容!", + "react_native_sdk": "適用於 RN 應用程式的 React Native SDK", + "title": "攔截 調查 設置" + }, "includes_all": "包含全部", "includes_either": "包含其中一個", - "insights_disabled": "洞察已停用", "install_widget": "安裝 Formbricks 小工具", "is_equal_to": "等於", "is_less_than": "小於", @@ -1772,60 +1820,34 @@ "last_month": "上個月", "last_quarter": "上一季", "last_year": "去年", - "link_to_public_results_copied": "已複製公開結果的連結", - "make_sure_the_survey_type_is_set_to": "請確保問卷類型設定為", - "mobile_app": "行動應用程式", - "no_response_matches_filter": "沒有任何回應符合您的篩選器", - "only_completed": "僅已完成", + "no_responses_found": "找不到回應", "other_values_found": "找到其他值", "overall": "整體", - "publish_to_web": "發布至網站", - "publish_to_web_warning": "您即將將這些問卷結果發布到公共領域。", - "publish_to_web_warning_description": "您的問卷結果將會是公開的。任何組織外的人員都可以存取這些結果(如果他們有連結)。", - "quickstart_mobile_apps": "快速入門:Mobile apps", - "quickstart_mobile_apps_description": "要開始使用行動應用程式中的調查,請按照 Quickstart 指南:", - "quickstart_web_apps": "快速入門:Web apps", - "quickstart_web_apps_description": "請按照 Quickstart 指南開始:", - "results_are_public": "結果是公開的", - "send_preview": "發送預覽", - "send_to_panel": "發送到小組", - "setup_instructions": "設定說明", + "qr_code": "QR 碼", + "qr_code_description": "透過 QR code 收集的回應都是匿名的。", + "qr_code_download_failed": "QR code 下載失敗", + "qr_code_download_with_start_soon": "QR code 下載即將開始", + "qr_code_generation_failed": "載入調查 QR Code 時發生問題。請再試一次。", + "reset_survey": "重設問卷", + "reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。", + "selected_responses_csv": "選擇的回應 (CSV)", + "selected_responses_excel": "選擇的回應 (Excel)", "setup_integrations": "設定整合", - "share_results": "分享結果", - "share_the_link": "分享連結", - "share_the_link_to_get_responses": "分享連結以取得回應", + "share_survey": "分享問卷", "show_all_responses_that_match": "顯示所有相符的回應", "show_all_responses_where": "顯示所有回應,其中...", - "single_use_links": "單次使用連結", - "source_tracking": "來源追蹤", - "source_tracking_description": "執行符合 GDPR 和 CCPA 的來源追蹤,無需額外工具。", "starts": "開始次數", "starts_tooltip": "問卷已開始的次數。", - "static_iframe": "靜態 (iframe)", - "survey_results_are_public": "您的問卷結果是公開的!", - "survey_results_are_shared_with_anyone_who_has_the_link": "您的問卷結果與任何擁有連結的人員分享。這些結果將不會被搜尋引擎編入索引。", + "survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。", "this_month": "本月", "this_quarter": "本季", "this_year": "今年", "time_to_complete": "完成時間", - "to_connect_your_website_with_formbricks": "以將您的網站與 Formbricks 連線", "ttc_tooltip": "完成問卷的平均時間。", "unknown_question_type": "未知的問題類型", - "unpublish_from_web": "從網站取消發布", - "unsupported_video_tag_warning": "您的瀏覽器不支援 video 標籤。", - "view_embed_code": "檢視嵌入程式碼", - "view_embed_code_for_email": "檢視電子郵件的嵌入程式碼", - "view_site": "檢視網站", + "use_personal_links": "使用 個人 連結", "waiting_for_response": "正在等待回應 \uD83E\uDDD8‍♂️", - "web_app": "Web 應用程式", - "what_is_a_panel": "什麼是小組?", - "what_is_a_panel_answer": "小組是一組根據年齡、職業、性別等特徵選取的參與者。", - "what_is_prolific": "什麼是 Prolific?", - "what_is_prolific_answer": "我們正在與 Prolific 合作,為您提供超過 200,000 名經過審核的參與者。", "whats_next": "下一步是什麼?", - "when_do_i_need_it": "我何時需要它?", - "when_do_i_need_it_answer": "如果您無法存取足夠的符合您目標受眾的人員,則可以付費存取小組。", - "you_can_do_a_lot_more_with_links_surveys": "使用連結問卷,您可以做更多事情 \uD83D\uDCA1", "your_survey_is_public": "您的問卷是公開的", "youre_not_plugged_in_yet": "您尚未插入任何內容!" }, @@ -1954,11 +1976,6 @@ "this_user_has_all_the_power": "此使用者擁有所有權限。" } }, - "share": { - "back_to_home": "返回首頁", - "page_not_found": "找不到頁面", - "page_not_found_description": "抱歉,我們找不到您要尋找的回應分享 ID。" - }, "templates": { "address": "地址", "address_description": "要求郵寄地址", @@ -1969,7 +1986,6 @@ "alignment_and_engagement_survey_question_1_upper_label": "完全瞭解", "alignment_and_engagement_survey_question_2_headline": "我覺得我的價值觀與公司的使命和文化一致。", "alignment_and_engagement_survey_question_2_lower_label": "不一致", - "alignment_and_engagement_survey_question_2_upper_label": "完全一致", "alignment_and_engagement_survey_question_3_headline": "我與我的團隊有效協作以實現我們的目標。", "alignment_and_engagement_survey_question_3_lower_label": "協作不佳", "alignment_and_engagement_survey_question_3_upper_label": "良好的協作", @@ -1979,7 +1995,6 @@ "book_interview": "預訂面試", "build_product_roadmap_description": "找出您的使用者最想要的一件事,然後建立它。", "build_product_roadmap_name": "建立產品路線圖", - "build_product_roadmap_name_with_project_name": "{projectName} 路線圖輸入", "build_product_roadmap_question_1_headline": "您對 {projectName} 的功能和特性感到滿意嗎?", "build_product_roadmap_question_1_lower_label": "完全不滿意", "build_product_roadmap_question_1_upper_label": "非常滿意", @@ -2162,7 +2177,6 @@ "csat_question_7_choice_3": "有點快速回應", "csat_question_7_choice_4": "不太快速回應", "csat_question_7_choice_5": "完全不快速回應", - "csat_question_7_choice_6": "不適用", "csat_question_7_headline": "我們對您有關我們服務的問題的回應有多迅速?", "csat_question_7_subheader": "請選取其中一項:", "csat_question_8_choice_1": "這是我的第一次購買", @@ -2170,7 +2184,6 @@ "csat_question_8_choice_3": "六個月到一年", "csat_question_8_choice_4": "1 - 2 年", "csat_question_8_choice_5": "3 年或以上", - "csat_question_8_choice_6": "我尚未購買", "csat_question_8_headline": "您成為 {projectName} 的客戶有多久了?", "csat_question_8_subheader": "請選取其中一項:", "csat_question_9_choice_1": "非常有可能", @@ -2385,7 +2398,6 @@ "identify_sign_up_barriers_question_9_dismiss_button_label": "暫時跳過", "identify_sign_up_barriers_question_9_headline": "謝謝!這是您的程式碼:SIGNUPNOW10", "identify_sign_up_barriers_question_9_html": "

非常感謝您撥冗分享回饋 \uD83D\uDE4F

", - "identify_sign_up_barriers_with_project_name": "{projectName} 註冊障礙", "identify_upsell_opportunities_description": "找出您的產品為使用者節省了多少時間。使用它來追加銷售。", "identify_upsell_opportunities_name": "識別追加銷售機會", "identify_upsell_opportunities_question_1_choice_1": "不到 1 小時", @@ -2597,7 +2609,7 @@ "preview_survey_question_2_back_button_label": "返回", "preview_survey_question_2_choice_1_label": "是,請保持通知我。", "preview_survey_question_2_choice_2_label": "不用了,謝謝!", - "preview_survey_question_2_headline": "想要保持最新消息嗎?", + "preview_survey_question_2_headline": "想要緊跟最新動態嗎?", "preview_survey_welcome_card_headline": "歡迎!", "preview_survey_welcome_card_html": "感謝您提供回饋 - 開始吧!", "prioritize_features_description": "找出您的使用者最需要和最不需要的功能。", @@ -2750,7 +2762,6 @@ "site_abandonment_survey_question_6_choice_3": "更多產品種類", "site_abandonment_survey_question_6_choice_4": "改進的網站設計", "site_abandonment_survey_question_6_choice_5": "更多客戶評論", - "site_abandonment_survey_question_6_choice_6": "其他", "site_abandonment_survey_question_6_headline": "哪些改進措施可以鼓勵您在我們的網站上停留更久?", "site_abandonment_survey_question_6_subheader": "請選取所有適用的選項:", "site_abandonment_survey_question_7_headline": "您是否要接收有關新產品和促銷活動的更新資訊?", @@ -2781,6 +2792,8 @@ "star_rating_survey_question_3_placeholder": "在此輸入您的答案...", "star_rating_survey_question_3_subheader": "協助我們改善您的體驗。", "statement_call_to_action": "陳述(行動呼籲)", + "strongly_agree": "非常同意", + "strongly_disagree": "非常不同意", "supportive_work_culture_survey_description": "評估員工對領導層支援、溝通和整體工作環境的看法。", "supportive_work_culture_survey_name": "支援性工作文化", "supportive_work_culture_survey_question_1_headline": "我的經理為我提供了完成工作所需的支援。", @@ -2836,6 +2849,18 @@ "understand_purchase_intention_question_2_headline": "瞭解了。您今天來訪的主要原因是什麼?", "understand_purchase_intention_question_2_placeholder": "在此輸入您的答案...", "understand_purchase_intention_question_3_headline": "有什麼阻礙您今天進行購買嗎?", - "understand_purchase_intention_question_3_placeholder": "在此輸入您的答案..." + "understand_purchase_intention_question_3_placeholder": "在此輸入您的答案...", + "usability_question_10_headline": "我 必須 學習 很多 東西 才能 正確 使用 該 系統。", + "usability_question_1_headline": "我可能會經常使用這個系統。", + "usability_question_2_headline": "系統感覺起來比實際需要的更複雜。", + "usability_question_3_headline": "系統很容易理解。", + "usability_question_4_headline": "我 認為 我 需要 技術 專家 的 幫助 才能 使用 這個 系統。", + "usability_question_5_headline": "系統中 的 所有 元素 看起來 都能 很好 地 運作。", + "usability_question_6_headline": "系統在運作上給人不一致的感覺。", + "usability_question_7_headline": "我認為大多數人可以快速 學會 使用 這個 系統。", + "usability_question_8_headline": "使用系統 感覺 令人 困擾。", + "usability_question_9_headline": "使用 系統 時,我 感到 有 信心。", + "usability_rating_description": "透過使用標準化的 十個問題 問卷,要求使用者評估他們對 您 產品的使用體驗,來衡量感知的 可用性。", + "usability_score_name": "系統 可用性 分數 (SUS)" } } diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index e79837acc86d..257786cc0c1c 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,122 +1,65 @@ -import { - clientSideApiEndpointsLimiter, - forgotPasswordLimiter, - loginLimiter, - shareUrlLimiter, - signupLimiter, - syncUserIdentificationLimiter, - verifyEmailLimiter, -} from "@/app/middleware/bucket"; -import { - isAuthProtectedRoute, - isClientSideApiRoute, - isForgotPasswordRoute, - isLoginRoute, - isManagementApiRoute, - isShareUrlRoute, - isSignupRoute, - isSyncWithUserIdentificationEndpoint, - isVerifyEmailRoute, -} from "@/app/middleware/endpoint-validator"; -import { logApiError } from "@/modules/api/v2/lib/utils"; -import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { ipAddress } from "@vercel/functions"; +import { isPublicDomainConfigured, isRequestFromPublicDomain } from "@/app/middleware/domain-utils"; +import { isAuthProtectedRoute, isRouteAllowedForDomain } from "@/app/middleware/endpoint-validator"; +import { WEBAPP_URL } from "@/lib/constants"; +import { isValidCallbackUrl } from "@/lib/utils/url"; import { getToken } from "next-auth/jwt"; import { NextRequest, NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; -import { - E2E_TESTING, - IS_PRODUCTION, - RATE_LIMITING_DISABLED, - SURVEY_URL, - WEBAPP_URL, -} from "@formbricks/lib/constants"; -import { isValidCallbackUrl } from "@formbricks/lib/utils/url"; import { logger } from "@formbricks/logger"; -const enforceHttps = (request: NextRequest): Response | null => { - const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http"; - if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") { - const apiError: ApiErrorResponseV2 = { - type: "forbidden", - details: [ - { - field: "", - issue: "Only HTTPS connections are allowed on the management and contacts bulk endpoints.", - }, - ], - }; - logApiError(request, apiError); - return NextResponse.json(apiError, { status: 403 }); - } - return null; -}; - const handleAuth = async (request: NextRequest): Promise => { const token = await getToken({ req: request as any }); + if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) { const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`; return NextResponse.redirect(loginUrl); } const callbackUrl = request.nextUrl.searchParams.get("callbackUrl"); + if (callbackUrl && !isValidCallbackUrl(callbackUrl, WEBAPP_URL)) { return NextResponse.json({ error: "Invalid callback URL" }, { status: 400 }); } + if (token && callbackUrl) { - return NextResponse.redirect(WEBAPP_URL + callbackUrl); + return NextResponse.redirect(callbackUrl); } - return null; -}; -const applyRateLimiting = (request: NextRequest, ip: string) => { - if (isLoginRoute(request.nextUrl.pathname)) { - loginLimiter(`login-${ip}`); - } else if (isSignupRoute(request.nextUrl.pathname)) { - signupLimiter(`signup-${ip}`); - } else if (isVerifyEmailRoute(request.nextUrl.pathname)) { - verifyEmailLimiter(`verify-email-${ip}`); - } else if (isForgotPasswordRoute(request.nextUrl.pathname)) { - forgotPasswordLimiter(`forgot-password-${ip}`); - } else if (isClientSideApiRoute(request.nextUrl.pathname)) { - clientSideApiEndpointsLimiter(`client-side-api-${ip}`); - const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname); - if (envIdAndUserId) { - const { environmentId, userId } = envIdAndUserId; - syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`); - } - } else if (isShareUrlRoute(request.nextUrl.pathname)) { - shareUrlLimiter(`share-${ip}`); - } + return null; }; -const handleSurveyDomain = (request: NextRequest): Response | null => { +/** + * Handle domain-aware routing based on PUBLIC_URL and WEBAPP_URL + */ +const handleDomainAwareRouting = (request: NextRequest): Response | null => { try { - if (!SURVEY_URL) return null; + const publicDomainConfigured = isPublicDomainConfigured(); - const host = request.headers.get("host") || ""; - const surveyDomain = SURVEY_URL ? new URL(SURVEY_URL).host : ""; - if (host !== surveyDomain) return null; + // When PUBLIC_URL is not configured, admin domain allows all routes (backward compatibility) + if (!publicDomainConfigured) return null; - return new NextResponse(null, { status: 404 }); + const isPublicDomain = isRequestFromPublicDomain(request); + + const pathname = request.nextUrl.pathname; + + // Check if the route is allowed for the current domain + const isAllowed = isRouteAllowedForDomain(pathname, isPublicDomain); + + if (!isAllowed) { + return new NextResponse(null, { status: 404 }); + } + + return null; // Allow the request to continue } catch (error) { - logger.error(error, "Error handling survey domain"); + logger.error(error, "Error handling domain-aware routing"); return new NextResponse(null, { status: 404 }); } }; -const isSurveyRoute = (request: NextRequest) => { - return request.nextUrl.pathname.startsWith("/c/") || request.nextUrl.pathname.startsWith("/s/"); -}; - export const middleware = async (originalRequest: NextRequest) => { - if (isSurveyRoute(originalRequest)) { - return NextResponse.next(); - } - - // Handle survey domain routing. - const surveyResponse = handleSurveyDomain(originalRequest); - if (surveyResponse) return surveyResponse; + // Handle domain-aware routing first + const domainResponse = handleDomainAwareRouting(originalRequest); + if (domainResponse) return domainResponse; // Create a new Request object to override headers and add a unique request ID header const request = new NextRequest(originalRequest, { @@ -127,51 +70,21 @@ export const middleware = async (originalRequest: NextRequest) => { request.headers.set("x-start-time", Date.now().toString()); // Create a new NextResponse object to forward the new request with headers - const nextResponseWithCustomHeader = NextResponse.next({ request: { headers: request.headers, }, }); - // Enforce HTTPS for management endpoints - if (isManagementApiRoute(request.nextUrl.pathname)) { - const httpsResponse = enforceHttps(request); - if (httpsResponse) return httpsResponse; - } - // Handle authentication const authResponse = await handleAuth(request); if (authResponse) return authResponse; - if (!IS_PRODUCTION || RATE_LIMITING_DISABLED) { - return nextResponseWithCustomHeader; - } - - let ip = - request.headers.get("cf-connecting-ip") || - request.headers.get("x-forwarded-for")?.split(",")[0].trim() || - ipAddress(request); - - if (ip) { - try { - applyRateLimiting(request, ip); - return nextResponseWithCustomHeader; - } catch (e) { - const apiError: ApiErrorResponseV2 = { - type: "too_many_requests", - details: [{ field: "", issue: "Too many requests. Please try again later." }], - }; - logApiError(request, apiError); - return NextResponse.json(apiError, { status: 429 }); - } - } - return nextResponseWithCustomHeader; }; export const config = { matcher: [ - "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|js|css|images|fonts|icons|public|api/v1/og).*)", // Exclude the Open Graph image generation route from middleware + "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|js|css|images|fonts|icons|public).*)", ], }; diff --git a/apps/web/modules/account/components/DeleteAccountModal/actions.ts b/apps/web/modules/account/components/DeleteAccountModal/actions.ts index 87b4d9ac40e8..5755f19a3dc7 100644 --- a/apps/web/modules/account/components/DeleteAccountModal/actions.ts +++ b/apps/web/modules/account/components/DeleteAccountModal/actions.ts @@ -1,18 +1,29 @@ "use server"; +import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; +import { deleteUser, getUser } from "@/lib/user/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; +import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; -import { getOrganizationsWhereUserIsSingleOwner } from "@formbricks/lib/organization/service"; -import { deleteUser } from "@formbricks/lib/user/service"; import { OperationNotAllowedError } from "@formbricks/types/errors"; -export const deleteUserAction = authenticatedActionClient.action(async ({ ctx }) => { - const isMultiOrgEnabled = await getIsMultiOrgEnabled(); - const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id); - if (!isMultiOrgEnabled && organizationsWithSingleOwner.length > 0) { - throw new OperationNotAllowedError( - "You are the only owner of this organization. Please transfer ownership to another member first." - ); - } - return await deleteUser(ctx.user.id); -}); +export const deleteUserAction = authenticatedActionClient.action( + withAuditLogging( + "deleted", + "user", + async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => { + const isMultiOrgEnabled = await getIsMultiOrgEnabled(); + const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id); + if (!isMultiOrgEnabled && organizationsWithSingleOwner.length > 0) { + throw new OperationNotAllowedError( + "You are the only owner of this organization. Please transfer ownership to another member first." + ); + } + ctx.auditLoggingCtx.userId = ctx.user.id; + ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id); + const result = await deleteUser(ctx.user.id); + return result; + } + ) +); diff --git a/apps/web/modules/account/components/DeleteAccountModal/index.test.tsx b/apps/web/modules/account/components/DeleteAccountModal/index.test.tsx new file mode 100644 index 000000000000..004ff45e37a5 --- /dev/null +++ b/apps/web/modules/account/components/DeleteAccountModal/index.test.tsx @@ -0,0 +1,189 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TUser } from "@formbricks/types/user"; +import * as actions from "./actions"; +import { DeleteAccountModal } from "./index"; + +// Mock constants that this test needs +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + WEBAPP_URL: "http://localhost:3000", +})); + +// Mock server actions that this test needs +vi.mock("@/modules/auth/actions/sign-out", () => ({ + logSignOutAction: vi.fn().mockResolvedValue(undefined), +})); + +// Mock our useSignOut hook +const mockSignOut = vi.fn(); +vi.mock("@/modules/auth/hooks/use-sign-out", () => ({ + useSignOut: () => ({ + signOut: mockSignOut, + }), +})); + +vi.mock("./actions", () => ({ + deleteUserAction: vi.fn(), +})); + +describe("DeleteAccountModal", () => { + const mockUser: TUser = { + email: "test@example.com", + } as TUser; + + const mockOrgs: TOrganization[] = [{ name: "Org1" }, { name: "Org2" }] as TOrganization[]; + + const mockSetOpen = vi.fn(); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders modal with correct props", () => { + render( + + ); + + expect(screen.getByText("Org1")).toBeInTheDocument(); + expect(screen.getByText("Org2")).toBeInTheDocument(); + }); + + test("disables delete button when email does not match", () => { + render( + + ); + + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "wrong@example.com" } }); + expect(input).toHaveValue("wrong@example.com"); + }); + + test("allows account deletion flow (non-cloud)", async () => { + const deleteUserAction = vi + .spyOn(actions, "deleteUserAction") + .mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here + + Object.defineProperty(window, "localStorage", { + writable: true, + value: { removeItem: vi.fn() }, + }); + + // Mock window.location.replace + Object.defineProperty(window, "location", { + writable: true, + value: { replace: vi.fn() }, + }); + + render( + + ); + + const input = screen.getByTestId("deleteAccountConfirmation"); + fireEvent.change(input, { target: { value: mockUser.email } }); + + const form = screen.getByTestId("deleteAccountForm"); + fireEvent.submit(form); + + await waitFor(() => { + expect(deleteUserAction).toHaveBeenCalled(); + expect(mockSignOut).toHaveBeenCalledWith({ + reason: "account_deletion", + redirect: false, // Updated to match new implementation + clearEnvironmentId: true, + }); + expect(window.location.replace).toHaveBeenCalledWith("/auth/login"); + expect(mockSetOpen).toHaveBeenCalledWith(false); + }); + }); + + test("allows account deletion flow (cloud)", async () => { + const deleteUserAction = vi + .spyOn(actions, "deleteUserAction") + .mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here + + Object.defineProperty(window, "localStorage", { + writable: true, + value: { removeItem: vi.fn() }, + }); + + Object.defineProperty(window, "location", { + writable: true, + value: { replace: vi.fn() }, + }); + + render( + + ); + + const input = screen.getByTestId("deleteAccountConfirmation"); + fireEvent.change(input, { target: { value: mockUser.email } }); + + const form = screen.getByTestId("deleteAccountForm"); + fireEvent.submit(form); + + await waitFor(() => { + expect(deleteUserAction).toHaveBeenCalled(); + expect(mockSignOut).toHaveBeenCalledWith({ + reason: "account_deletion", + redirect: false, // Updated to match new implementation + clearEnvironmentId: true, + }); + expect(window.location.replace).toHaveBeenCalledWith( + "https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2" + ); + expect(mockSetOpen).toHaveBeenCalledWith(false); + }); + }); + + test("handles deletion errors", async () => { + const deleteUserAction = vi.spyOn(actions, "deleteUserAction").mockRejectedValue(new Error("fail")); + + render( + + ); + + const input = screen.getByTestId("deleteAccountConfirmation"); + fireEvent.change(input, { target: { value: mockUser.email } }); + + const form = screen.getByTestId("deleteAccountForm"); + fireEvent.submit(form); + + await waitFor(() => { + expect(deleteUserAction).toHaveBeenCalled(); + expect(mockSetOpen).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/apps/web/modules/account/components/DeleteAccountModal/index.tsx b/apps/web/modules/account/components/DeleteAccountModal/index.tsx index 7c5c50fb1f0d..6f80722bed88 100644 --- a/apps/web/modules/account/components/DeleteAccountModal/index.tsx +++ b/apps/web/modules/account/components/DeleteAccountModal/index.tsx @@ -1,10 +1,9 @@ "use client"; +import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { Input } from "@/modules/ui/components/input"; -import { useTranslate } from "@tolgee/react"; -import { T } from "@tolgee/react"; -import { signOut } from "next-auth/react"; +import { T, useTranslate } from "@tolgee/react"; import { Dispatch, SetStateAction, useState } from "react"; import toast from "react-hot-toast"; import { TOrganization } from "@formbricks/types/organizations"; @@ -17,7 +16,6 @@ interface DeleteAccountModalProps { user: TUser; isFormbricksCloud: boolean; organizationsWithSingleOwner: TOrganization[]; - formbricksLogout: () => Promise; } export const DeleteAccountModal = ({ @@ -25,12 +23,12 @@ export const DeleteAccountModal = ({ open, user, isFormbricksCloud, - formbricksLogout, organizationsWithSingleOwner, }: DeleteAccountModalProps) => { const { t } = useTranslate(); const [deleting, setDeleting] = useState(false); const [inputValue, setInputValue] = useState(""); + const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); const handleInputChange = (e: React.ChangeEvent) => { setInputValue(e.target.value); }; @@ -39,13 +37,19 @@ export const DeleteAccountModal = ({ try { setDeleting(true); await deleteUserAction(); - await formbricksLogout(); - // redirect to account deletion survey in Formbricks Cloud + + // Sign out with account deletion reason (no automatic redirect) + await signOutWithAudit({ + reason: "account_deletion", + redirect: false, // Prevent NextAuth automatic redirect + clearEnvironmentId: true, + }); + + // Manual redirect after signOut completes if (isFormbricksCloud) { - await signOut({ redirect: true }); window.location.replace("https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2"); } else { - await signOut({ callbackUrl: "/auth/login" }); + window.location.replace("/auth/login"); } } catch (error) { toast.error("Something went wrong"); @@ -88,6 +92,7 @@ export const DeleteAccountModal = ({
  • {t("environments.settings.profile.warning_cannot_undo")}
  • { e.preventDefault(); await deleteAccount(); @@ -98,6 +103,7 @@ export const DeleteAccountModal = ({ })} ({ + TiredFace: (props: any) => ( + + TiredFace + + ), + WearyFace: (props: any) => ( + + WearyFace + + ), + PerseveringFace: (props: any) => ( + + PerseveringFace + + ), + FrowningFace: (props: any) => ( + + FrowningFace + + ), + ConfusedFace: (props: any) => ( + + ConfusedFace + + ), + NeutralFace: (props: any) => ( + + NeutralFace + + ), + SlightlySmilingFace: (props: any) => ( + + SlightlySmilingFace + + ), + SmilingFaceWithSmilingEyes: (props: any) => ( + + SmilingFaceWithSmilingEyes + + ), + GrinningFaceWithSmilingEyes: (props: any) => ( + + GrinningFaceWithSmilingEyes + + ), + GrinningSquintingFace: (props: any) => ( + + GrinningSquintingFace + + ), +})); + +describe("RatingSmiley", () => { + afterEach(() => { + cleanup(); + }); + + const activeClass = "bg-rating-fill"; + + // Test branch: range === 10 => iconsIdx = [0,1,2,...,9] + test("renders correct icon for range 10 when active", () => { + // For idx 0, iconsIdx[0] === 0, which corresponds to TiredFace. + const { getByTestId } = render(); + const icon = getByTestId("tired"); + expect(icon).toBeDefined(); + expect(icon.className).toContain(activeClass); + }); + + test("renders correct icon for range 10 when inactive", () => { + const { getByTestId } = render(); + const icon = getByTestId("tired"); + expect(icon).toBeDefined(); + expect(icon.className).toContain("fill-none"); + }); + + // Test branch: range === 7 => iconsIdx = [1,3,4,5,6,8,9] + test("renders correct icon for range 7 when active", () => { + // For idx 0, iconsIdx[0] === 1, which corresponds to WearyFace. + const { getByTestId } = render(); + const icon = getByTestId("weary"); + expect(icon).toBeDefined(); + expect(icon.className).toContain(activeClass); + }); + + // Test branch: range === 5 => iconsIdx = [3,4,5,6,7] + test("renders correct icon for range 5 when active", () => { + // For idx 0, iconsIdx[0] === 3, which corresponds to FrowningFace. + const { getByTestId } = render(); + const icon = getByTestId("frowning"); + expect(icon).toBeDefined(); + expect(icon.className).toContain(activeClass); + }); + + // Test branch: range === 4 => iconsIdx = [4,5,6,7] + test("renders correct icon for range 4 when active", () => { + // For idx 0, iconsIdx[0] === 4, corresponding to ConfusedFace. + const { getByTestId } = render(); + const icon = getByTestId("confused"); + expect(icon).toBeDefined(); + expect(icon.className).toContain(activeClass); + }); + + // Test branch: range === 3 => iconsIdx = [4,5,7] + test("renders correct icon for range 3 when active", () => { + // For idx 0, iconsIdx[0] === 4, corresponding to ConfusedFace. + const { getByTestId } = render(); + const icon = getByTestId("confused"); + expect(icon).toBeDefined(); + expect(icon.className).toContain(activeClass); + }); +}); diff --git a/apps/web/modules/analysis/components/RatingSmiley/index.tsx b/apps/web/modules/analysis/components/RatingSmiley/index.tsx index b91207866f81..52f1072b6f22 100644 --- a/apps/web/modules/analysis/components/RatingSmiley/index.tsx +++ b/apps/web/modules/analysis/components/RatingSmiley/index.tsx @@ -1,67 +1,96 @@ import type { JSX } from "react"; -import { - ConfusedFace, - FrowningFace, - GrinningFaceWithSmilingEyes, - GrinningSquintingFace, - NeutralFace, - PerseveringFace, - SlightlySmilingFace, - SmilingFaceWithSmilingEyes, - TiredFace, - WearyFace, -} from "../SingleResponseCard/components/Smileys"; interface RatingSmileyProps { active: boolean; idx: number; range: number; addColors?: boolean; + baseUrl?: string; } const getSmileyColor = (range: number, idx: number) => { if (range > 5) { - if (range - idx < 3) return "fill-emerald-100"; - if (range - idx < 5) return "fill-orange-100"; - return "fill-rose-100"; + if (range - idx < 3) return "bg-emerald-100"; + if (range - idx < 5) return "bg-orange-100"; + return "bg-rose-100"; } else if (range < 5) { - if (range - idx < 2) return "fill-emerald-100"; - if (range - idx < 3) return "fill-orange-100"; - return "fill-rose-100"; + if (range - idx < 2) return "bg-emerald-100"; + if (range - idx < 3) return "bg-orange-100"; + return "bg-rose-100"; } else { - if (range - idx < 3) return "fill-emerald-100"; - if (range - idx < 4) return "fill-orange-100"; - return "fill-rose-100"; + if (range - idx < 3) return "bg-emerald-100"; + if (range - idx < 4) return "bg-orange-100"; + return "bg-rose-100"; } }; -const getSmiley = (iconIdx: number, idx: number, range: number, active: boolean, addColors: boolean) => { - const activeColor = "fill-rating-fill"; - const inactiveColor = addColors ? getSmileyColor(range, idx) : "fill-none"; +// Helper function to get smiley image URL based on index and range +const getSmiley = ( + iconIdx: number, + idx: number, + range: number, + active: boolean, + addColors: boolean, + baseUrl?: string +): JSX.Element => { + const activeColor = "bg-rating-fill"; + const inactiveColor = addColors ? getSmileyColor(range, idx) : "bg-fill-none"; - const icons = [ - , - , - , - , - , - , - , - , - , - , + const faceIcons = [ + "tired", + "weary", + "persevering", + "frowning", + "confused", + "neutral", + "slightly-smiling", + "smiling-face-with-smiling-eyes", + "grinning-face-with-smiling-eyes", + "grinning-squinting", ]; - return icons[iconIdx]; + const icon = ( + {faceIcons[iconIdx]} + ); + + return ( + + {" "} + {/* NOSONAR S5256 - Need table layout for email compatibility (gmail) */} + + + +
    + {icon} +
    + ); }; -export const RatingSmiley = ({ active, idx, range, addColors = false }: RatingSmileyProps): JSX.Element => { +export const RatingSmiley = ({ + active, + idx, + range, + addColors = false, + baseUrl, +}: RatingSmileyProps): JSX.Element => { let iconsIdx: number[] = []; if (range === 10) iconsIdx = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; else if (range === 7) iconsIdx = [1, 3, 4, 5, 6, 8, 9]; + else if (range === 6) iconsIdx = [0, 2, 4, 5, 7, 9]; else if (range === 5) iconsIdx = [3, 4, 5, 6, 7]; else if (range === 4) iconsIdx = [4, 5, 6, 7]; else if (range === 3) iconsIdx = [4, 5, 7]; - return getSmiley(iconsIdx[idx], idx, range, active, addColors); + return getSmiley(iconsIdx[idx], idx, range, active, addColors, baseUrl); }; diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.test.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.test.tsx new file mode 100644 index 000000000000..dec906f64b44 --- /dev/null +++ b/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.test.tsx @@ -0,0 +1,94 @@ +import { getEnabledLanguages } from "@/lib/i18n/utils"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils"; +import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types"; +import { LanguageDropdown } from "./LanguageDropdown"; + +vi.mock("@/lib/i18n/utils", () => ({ + getEnabledLanguages: vi.fn(), +})); + +vi.mock("@formbricks/i18n-utils/src/utils", () => ({ + getLanguageLabel: vi.fn(), +})); + +describe("LanguageDropdown", () => { + const dummySurveyMultiple = { + languages: [ + { language: { code: "en" } } as TSurveyLanguage, + { language: { code: "fr" } } as TSurveyLanguage, + ], + } as TSurvey; + const dummySurveySingle = { + languages: [{ language: { code: "en" } }], + } as TSurvey; + const dummyLocale = "en-US"; + const setLanguageMock = vi.fn(); + + afterEach(() => { + cleanup(); + }); + + test("renders nothing when enabledLanguages length is 1", () => { + vi.mocked(getEnabledLanguages).mockReturnValueOnce([{ language: { code: "en" } } as TSurveyLanguage]); + render( + + ); + // Since enabledLanguages.length === 1, component should render null. + expect(screen.queryByRole("button")).toBeNull(); + }); + + test("renders button and toggles dropdown when multiple languages exist", async () => { + vi.mocked(getEnabledLanguages).mockReturnValue(dummySurveyMultiple.languages); + vi.mocked(getLanguageLabel).mockImplementation((code: string, _locale: string) => code.toUpperCase()); + + render( + + ); + + const button = screen.getByRole("button", { name: "Select Language" }); + expect(button).toBeDefined(); + + await userEvent.click(button); + // Wait for the dropdown options to appear. They are wrapped in a div with no specific role, + // so we query for texts (our mock labels) instead. + const optionEn = await screen.findByText("EN"); + const optionFr = await screen.findByText("FR"); + + expect(optionEn).toBeDefined(); + expect(optionFr).toBeDefined(); + + await userEvent.click(optionFr); + expect(setLanguageMock).toHaveBeenCalledWith("fr"); + + // After clicking, dropdown should no longer be visible. + await waitFor(() => { + expect(screen.queryByText("EN")).toBeNull(); + expect(screen.queryByText("FR")).toBeNull(); + }); + }); + + test("closes dropdown when clicking outside", async () => { + vi.mocked(getEnabledLanguages).mockReturnValue(dummySurveyMultiple.languages); + vi.mocked(getLanguageLabel).mockImplementation((code: string, _locale: string) => code); + + render( + + ); + const button = screen.getByRole("button", { name: "Select Language" }); + await userEvent.click(button); + + // Confirm dropdown shown + expect(await screen.findByText("en")).toBeDefined(); + + // Simulate clicking outside by dispatching a click event on the container's parent. + await userEvent.click(document.body); + + // Wait for dropdown to close + await waitFor(() => { + expect(screen.queryByText("en")).toBeNull(); + }); + }); +}); diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.tsx index ab291129a6ea..a2ee9222b4a7 100644 --- a/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.tsx +++ b/apps/web/modules/analysis/components/ShareSurveyLink/components/LanguageDropdown.tsx @@ -1,9 +1,9 @@ +import { getEnabledLanguages } from "@/lib/i18n/utils"; +import { useClickOutside } from "@/lib/utils/hooks/useClickOutside"; import { Button } from "@/modules/ui/components/button"; import { Languages } from "lucide-react"; import { useRef, useState } from "react"; -import { getEnabledLanguages } from "@formbricks/lib/i18n/utils"; -import { getLanguageLabel } from "@formbricks/lib/i18n/utils"; -import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside"; +import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils"; import { TSurvey } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; @@ -28,15 +28,15 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo className="absolute top-12 z-30 w-fit rounded-lg border bg-slate-900 p-1 text-sm text-white" ref={languageDropdownRef}> {enabledLanguages.map((surveyLanguage) => ( -
    { setLanguage(surveyLanguage.language.code); setShowLanguageSelect(false); }}> {getLanguageLabel(surveyLanguage.language.code, locale)} -
    + ))}
    )} diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.test.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.test.tsx new file mode 100644 index 000000000000..ea6ffc749d1f --- /dev/null +++ b/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.test.tsx @@ -0,0 +1,22 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { SurveyLinkDisplay } from "./SurveyLinkDisplay"; + +describe("SurveyLinkDisplay", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the Input when surveyUrl is provided", () => { + const surveyUrl = "http://example.com/s/123"; + render(); + const input = screen.getByTestId("survey-url-input"); + expect(input).toBeInTheDocument(); + }); + + test("renders loading state when surveyUrl is empty", () => { + render(); + const loadingDiv = screen.getByTestId("loading-div"); + expect(loadingDiv).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.tsx index 61e0e5bf0577..95d45877b087 100644 --- a/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.tsx +++ b/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.tsx @@ -1,21 +1,38 @@ +import { cn } from "@/lib/cn"; import { Input } from "@/modules/ui/components/input"; interface SurveyLinkDisplayProps { surveyUrl: string; + enforceSurveyUrlWidth?: boolean; } -export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => { +export const SurveyLinkDisplay = ({ surveyUrl, enforceSurveyUrlWidth = false }: SurveyLinkDisplayProps) => { return ( <> {surveyUrl ? ( ) : ( //loading state -
    +
    )} ); diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/index.test.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/index.test.tsx new file mode 100644 index 000000000000..55a146c72486 --- /dev/null +++ b/apps/web/modules/analysis/components/ShareSurveyLink/index.test.tsx @@ -0,0 +1,263 @@ +import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink/index"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { toast } from "react-hot-toast"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { TUserLocale } from "@formbricks/types/user"; +import { getSurveyUrl } from "../../utils"; + +vi.mock("react-hot-toast", () => ({ + toast: { + success: vi.fn(), + }, +})); + +// Mock the useSingleUseId hook +vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({ + useSingleUseId: vi.fn(() => ({ + singleUseId: "test-single-use-id", + refreshSingleUseId: vi.fn().mockResolvedValue("test-single-use-id"), + })), +})); + +// Mock the survey utils +vi.mock("../../utils", () => ({ + getSurveyUrl: vi.fn((survey, publicDomain, language) => { + if (language && language !== "en") { + return `${publicDomain}/s/${survey.id}?lang=${language}`; + } + return `${publicDomain}/s/${survey.id}`; + }), +})); + +const survey: TSurvey = { + id: "survey-id", + name: "Test Survey", + type: "link", + status: "inProgress", + questions: [ + { + id: "question-id", + type: TSurveyQuestionTypeEnum.OpenText, + headline: { default: "Question headline" }, + subheader: { default: "Question subheader" }, + required: true, + buttonLabel: { default: "Next" }, + inputType: "text", + charLimit: { enabled: false }, + }, + ], + recontactDays: 1, + autoClose: null, + closeOnDate: null, + delay: 0, + displayPercentage: null, + displayLimit: null, + triggers: [], + redirectUrl: null, + numDisplays: 0, + numDisplaysGlobally: 0, + numResponses: 0, + numResponsesGlobally: 0, + createdAt: new Date(), + updatedAt: new Date(), + languages: [ + { + default: true, + enabled: true, + language: { + id: "lang-1", + code: "en", + alias: "English", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "proj-1", + }, + }, + { + default: false, + enabled: true, + language: { + id: "lang-2", + code: "de", + alias: "German", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "proj-1", + }, + }, + ], + styling: null, + variables: [], + welcomeCard: { + enabled: true, + headline: { default: "Welcome!" }, + timeToFinish: false, + showResponseCount: false, + }, + surveyClosedMessage: null, + singleUse: null, + productOverwrites: null, + pin: null, + verifyEmail: null, + attributeFilters: [], + autoComplete: null, + hiddenFields: { enabled: true }, + environmentId: "env-id", + endings: [], + displayOption: "displayOnce", + isBackButtonHidden: false, + isSingleResponsePerEmailEnabled: false, + isVerifyEmailEnabled: false, + recaptcha: { enabled: false, threshold: 0.5 }, + segment: null, + showLanguageSwitch: false, + createdBy: "user-id", + followUps: [], +} as unknown as TSurvey; + +const publicDomain = "http://localhost:3000"; +let surveyUrl = `${publicDomain}/s/survey-id`; +const setSurveyUrl = vi.fn((url: string) => { + surveyUrl = url; +}); +const locale: TUserLocale = "en-US"; + +// Mocking dependencies +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +Object.assign(navigator, { + clipboard: { + writeText: vi.fn(), + }, +}); + +global.open = vi.fn(); + +describe("ShareSurveyLink", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + surveyUrl = `${publicDomain}/s/survey-id`; + }); + + test("renders the component with initial values", () => { + render( + + ); + + expect(screen.getByDisplayValue(surveyUrl)).toBeInTheDocument(); + expect(screen.getByText("common.copy")).toBeInTheDocument(); + expect(screen.getByText("common.preview")).toBeInTheDocument(); + }); + + test("copies the survey link to the clipboard when copy button is clicked", () => { + render( + + ); + + const copyButton = screen.getByLabelText("environments.surveys.copy_survey_link_to_clipboard"); + fireEvent.click(copyButton); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(surveyUrl); + expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard"); + }); + + test("opens the preview link in a new tab when preview button is clicked (no query params)", async () => { + render( + + ); + + const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab"); + fireEvent.click(previewButton); + + // Wait for the async function to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(global.open).toHaveBeenCalledWith(`${surveyUrl}?preview=true`, "_blank"); + }); + + test("opens the preview link in a new tab when preview button is clicked (with query params)", async () => { + const surveyWithParamsUrl = `${publicDomain}/s/survey-id?foo=bar`; + render( + + ); + + const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab"); + fireEvent.click(previewButton); + + // Wait for the async function to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(global.open).toHaveBeenCalledWith(`${surveyWithParamsUrl}&preview=true`, "_blank"); + }); + + test("disables copy and preview buttons when surveyUrl is empty", () => { + render( + + ); + + const copyButton = screen.getByLabelText("environments.surveys.copy_survey_link_to_clipboard"); + const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab"); + + expect(copyButton).toBeDisabled(); + expect(previewButton).toBeDisabled(); + }); + + test("updates the survey URL when the language is changed", () => { + const mockGetSurveyUrl = vi.mocked(getSurveyUrl); + + render( + + ); + + const languageDropdown = screen.getByTitle("Select Language"); + fireEvent.click(languageDropdown); + + const germanOption = screen.getByText("German"); + fireEvent.click(germanOption); + + expect(mockGetSurveyUrl).toHaveBeenCalledWith(survey, publicDomain, "de"); + expect(setSurveyUrl).toHaveBeenCalledWith(`${publicDomain}/s/${survey.id}?lang=de`); + }); +}); diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx index 5408dc4f8a02..7347cb8e5a1f 100644 --- a/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx +++ b/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx @@ -1,100 +1,67 @@ "use client"; -import { useSurveyQRCode } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey-qr-code"; -import { getFormattedErrorMessage } from "@/lib/utils/helper"; -import { generateSingleUseIdAction } from "@/modules/survey/list/actions"; +import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId"; import { Button } from "@/modules/ui/components/button"; import { useTranslate } from "@tolgee/react"; -import { Copy, QrCode, RefreshCcw, SquareArrowOutUpRight } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { Copy, SquareArrowOutUpRight } from "lucide-react"; import { toast } from "react-hot-toast"; import { TSurvey } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; +import { getSurveyUrl } from "../../utils"; import { LanguageDropdown } from "./components/LanguageDropdown"; import { SurveyLinkDisplay } from "./components/SurveyLinkDisplay"; interface ShareSurveyLinkProps { survey: TSurvey; - surveyDomain: string; + publicDomain: string; surveyUrl: string; setSurveyUrl: (url: string) => void; locale: TUserLocale; + enforceSurveyUrlWidth?: boolean; + isReadOnly: boolean; } export const ShareSurveyLink = ({ survey, surveyUrl, - surveyDomain, + publicDomain, setSurveyUrl, locale, + enforceSurveyUrlWidth = false, + isReadOnly, }: ShareSurveyLinkProps) => { const { t } = useTranslate(); - const [language, setLanguage] = useState("default"); - const getUrl = useCallback(async () => { - let url = `${surveyDomain}/s/${survey.id}`; - const queryParams: string[] = []; - - if (survey.singleUse?.enabled) { - const singleUseIdResponse = await generateSingleUseIdAction({ - surveyId: survey.id, - isEncrypted: survey.singleUse.isEncrypted, - }); + const handleLanguageChange = (language: string) => { + const url = getSurveyUrl(survey, publicDomain, language); + setSurveyUrl(url); + }; - if (singleUseIdResponse?.data) { - queryParams.push(`suId=${singleUseIdResponse.data}`); - } else { - const errorMessage = getFormattedErrorMessage(singleUseIdResponse); - toast.error(errorMessage); - } - } + const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly); - if (language !== "default") { - queryParams.push(`lang=${language}`); - } + const getPreviewUrl = async () => { + const previewUrl = new URL(surveyUrl); - if (queryParams.length) { - url += `?${queryParams.join("&")}`; + if (survey.singleUse?.enabled) { + const newId = await refreshSingleUseId(); + if (newId) { + previewUrl.searchParams.set("suId", newId); + } } - setSurveyUrl(url); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [survey, surveyDomain, language]); - - const generateNewSingleUseLink = () => { - getUrl(); - toast.success(t("environments.surveys.new_single_use_link_generated")); + previewUrl.searchParams.set("preview", "true"); + return previewUrl.toString(); }; - useEffect(() => { - getUrl(); - }, [survey, getUrl, language]); - - const { downloadQRCode } = useSurveyQRCode(surveyUrl); - return ( -
    - -
    - - +
    + +
    + - {survey.singleUse?.enabled && ( - - )}
    ); diff --git a/apps/web/modules/analysis/components/SingleResponseCard/actions.ts b/apps/web/modules/analysis/components/SingleResponseCard/actions.ts index 8e953980e167..55614ac2312d 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/actions.ts +++ b/apps/web/modules/analysis/components/SingleResponseCard/actions.ts @@ -1,26 +1,21 @@ "use server"; +import { deleteResponse, getResponse } from "@/lib/response/service"; +import { createTag } from "@/lib/tag/service"; +import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { getEnvironmentIdFromResponseId, getOrganizationIdFromEnvironmentId, getOrganizationIdFromResponseId, - getOrganizationIdFromResponseNoteId, getProjectIdFromEnvironmentId, getProjectIdFromResponseId, - getProjectIdFromResponseNoteId, } from "@/lib/utils/helper"; import { getTag } from "@/lib/utils/services"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { z } from "zod"; -import { deleteResponse, getResponse } from "@formbricks/lib/response/service"; -import { - createResponseNote, - resolveResponseNote, - updateResponseNote, -} from "@formbricks/lib/responseNote/service"; -import { createTag } from "@formbricks/lib/tag/service"; -import { addTagToRespone, deleteTagOnResponse } from "@formbricks/lib/tagOnResponse/service"; import { ZId } from "@formbricks/types/common"; const ZCreateTagAction = z.object({ @@ -28,209 +23,168 @@ const ZCreateTagAction = z.object({ tagName: z.string(), }); -export const createTagAction = authenticatedActionClient - .schema(ZCreateTagAction) - .action(async ({ parsedInput, ctx }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "projectTeam", - projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId), - minPermission: "readWrite", - }, - ], - }); - - return await createTag(parsedInput.environmentId, parsedInput.tagName); - }); +export const createTagAction = authenticatedActionClient.schema(ZCreateTagAction).action( + withAuditLogging( + "created", + "tag", + async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => { + const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId); + + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId), + minPermission: "readWrite", + }, + ], + }); + ctx.auditLoggingCtx.organizationId = organizationId; + const result = await createTag(parsedInput.environmentId, parsedInput.tagName); + + if (result.ok) { + ctx.auditLoggingCtx.tagId = result.data.id; + ctx.auditLoggingCtx.newObject = result.data; + } else { + ctx.auditLoggingCtx.newObject = null; + } + + return result; + } + ) +); const ZCreateTagToResponseAction = z.object({ responseId: ZId, tagId: ZId, }); -export const createTagToResponseAction = authenticatedActionClient - .schema(ZCreateTagToResponseAction) - .action(async ({ parsedInput, ctx }) => { - const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId); - const tagEnvironment = await getTag(parsedInput.tagId); - - if (!responseEnvironmentId || !tagEnvironment) { - throw new Error("Environment not found"); - } - - if (responseEnvironmentId !== tagEnvironment.environmentId) { - throw new Error("Response and tag are not in the same environment"); +export const createTagToResponseAction = authenticatedActionClient.schema(ZCreateTagToResponseAction).action( + withAuditLogging( + "addedToResponse", + "tag", + async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => { + const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId); + const tagEnvironment = await getTag(parsedInput.tagId); + + if (!responseEnvironmentId || !tagEnvironment) { + throw new Error("Environment not found"); + } + + if (responseEnvironmentId !== tagEnvironment.environmentId) { + throw new Error("Response and tag are not in the same environment"); + } + + const organizationId = await getOrganizationIdFromEnvironmentId(responseEnvironmentId); + + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId), + minPermission: "readWrite", + }, + ], + }); + ctx.auditLoggingCtx.organizationId = organizationId; + ctx.auditLoggingCtx.tagId = parsedInput.tagId; + const result = await addTagToRespone(parsedInput.responseId, parsedInput.tagId); + ctx.auditLoggingCtx.newObject = result; + return result; } - - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "projectTeam", - projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId), - minPermission: "readWrite", - }, - ], - }); - - return await addTagToRespone(parsedInput.responseId, parsedInput.tagId); - }); + ) +); const ZDeleteTagOnResponseAction = z.object({ responseId: ZId, tagId: ZId, }); -export const deleteTagOnResponseAction = authenticatedActionClient - .schema(ZDeleteTagOnResponseAction) - .action(async ({ parsedInput, ctx }) => { - const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId); - const tagEnvironment = await getTag(parsedInput.tagId); - - if (!responseEnvironmentId || !tagEnvironment) { - throw new Error("Environment not found"); - } - - if (responseEnvironmentId !== tagEnvironment.environmentId) { - throw new Error("Response and tag are not in the same environment"); +export const deleteTagOnResponseAction = authenticatedActionClient.schema(ZDeleteTagOnResponseAction).action( + withAuditLogging( + "removedFromResponse", + "tag", + async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => { + const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId); + const tagEnvironment = await getTag(parsedInput.tagId); + const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId); + if (!responseEnvironmentId || !tagEnvironment) { + throw new Error("Environment not found"); + } + + if (responseEnvironmentId !== tagEnvironment.environmentId) { + throw new Error("Response and tag are not in the same environment"); + } + + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId), + minPermission: "readWrite", + }, + ], + }); + ctx.auditLoggingCtx.organizationId = organizationId; + ctx.auditLoggingCtx.tagId = parsedInput.tagId; + const result = await deleteTagOnResponse(parsedInput.responseId, parsedInput.tagId); + ctx.auditLoggingCtx.oldObject = result; + return result; } - - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "projectTeam", - projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId), - minPermission: "readWrite", - }, - ], - }); - - return await deleteTagOnResponse(parsedInput.responseId, parsedInput.tagId); - }); + ) +); const ZDeleteResponseAction = z.object({ responseId: ZId, }); -export const deleteResponseAction = authenticatedActionClient - .schema(ZDeleteResponseAction) - .action(async ({ parsedInput, ctx }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "projectTeam", - projectId: await getProjectIdFromResponseId(parsedInput.responseId), - minPermission: "readWrite", - }, - ], - }); - - return await deleteResponse(parsedInput.responseId); - }); - -const ZUpdateResponseNoteAction = z.object({ - responseNoteId: ZId, - text: z.string(), -}); - -export const updateResponseNoteAction = authenticatedActionClient - .schema(ZUpdateResponseNoteAction) - .action(async ({ parsedInput, ctx }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromResponseNoteId(parsedInput.responseNoteId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "projectTeam", - projectId: await getProjectIdFromResponseNoteId(parsedInput.responseNoteId), - minPermission: "readWrite", - }, - ], - }); - - return await updateResponseNote(parsedInput.responseNoteId, parsedInput.text); - }); - -const ZResolveResponseNoteAction = z.object({ - responseNoteId: ZId, -}); - -export const resolveResponseNoteAction = authenticatedActionClient - .schema(ZResolveResponseNoteAction) - .action(async ({ parsedInput, ctx }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromResponseNoteId(parsedInput.responseNoteId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "projectTeam", - projectId: await getProjectIdFromResponseNoteId(parsedInput.responseNoteId), - minPermission: "readWrite", - }, - ], - }); - - await resolveResponseNote(parsedInput.responseNoteId); - }); - -const ZCreateResponseNoteAction = z.object({ - responseId: ZId, - text: z.string(), -}); - -export const createResponseNoteAction = authenticatedActionClient - .schema(ZCreateResponseNoteAction) - .action(async ({ parsedInput, ctx }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "projectTeam", - projectId: await getProjectIdFromResponseId(parsedInput.responseId), - minPermission: "readWrite", - }, - ], - }); - - return await createResponseNote(parsedInput.responseId, ctx.user.id, parsedInput.text); - }); +export const deleteResponseAction = authenticatedActionClient.schema(ZDeleteResponseAction).action( + withAuditLogging( + "deleted", + "response", + async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => { + const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId); + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + { + type: "projectTeam", + projectId: await getProjectIdFromResponseId(parsedInput.responseId), + minPermission: "readWrite", + }, + ], + }); + ctx.auditLoggingCtx.organizationId = organizationId; + ctx.auditLoggingCtx.responseId = parsedInput.responseId; + const result = await deleteResponse(parsedInput.responseId); + ctx.auditLoggingCtx.oldObject = result; + return result; + } + ) +); const ZGetResponseAction = z.object({ responseId: ZId, diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/HiddenFields.test.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/HiddenFields.test.tsx new file mode 100644 index 000000000000..b509bd91de78 --- /dev/null +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/HiddenFields.test.tsx @@ -0,0 +1,70 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurveyHiddenFields } from "@formbricks/types/surveys/types"; +import { HiddenFields } from "./HiddenFields"; + +// Mock tooltip components to always render their children +vi.mock("@/modules/ui/components/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + TooltipContent: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + TooltipProvider: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
    {children}
    , +})); + +describe("HiddenFields", () => { + afterEach(() => { + cleanup(); + }); + + test("renders empty container when no fieldIds are provided", () => { + render( + + ); + const container = screen.getByTestId("main-hidden-fields-div"); + expect(container).toBeDefined(); + }); + + test("renders nothing for fieldIds with no corresponding response data", () => { + render( + + ); + expect(screen.queryByText("field1")).toBeNull(); + }); + + test("renders field and value when responseData exists and is a string", async () => { + render( + + ); + expect(screen.getByText("field1")).toBeInTheDocument(); + expect(screen.getByText("Value 1")).toBeInTheDocument(); + expect(screen.queryByText("field2")).toBeNull(); + }); + + test("renders empty text when responseData value is not a string", () => { + render( + + ); + expect(screen.getByText("field1")).toBeInTheDocument(); + const valueParagraphs = screen.getAllByText("", { selector: "p" }); + expect(valueParagraphs.length).toBeGreaterThan(0); + }); + + test("displays tooltip content for hidden field", async () => { + render( + + ); + expect(screen.getByText("common.hidden_field")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/HiddenFields.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/HiddenFields.tsx index 0cd17fbf98b5..06e9af31beeb 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/HiddenFields.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/HiddenFields.tsx @@ -15,7 +15,7 @@ export const HiddenFields = ({ hiddenFields, responseData }: HiddenFieldsProps) const { t } = useTranslate(); const fieldIds = hiddenFields.fieldIds ?? []; return ( -
    +
    {fieldIds.map((field) => { if (!responseData[field]) return; return ( diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/QuestionSkip.test.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/QuestionSkip.test.tsx new file mode 100644 index 000000000000..0257a368c06f --- /dev/null +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/QuestionSkip.test.tsx @@ -0,0 +1,98 @@ +import { parseRecallInfo } from "@/lib/utils/recall"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TSurveyQuestion } from "@formbricks/types/surveys/types"; +import { QuestionSkip } from "./QuestionSkip"; + +vi.mock("@/modules/ui/components/tooltip", () => ({ + Tooltip: ({ children }: any) =>
    {children}
    , + TooltipContent: ({ children }: any) =>
    {children}
    , + TooltipProvider: ({ children }: any) =>
    {children}
    , + TooltipTrigger: ({ children }: any) =>
    {children}
    , +})); + +vi.mock("@/modules/i18n/utils", () => ({ + getLocalizedValue: vi.fn((value, _) => value), +})); + +// Mock recall utils +vi.mock("@/lib/utils/recall", () => ({ + parseRecallInfo: vi.fn((headline, _) => { + return `parsed: ${headline}`; + }), +})); + +const dummyQuestions = [ + { id: "f1", headline: "headline1" }, + { id: "f2", headline: "headline2" }, +] as unknown as TSurveyQuestion[]; + +const dummyResponseData = { f1: "Answer 1", f2: "Answer 2" }; + +describe("QuestionSkip", () => { + afterEach(() => { + cleanup(); + }); + + test("renders nothing when skippedQuestions is falsy", () => { + render( + + ); + expect(screen.queryByText("headline1")).toBeNull(); + expect(screen.queryByText("headline2")).toBeNull(); + }); + + test("renders welcomeCard branch", () => { + render( + + ); + expect(screen.getByText("common.welcome_card")).toBeInTheDocument(); + }); + + test("renders skipped branch with tooltip and parsed headlines", () => { + vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline1"); + vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline2"); + + render( + + ); + // Check tooltip text from TooltipContent + expect(screen.getByTestId("tooltip-respondent_skipped_questions")).toBeInTheDocument(); + // Check mapping: parseRecallInfo should be called on each headline value, so expect the parsed text to appear. + expect(screen.getByText("parsed: headline1")).toBeInTheDocument(); + expect(screen.getByText("parsed: headline2")).toBeInTheDocument(); + }); + + test("renders aborted branch with closed message and parsed headlines", () => { + vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline1"); + vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline2"); + + render( + + ); + expect(screen.getByTestId("tooltip-survey_closed")).toBeInTheDocument(); + expect(screen.getByText("parsed: headline1")).toBeInTheDocument(); + expect(screen.getByText("parsed: headline2")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/QuestionSkip.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/QuestionSkip.tsx index 764c0aaff278..5cac1ae29022 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/QuestionSkip.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/QuestionSkip.tsx @@ -1,10 +1,10 @@ "use client"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { parseRecallInfo } from "@/lib/utils/recall"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { useTranslate } from "@tolgee/react"; import { CheckCircle2Icon, ChevronsDownIcon, XCircleIcon } from "lucide-react"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { parseRecallInfo } from "@formbricks/lib/utils/recall"; import { TResponseData } from "@formbricks/types/responses"; import { TSurveyQuestion } from "@formbricks/types/surveys/types"; @@ -60,27 +60,28 @@ export const QuestionSkip = ({ -

    {t("environments.surveys.responses.respondent_skipped_questions")}

    +

    + {t("environments.surveys.responses.respondent_skipped_questions")} +

    )}
    - {skippedQuestions && - skippedQuestions.map((questionId) => { - return ( -

    - {parseRecallInfo( - getLocalizedValue( - questions.find((question) => question.id === questionId)!.headline, - "default" - ), - responseData - )} -

    - ); - })} + {skippedQuestions?.map((questionId) => { + return ( +

    + {parseRecallInfo( + getLocalizedValue( + questions.find((question) => question.id === questionId)!.headline, + "default" + ), + responseData + )} +

    + ); + })}
    )} @@ -97,7 +98,9 @@ export const QuestionSkip = ({
    -

    +

    {t("environments.surveys.responses.survey_closed")}

    {skippedQuestions && diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.test.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.test.tsx new file mode 100644 index 000000000000..175d93ee8ba4 --- /dev/null +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.test.tsx @@ -0,0 +1,506 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { RenderResponse } from "./RenderResponse"; + +// Mocks for dependencies +vi.mock("@/modules/ui/components/rating-response", () => ({ + RatingResponse: ({ answer }: any) =>
    Rating: {answer}
    , +})); +vi.mock("@/modules/ui/components/file-upload-response", () => ({ + FileUploadResponse: ({ selected }: any) => ( +
    FileUpload: {selected.join(",")}
    + ), +})); +vi.mock("@/modules/ui/components/picture-selection-response", () => ({ + PictureSelectionResponse: ({ selected, isExpanded, showId }: any) => ( +
    + PictureSelection: {selected.join(",")} ({isExpanded ? "expanded" : "collapsed"}) showId:{" "} + {String(showId)} +
    + ), +})); +vi.mock("@/modules/ui/components/array-response", () => ({ + ArrayResponse: ({ value }: any) =>
    {value.join(",")}
    , +})); +vi.mock("@/modules/ui/components/response-badges", () => ({ + ResponseBadges: ({ items, showId }: any) => ( +
    + {Array.isArray(items) + ? items + .map((item) => (typeof item === "object" ? `${item.value}:${item.id || "no-id"}` : item)) + .join(",") + : items}{" "} + showId: {String(showId)} +
    + ), +})); +vi.mock("@/modules/ui/components/ranking-response", () => ({ + RankingResponse: ({ value, showId }: any) => ( +
    + {Array.isArray(value) + ? value + .map((item) => (typeof item === "object" ? `${item.value}:${item.id || "no-id"}` : item)) + .join(",") + : value}{" "} + showId: {String(showId)} +
    + ), +})); +vi.mock("@/modules/analysis/utils", () => ({ + renderHyperlinkedContent: vi.fn((text: string) => "hyper:" + text), +})); +vi.mock("@/lib/responses", () => ({ + processResponseData: (val: any) => "processed:" + val, +})); +vi.mock("@/lib/utils/datetime", () => ({ + formatDateWithOrdinal: (d: Date) => "formatted_" + d.toISOString(), +})); +vi.mock("@/lib/cn", () => ({ + cn: (...classes: (string | boolean | undefined)[]) => classes.filter(Boolean).join(" "), +})); +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: vi.fn((val, _) => val), + getLanguageCode: vi.fn().mockReturnValue("default"), +})); + +describe("RenderResponse", () => { + afterEach(() => { + cleanup(); + }); + + const defaultSurvey = { languages: [] } as any; + const defaultQuestion = { + id: "q1", + type: "Unknown", + choices: [ + { id: "choice1", label: { default: "Option 1" } }, + { id: "choice2", label: { default: "Option 2" } }, + ], + } as any; + const dummyLanguage = "default"; + + test("returns '-' for empty responseData (string)", () => { + const { container } = render( + + ); + expect(container.textContent).toBe("-"); + }); + + test("returns '-' for empty responseData (array)", () => { + const { container } = render( + + ); + expect(container.textContent).toBe("-"); + }); + + test("returns '-' for empty responseData (object)", () => { + const { container } = render( + + ); + expect(container.textContent).toBe("-"); + }); + + test("renders RatingResponse for 'Rating' question with number", () => { + const question = { ...defaultQuestion, type: "rating", scale: 5, range: [1, 5] }; + render( + + ); + expect(screen.getByTestId("RatingResponse")).toHaveTextContent("Rating: 4"); + }); + + test("renders formatted date for 'Date' question", () => { + const question = { ...defaultQuestion, type: "date" }; + const dateStr = new Date("2023-01-01T12:00:00Z").toISOString(); + render( + + ); + expect(screen.getByText(/formatted_/)).toBeInTheDocument(); + }); + + test("renders PictureSelectionResponse for 'PictureSelection' question", () => { + const question = { ...defaultQuestion, type: "pictureSelection", choices: ["a", "b"] }; + render( + + ); + expect(screen.getByTestId("PictureSelectionResponse")).toHaveTextContent( + "PictureSelection: choice1,choice2" + ); + }); + + test("renders FileUploadResponse for 'FileUpload' question", () => { + const question = { ...defaultQuestion, type: "fileUpload" }; + render( + + ); + expect(screen.getByTestId("FileUploadResponse")).toHaveTextContent("FileUpload: file1,file2"); + }); + + test("renders Matrix response", () => { + const question = { id: "q1", type: "matrix", rows: ["row1", "row2"] } as any; + // getLocalizedValue returns the row value itself + const responseData = { row1: "answer1", row2: "answer2" }; + render( + + ); + expect(screen.getByText("row1:processed:answer1")).toBeInTheDocument(); + expect(screen.getByText("row2:processed:answer2")).toBeInTheDocument(); + }); + + test("renders ArrayResponse for 'Address' question", () => { + const question = { ...defaultQuestion, type: "address" }; + render( + + ); + expect(screen.getByTestId("ArrayResponse")).toHaveTextContent("addr1,addr2"); + }); + + test("renders ResponseBadges for 'Cal' question (string)", () => { + const question = { ...defaultQuestion, type: "cal" }; + render( + + ); + expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Value"); + }); + + test("renders ResponseBadges for 'Consent' question (number)", () => { + const question = { ...defaultQuestion, type: "consent" }; + render( + + ); + expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("5"); + }); + + test("renders ResponseBadges for 'CTA' question (string)", () => { + const question = { ...defaultQuestion, type: "cta" }; + render( + + ); + expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Click"); + }); + + test("renders ResponseBadges for 'MultipleChoiceSingle' question (string)", () => { + const question = { ...defaultQuestion, type: "multipleChoiceSingle", choices: [] }; + render( + + ); + expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("option1"); + }); + + test("renders ResponseBadges for 'MultipleChoiceMulti' question (array)", () => { + const question = { ...defaultQuestion, type: "multipleChoiceMulti", choices: [] }; + render( + + ); + expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("opt1:other,opt2:other"); + }); + + test("renders ResponseBadges for 'NPS' question (number)", () => { + const question = { ...defaultQuestion, type: "nps" }; + render( + + ); + // NPS questions render as simple text, not ResponseBadges + expect(screen.getByText("9")).toBeInTheDocument(); + }); + + test("renders RankingResponse for 'Ranking' question", () => { + const question = { ...defaultQuestion, type: "ranking", choices: [] }; + render( + + ); + expect(screen.getByTestId("RankingResponse")).toHaveTextContent("first:other,second:other showId: false"); + }); + + test("renders default branch for unknown question type with string", () => { + const question = { ...defaultQuestion, type: "unknown" }; + render( + + ); + expect(screen.getByText("hyper:some text")).toBeInTheDocument(); + }); + + test("renders default branch for unknown question type with array", () => { + const question = { ...defaultQuestion, type: "unknown" }; + render( + + ); + expect(screen.getByText("a, b")).toBeInTheDocument(); + }); + + // New tests for showId functionality + test("passes showId prop to PictureSelectionResponse", () => { + const question = { + ...defaultQuestion, + type: "pictureSelection", + choices: [{ id: "choice1", imageUrl: "url1" }], + }; + render( + + ); + const component = screen.getByTestId("PictureSelectionResponse"); + expect(component).toHaveAttribute("data-show-id", "true"); + expect(component).toHaveTextContent("showId: true"); + }); + + test("passes showId prop to RankingResponse with choice ID extraction", () => { + const question = { + ...defaultQuestion, + type: "ranking", + choices: [ + { id: "choice1", label: { default: "Option 1" } }, + { id: "choice2", label: { default: "Option 2" } }, + ], + }; + render( + + ); + const component = screen.getByTestId("RankingResponse"); + expect(component).toHaveAttribute("data-show-id", "true"); + expect(component).toHaveTextContent("showId: true"); + // Should extract choice IDs and pass them as value objects + expect(component).toHaveTextContent("Option 1:choice1,Option 2:choice2"); + }); + + test("handles ranking response with missing choice IDs", () => { + const question = { + ...defaultQuestion, + type: "ranking", + choices: [ + { id: "choice1", label: { default: "Option 1" } }, + { id: "choice2", label: { default: "Option 2" } }, + ], + }; + render( + + ); + const component = screen.getByTestId("RankingResponse"); + expect(component).toHaveTextContent("Option 1:choice1,Unknown Option:other"); + }); + + test("passes showId prop to ResponseBadges for multiple choice single", () => { + const question = { + ...defaultQuestion, + type: "multipleChoiceSingle", + choices: [{ id: "choice1", label: { default: "Option 1" } }], + }; + render( + + ); + const component = screen.getByTestId("ResponseBadges"); + expect(component).toHaveAttribute("data-show-id", "true"); + expect(component).toHaveTextContent("showId: true"); + expect(component).toHaveTextContent("Option 1:choice1"); + }); + + test("passes showId prop to ResponseBadges for multiple choice multi", () => { + const question = { + ...defaultQuestion, + type: "multipleChoiceMulti", + choices: [ + { id: "choice1", label: { default: "Option 1" } }, + { id: "choice2", label: { default: "Option 2" } }, + ], + }; + render( + + ); + const component = screen.getByTestId("ResponseBadges"); + expect(component).toHaveAttribute("data-show-id", "true"); + expect(component).toHaveTextContent("showId: true"); + expect(component).toHaveTextContent("Option 1:choice1,Option 2:choice2"); + }); + + test("handles multiple choice with missing choice IDs", () => { + const question = { + ...defaultQuestion, + type: "multipleChoiceMulti", + choices: [{ id: "choice1", label: { default: "Option 1" } }], + }; + render( + + ); + const component = screen.getByTestId("ResponseBadges"); + expect(component).toHaveTextContent("Option 1:choice1,Unknown Option:other"); + }); + + test("passes showId=false to components when showId is false", () => { + const question = { + ...defaultQuestion, + type: "multipleChoiceMulti", + choices: [{ id: "choice1", label: { default: "Option 1" } }], + }; + render( + + ); + const component = screen.getByTestId("ResponseBadges"); + expect(component).toHaveAttribute("data-show-id", "false"); + expect(component).toHaveTextContent("showId: false"); + // Should still extract IDs but showId=false + expect(component).toHaveTextContent("Option 1:choice1"); + }); + + test("handles questions without choices property", () => { + const question = { ...defaultQuestion, type: "multipleChoiceSingle" }; // No choices property + render( + + ); + const component = screen.getByTestId("ResponseBadges"); + expect(component).toHaveTextContent("Option 1:choice1"); + }); +}); diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx index c1bd3ebcd43d..bde279305a52 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/RenderResponse.tsx @@ -1,17 +1,18 @@ +import { cn } from "@/lib/cn"; +import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils"; +import { getChoiceIdByValue } from "@/lib/response/utils"; +import { processResponseData } from "@/lib/responses"; +import { formatDateWithOrdinal } from "@/lib/utils/datetime"; +import { capitalizeFirstLetter } from "@/lib/utils/strings"; import { renderHyperlinkedContent } from "@/modules/analysis/utils"; import { ArrayResponse } from "@/modules/ui/components/array-response"; import { FileUploadResponse } from "@/modules/ui/components/file-upload-response"; import { PictureSelectionResponse } from "@/modules/ui/components/picture-selection-response"; -import { RankingRespone } from "@/modules/ui/components/ranking-response"; +import { RankingResponse } from "@/modules/ui/components/ranking-response"; import { RatingResponse } from "@/modules/ui/components/rating-response"; import { ResponseBadges } from "@/modules/ui/components/response-badges"; import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react"; import React from "react"; -import { cn } from "@formbricks/lib/cn"; -import { getLanguageCode, getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { processResponseData } from "@formbricks/lib/responses"; -import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime"; -import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings"; import { TSurvey, TSurveyMatrixQuestion, @@ -27,6 +28,7 @@ interface RenderResponseProps { survey: TSurvey; language: string | null; isExpanded?: boolean; + showId: boolean; } export const RenderResponse: React.FC = ({ @@ -35,6 +37,7 @@ export const RenderResponse: React.FC = ({ survey, language, isExpanded = true, + showId, }) => { if ( (typeof responseData === "string" && responseData === "") || @@ -81,6 +84,7 @@ export const RenderResponse: React.FC = ({ choices={(question as TSurveyPictureSelectionQuestion).choices} selected={responseData} isExpanded={isExpanded} + showId={showId} /> ); } @@ -101,7 +105,7 @@ export const RenderResponse: React.FC = ({ return (

    + className="ph-no-capture my-1 font-normal capitalize text-slate-700"> {rowValueInSelectedLanguage}:{processResponseData(responseData[rowValueInSelectedLanguage])}

    ); @@ -121,9 +125,10 @@ export const RenderResponse: React.FC = ({ if (typeof responseData === "string" || typeof responseData === "number") { return ( } + showId={showId} /> ); } @@ -132,9 +137,10 @@ export const RenderResponse: React.FC = ({ if (typeof responseData === "string" || typeof responseData === "number") { return ( } + showId={showId} /> ); } @@ -143,26 +149,43 @@ export const RenderResponse: React.FC = ({ if (typeof responseData === "string" || typeof responseData === "number") { return ( } + showId={showId} /> ); } break; case TSurveyQuestionTypeEnum.MultipleChoiceMulti: case TSurveyQuestionTypeEnum.MultipleChoiceSingle: - case TSurveyQuestionTypeEnum.NPS: + case TSurveyQuestionTypeEnum.Ranking: if (typeof responseData === "string" || typeof responseData === "number") { - return ; + const choiceId = getChoiceIdByValue(responseData.toString(), question); + return ( + + ); } else if (Array.isArray(responseData)) { - return ; + const itemsArray = responseData.map((choice) => { + const choiceId = getChoiceIdByValue(choice, question); + return { value: choice, id: choiceId }; + }); + return ( + <> + {questionType === TSurveyQuestionTypeEnum.Ranking ? ( + + ) : ( + + )} + + ); } break; - case TSurveyQuestionTypeEnum.Ranking: - if (Array.isArray(responseData)) { - return ; - } + default: if ( typeof responseData === "string" || diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseNote.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseNote.tsx deleted file mode 100644 index 5a0670b4adee..000000000000 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseNote.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"use client"; - -import { Button } from "@/modules/ui/components/button"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; -import { useTranslate } from "@tolgee/react"; -import clsx from "clsx"; -import { CheckIcon, PencilIcon, PlusIcon } from "lucide-react"; -import { Maximize2Icon, Minimize2Icon } from "lucide-react"; -import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; -import toast from "react-hot-toast"; -import { cn } from "@formbricks/lib/cn"; -import { timeSince } from "@formbricks/lib/time"; -import { TResponseNote } from "@formbricks/types/responses"; -import { TUser, TUserLocale } from "@formbricks/types/user"; -import { createResponseNoteAction, resolveResponseNoteAction, updateResponseNoteAction } from "../actions"; - -interface ResponseNotesProps { - user: TUser; - responseId: string; - notes: TResponseNote[]; - isOpen: boolean; - setIsOpen: (isOpen: boolean) => void; - updateFetchedResponses: () => void; - locale: TUserLocale; -} - -export const ResponseNotes = ({ - user, - responseId, - notes, - isOpen, - setIsOpen, - updateFetchedResponses, - locale, -}: ResponseNotesProps) => { - const { t } = useTranslate(); - const [noteText, setNoteText] = useState(""); - const [isCreatingNote, setIsCreatingNote] = useState(false); - const [isUpdatingNote, setIsUpdatingNote] = useState(false); - const [isTextAreaOpen, setIsTextAreaOpen] = useState(true); - const [noteId, setNoteId] = useState(""); - const divRef = useRef(null); - - const handleNoteSubmission = async (e: FormEvent) => { - e.preventDefault(); - setIsCreatingNote(true); - try { - await createResponseNoteAction({ responseId: responseId, text: noteText }); - updateFetchedResponses(); - setIsCreatingNote(false); - setNoteText(""); - } catch (e) { - toast.error(t("environments.surveys.responses.an_error_occurred_creating_a_new_note")); - setIsCreatingNote(false); - } - }; - - const handleResolveNote = (note: TResponseNote) => { - try { - resolveResponseNoteAction({ responseNoteId: note.id }); - // when this was the last note, close the notes panel - if (unresolvedNotes.length === 1) { - setIsOpen(false); - } - updateFetchedResponses(); - } catch (e) { - toast.error(t("environments.surveys.responses.an_error_occurred_resolving_a_note")); - setIsUpdatingNote(false); - } - }; - - const handleEditPencil = (note: TResponseNote) => { - setIsTextAreaOpen(true); - setNoteText(note.text); - setIsUpdatingNote(true); - setNoteId(note.id); - }; - - const handleNoteUpdate = async (e: FormEvent) => { - e.preventDefault(); - setIsUpdatingNote(true); - try { - await updateResponseNoteAction({ responseNoteId: noteId, text: noteText }); - updateFetchedResponses(); - setIsUpdatingNote(false); - setNoteText(""); - } catch (e) { - toast.error(t("environments.surveys.responses.an_error_occurred_updating_a_note")); - setIsUpdatingNote(false); - } - }; - - useEffect(() => { - if (divRef.current) { - divRef.current.scrollTop = divRef.current.scrollHeight; - } - }, [notes]); - - const unresolvedNotes = useMemo(() => notes.filter((note) => !note.isResolved), [notes]); - - return ( -
    { - if (!isOpen) setIsOpen(true); - }}> - {!isOpen ? ( -
    -
    - {!unresolvedNotes.length ? ( -
    -
    -

    {t("common.note")}

    -
    -
    - ) : ( -
    - -
    - )} -
    - {!unresolvedNotes.length ? ( -
    - - - -
    - ) : null} -
    - ) : ( -
    -
    -
    -
    -

    {t("common.note")}

    -
    - -
    -
    -
    - {unresolvedNotes.map((note) => ( -
    - - {note.user.name} - - {note.isEdited && ( - {"(edited)"} - )} - -
    - {note.text} - {user.id === note.user.id && ( - - )} - - - - - - - {t("environments.surveys.responses.resolve")} - - - -
    -
    - ))} -
    -
    -
    - -
    - -
    -
    - - {isTextAreaOpen && ( - - )} -
    - -
    -
    -
    - )} -
    - ); -}; diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseTagsWrapper.test.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseTagsWrapper.test.tsx new file mode 100644 index 000000000000..31cbe8bd440a --- /dev/null +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseTagsWrapper.test.tsx @@ -0,0 +1,252 @@ +import { TagError } from "@/modules/projects/settings/types/tag"; +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TTag } from "@formbricks/types/tags"; +import { createTagAction, createTagToResponseAction, deleteTagOnResponseAction } from "../actions"; +import { ResponseTagsWrapper } from "./ResponseTagsWrapper"; + +const dummyTags = [ + { tagId: "tag1", tagName: "Tag One" }, + { tagId: "tag2", tagName: "Tag Two" }, +]; +const dummyEnvironmentId = "env1"; +const dummyResponseId = "resp1"; +const dummyEnvironmentTags = [ + { id: "tag1", name: "Tag One" }, + { id: "tag2", name: "Tag Two" }, + { id: "tag3", name: "Tag Three" }, +] as TTag[]; +const dummyUpdateFetchedResponses = vi.fn(); +const dummyRouterPush = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: dummyRouterPush, + }), +})); + +vi.mock("@/lib/utils/helper", () => ({ + getFormattedErrorMessage: vi.fn((res) => res.error?.details[0].issue || "error"), +})); + +vi.mock("../actions", () => ({ + createTagAction: vi.fn(), + createTagToResponseAction: vi.fn(), + deleteTagOnResponseAction: vi.fn(), +})); + +// Mock Button, Tag and TagsCombobox components +vi.mock("@/modules/ui/components/button", () => ({ + Button: (props: any) => , +})); +vi.mock("@/modules/ui/components/tag", () => ({ + Tag: (props: any) => ( +
    + {props.tagName} + {props.allowDelete && } +
    + ), +})); +vi.mock("@/modules/ui/components/tags-combobox", () => ({ + TagsCombobox: (props: any) => ( +
    + + +
    + ), +})); + +describe("ResponseTagsWrapper", () => { + afterEach(() => { + cleanup(); + }); + + test("renders settings button when not readOnly and navigates on click", async () => { + render( + + ); + const settingsButton = screen.getByRole("button", { name: "" }); + await userEvent.click(settingsButton); + expect(dummyRouterPush).toHaveBeenCalledWith(`/environments/${dummyEnvironmentId}/project/tags`); + }); + + test("does not render settings button when readOnly", () => { + render( + + ); + expect(screen.queryByRole("button")).toBeNull(); + }); + + test("renders provided tags", () => { + render( + + ); + expect(screen.getAllByTestId("tag").length).toBe(2); + expect(screen.getByText("Tag One")).toBeInTheDocument(); + expect(screen.getByText("Tag Two")).toBeInTheDocument(); + }); + + test("calls deleteTagOnResponseAction on tag delete success", async () => { + vi.mocked(deleteTagOnResponseAction).mockResolvedValueOnce({ data: "deleted" } as any); + render( + + ); + const deleteButtons = screen.getAllByText("Delete"); + await userEvent.click(deleteButtons[0]); + await waitFor(() => { + expect(deleteTagOnResponseAction).toHaveBeenCalledWith({ responseId: dummyResponseId, tagId: "tag1" }); + expect(dummyUpdateFetchedResponses).toHaveBeenCalled(); + }); + }); + + test("shows toast error on deleteTagOnResponseAction error", async () => { + vi.mocked(deleteTagOnResponseAction).mockRejectedValueOnce(new Error("delete error")); + render( + + ); + const deleteButtons = screen.getAllByText("Delete"); + await userEvent.click(deleteButtons[0]); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + "environments.surveys.responses.an_error_occurred_deleting_the_tag" + ); + }); + }); + + test("creates a new tag via TagsCombobox and calls updateFetchedResponses on success", async () => { + vi.mocked(createTagAction).mockResolvedValueOnce({ + data: { ok: true, data: { id: "newTagId", name: "NewTag" } }, + } as any); + vi.mocked(createTagToResponseAction).mockResolvedValueOnce({ data: "tagAdded" } as any); + render( + + ); + const createButton = screen.getByTestId("tags-combobox").querySelector("button"); + await userEvent.click(createButton!); + await waitFor(() => { + expect(createTagAction).toHaveBeenCalledWith({ environmentId: dummyEnvironmentId, tagName: "NewTag" }); + expect(createTagToResponseAction).toHaveBeenCalledWith({ + responseId: dummyResponseId, + tagId: "newTagId", + }); + expect(dummyUpdateFetchedResponses).toHaveBeenCalled(); + }); + }); + + test("handles createTagAction failure and shows toast error", async () => { + vi.mocked(createTagAction).mockResolvedValueOnce({ + data: { + ok: false, + error: { message: "Unique constraint failed on the fields", code: TagError.TAG_NAME_ALREADY_EXISTS }, + }, + } as any); + render( + + ); + const createButton = screen.getByTestId("tags-combobox").querySelector("button"); + await userEvent.click(createButton!); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.tag_already_exists", { + duration: 2000, + icon: expect.anything(), + }); + }); + }); + + test("calls addTag correctly via TagsCombobox", async () => { + vi.mocked(createTagToResponseAction).mockResolvedValueOnce({ data: "tagAdded" } as any); + render( + + ); + const addButton = screen.getByTestId("tags-combobox").querySelectorAll("button")[1]; + await userEvent.click(addButton); + await waitFor(() => { + expect(createTagToResponseAction).toHaveBeenCalledWith({ responseId: dummyResponseId, tagId: "tag3" }); + expect(dummyUpdateFetchedResponses).toHaveBeenCalled(); + }); + }); + + test("clears tagIdToHighlight after timeout", async () => { + vi.useFakeTimers(); + + render( + + ); + // We simulate that tagIdToHighlight is set (simulate via setState if possible) + // Here we directly invoke the effect by accessing component instance is not trivial in RTL; + // Instead, we manually advance timers to ensure cleanup timeout is executed. + + await act(async () => { + vi.advanceTimersByTime(2000); + }); + + // No error expected; test passes if timer runs without issue. + expect(true).toBe(true); + }); +}); diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseTagsWrapper.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseTagsWrapper.tsx index 700e0df57a11..408916bb4840 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseTagsWrapper.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseTagsWrapper.tsx @@ -1,6 +1,7 @@ "use client"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { TagError } from "@/modules/projects/settings/types/tag"; import { Button } from "@/modules/ui/components/button"; import { Tag } from "@/modules/ui/components/tag"; import { TagsCombobox } from "@/modules/ui/components/tags-combobox"; @@ -8,7 +9,7 @@ import { useTranslate } from "@tolgee/react"; import { AlertCircleIcon, SettingsIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import React, { useEffect, useState } from "react"; -import { toast } from "react-hot-toast"; +import toast from "react-hot-toast"; import { TTag } from "@formbricks/types/tags"; import { createTagAction, createTagToResponseAction, deleteTagOnResponseAction } from "../actions"; @@ -58,6 +59,60 @@ export const ResponseTagsWrapper: React.FC = ({ return () => clearTimeout(timeoutId); }, [tagIdToHighlight]); + const handleCreateTag = async (tagName: string) => { + setOpen(false); + + const createTagResponse = await createTagAction({ + environmentId, + tagName: tagName?.trim() ?? "", + }); + + if (createTagResponse?.data?.ok) { + const tag = createTagResponse.data.data; + setTagsState((prevTags) => [ + ...prevTags, + { + tagId: tag.id, + tagName: tag.name, + }, + ]); + + const createTagToResponseActionResponse = await createTagToResponseAction({ + responseId, + tagId: tag.id, + }); + + if (createTagToResponseActionResponse?.data) { + updateFetchedResponses(); + setSearchValue(""); + } else { + const errorMessage = getFormattedErrorMessage(createTagToResponseActionResponse); + toast.error(errorMessage); + } + + return; + } + + if ( + createTagResponse?.data?.ok === false && + createTagResponse?.data?.error?.code === TagError.TAG_NAME_ALREADY_EXISTS + ) { + toast.error(t("environments.surveys.responses.tag_already_exists"), { + duration: 2000, + icon: , + }); + + setSearchValue(""); + return; + } + + const errorMessage = getFormattedErrorMessage(createTagResponse); + toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), { + duration: 2000, + }); + setSearchValue(""); + }; + return (
    {!isReadOnly && ( @@ -93,46 +148,7 @@ export const ResponseTagsWrapper: React.FC = ({ setSearchValue={setSearchValue} tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []} currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))} - createTag={async (tagName) => { - setOpen(false); - - const createTagResponse = await createTagAction({ - environmentId, - tagName: tagName?.trim() ?? "", - }); - if (createTagResponse?.data) { - setTagsState((prevTags) => [ - ...prevTags, - { - tagId: createTagResponse.data?.id ?? "", - tagName: createTagResponse.data?.name ?? "", - }, - ]); - const createTagToResponseActionResponse = await createTagToResponseAction({ - responseId, - tagId: createTagResponse.data.id, - }); - - if (createTagToResponseActionResponse?.data) { - updateFetchedResponses(); - setSearchValue(""); - } - } else { - const errorMessage = getFormattedErrorMessage(createTagResponse); - if (errorMessage.includes("Unique constraint failed on the fields")) { - toast.error(t("environments.surveys.responses.tag_already_exists"), { - duration: 2000, - icon: , - }); - } else { - toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), { - duration: 2000, - }); - } - - setSearchValue(""); - } - }} + createTag={handleCreateTag} addTag={(tagId) => { setTagsState((prevTags) => [ ...prevTags, diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseVariables.test.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseVariables.test.tsx new file mode 100644 index 000000000000..94a7a36e2c6e --- /dev/null +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/ResponseVariables.test.tsx @@ -0,0 +1,80 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TResponseVariables } from "@formbricks/types/responses"; +import { TSurveyVariables } from "@formbricks/types/surveys/types"; +import { ResponseVariables } from "./ResponseVariables"; + +const dummyVariables = [ + { id: "v1", name: "Variable One", type: "number" }, + { id: "v2", name: "Variable Two", type: "string" }, + { id: "v3", name: "Variable Three", type: "object" }, +] as unknown as TSurveyVariables; + +const dummyVariablesData = { + v1: 123, + v2: "abc", + v3: { not: "valid" }, +} as unknown as TResponseVariables; + +// Mock tooltip components +vi.mock("@/modules/ui/components/tooltip", () => ({ + Tooltip: ({ children }: any) =>
    {children}
    , + TooltipContent: ({ children }: any) =>
    {children}
    , + TooltipProvider: ({ children }: any) =>
    {children}
    , + TooltipTrigger: ({ children }: any) =>
    {children}
    , +})); + +// Mock useTranslate +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ t: (key: string) => key }), +})); + +// Mock i18n utils +vi.mock("@/modules/i18n/utils", () => ({ + getLocalizedValue: vi.fn((val, _) => val), + getLanguageCode: vi.fn().mockReturnValue("default"), +})); + +// Mock lucide-react icons to render identifiable elements +vi.mock("lucide-react", () => ({ + FileDigitIcon: () =>
    , + FileType2Icon: () =>
    , +})); + +describe("ResponseVariables", () => { + afterEach(() => { + cleanup(); + }); + + test("renders nothing when no variable in variablesData meets type check", () => { + render( + + ); + expect(screen.queryByText("Variable One")).toBeNull(); + expect(screen.queryByText("Variable Two")).toBeNull(); + }); + + test("renders variables with valid response data", () => { + render(); + expect(screen.getByText("Variable One")).toBeInTheDocument(); + expect(screen.getByText("Variable Two")).toBeInTheDocument(); + // Check that the value is rendered + expect(screen.getByText("123")).toBeInTheDocument(); + expect(screen.getByText("abc")).toBeInTheDocument(); + }); + + test("renders FileDigitIcon for number type and FileType2Icon for string type", () => { + render(); + expect(screen.getByTestId("FileDigitIcon")).toBeInTheDocument(); + expect(screen.getByTestId("FileType2Icon")).toBeInTheDocument(); + }); + + test("displays tooltip content with 'common.variable'", () => { + render(); + // TooltipContent mock always renders its children directly. + expect(screen.getAllByText("common.variable")[0]).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.test.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.test.tsx new file mode 100644 index 000000000000..866619c9ca5b --- /dev/null +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.test.tsx @@ -0,0 +1,125 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { SingleResponseCardBody } from "./SingleResponseCardBody"; + +// Mocks for imported components to return identifiable elements +vi.mock("./QuestionSkip", () => ({ + QuestionSkip: (props: any) =>
    {props.status}
    , +})); +vi.mock("./RenderResponse", () => ({ + RenderResponse: (props: any) =>
    {props.responseData.toString()}
    , +})); +vi.mock("./ResponseVariables", () => ({ + ResponseVariables: (props: any) =>
    Variables
    , +})); +vi.mock("./HiddenFields", () => ({ + HiddenFields: (props: any) =>
    Hidden
    , +})); +vi.mock("./VerifiedEmail", () => ({ + VerifiedEmail: (props: any) =>
    VerifiedEmail
    , +})); + +// Mocks for utility functions used inside component +vi.mock("@/lib/utils/recall", () => ({ + parseRecallInfo: vi.fn((headline, data) => "parsed:" + headline), +})); +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: vi.fn((headline) => headline), +})); +vi.mock("../util", () => ({ + isValidValue: (val: any) => { + if (typeof val === "string") return val.trim() !== ""; + if (Array.isArray(val)) return val.length > 0; + if (typeof val === "number") return true; + if (typeof val === "object") return Object.keys(val).length > 0; + return false; + }, +})); +// Mock CheckCircle2Icon from lucide-react +vi.mock("lucide-react", () => ({ + CheckCircle2Icon: () =>
    CheckCircle
    , +})); + +describe("SingleResponseCardBody", () => { + afterEach(() => { + cleanup(); + }); + + const dummySurvey = { + welcomeCard: { enabled: true }, + isVerifyEmailEnabled: true, + questions: [ + { id: "q1", headline: "headline1" }, + { id: "q2", headline: "headline2" }, + ], + variables: [{ id: "var1", name: "Variable1", type: "string" }], + hiddenFields: { enabled: true, fieldIds: ["hf1"] }, + } as unknown as TSurvey; + const dummyResponse = { + id: "resp1", + finished: true, + data: { q1: "answer1", q2: "", verifiedEmail: true, hf1: "hiddenVal" }, + variables: { var1: "varValue" }, + language: "en", + } as unknown as TResponse; + + test("renders welcomeCard branch when enabled", () => { + render(); + expect(screen.getAllByTestId("QuestionSkip")[0]).toHaveTextContent("welcomeCard"); + }); + + test("renders VerifiedEmail when enabled and response verified", () => { + render(); + expect(screen.getByTestId("VerifiedEmail")).toBeInTheDocument(); + }); + + test("renders RenderResponse for valid answer", () => { + const surveyCopy = { ...dummySurvey, welcomeCard: { enabled: false } } as TSurvey; + const responseCopy = { ...dummyResponse, data: { q1: "answer1", q2: "" } }; + render(); + // For question q1 answer is valid so RenderResponse is rendered + expect(screen.getByTestId("RenderResponse")).toHaveTextContent("answer1"); + }); + + test("renders QuestionSkip for invalid answer", () => { + const surveyCopy = { ...dummySurvey, welcomeCard: { enabled: false } } as TSurvey; + const responseCopy = { ...dummyResponse, data: { q1: "", q2: "" } }; + render( + + ); + // Renders QuestionSkip for q1 or q2 branch + expect(screen.getAllByTestId("QuestionSkip")[1]).toBeInTheDocument(); + }); + + test("renders ResponseVariables when variables exist", () => { + render(); + expect(screen.getByTestId("ResponseVariables")).toBeInTheDocument(); + }); + + test("renders HiddenFields when hiddenFields enabled", () => { + render(); + expect(screen.getByTestId("HiddenFields")).toBeInTheDocument(); + }); + + test("renders completion indicator when response finished", () => { + render(); + expect(screen.getByTestId("CheckCircle2Icon")).toBeInTheDocument(); + expect(screen.getByText("common.completed")).toBeInTheDocument(); + }); + + test("processes question mapping correctly with skippedQuestions modification", () => { + // Provide one question valid and one not valid, with skippedQuestions for the invalid one. + const surveyCopy = { ...dummySurvey, welcomeCard: { enabled: false } } as TSurvey; + const responseCopy = { ...dummyResponse, data: { q1: "answer1", q2: "" } }; + // Initially, skippedQuestions contains ["q2"]. + render( + + ); + // For q1, RenderResponse is rendered since answer valid. + expect(screen.getByTestId("RenderResponse")).toBeInTheDocument(); + // For q2, QuestionSkip is rendered. Our mock for QuestionSkip returns text "skipped". + expect(screen.getByText("skipped")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx index aaaebd1b02be..db23dee7a1a9 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx @@ -1,9 +1,9 @@ "use client"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { parseRecallInfo } from "@/lib/utils/recall"; import { useTranslate } from "@tolgee/react"; import { CheckCircle2Icon } from "lucide-react"; -import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; -import { parseRecallInfo } from "@formbricks/lib/utils/recall"; import { TResponse } from "@formbricks/types/responses"; import { TSurvey } from "@formbricks/types/surveys/types"; import { isValidValue } from "../util"; @@ -76,7 +76,7 @@ export const SingleResponseCardBody = ({
    {isValidValue(response.data[question.id]) ? (
    -

    +

    {formatTextWithSlashes( parseRecallInfo( getLocalizedValue(question.headline, "default"), @@ -92,6 +92,7 @@ export const SingleResponseCardBody = ({ survey={survey} responseData={response.data[question.id]} language={response.language} + showId={true} />

    diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardHeader.test.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardHeader.test.tsx new file mode 100644 index 000000000000..c0817faed96c --- /dev/null +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardHeader.test.tsx @@ -0,0 +1,159 @@ +import { isSubmissionTimeMoreThan5Minutes } from "@/modules/analysis/components/SingleResponseCard/util"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUser } from "@formbricks/types/user"; +import { SingleResponseCardHeader } from "./SingleResponseCardHeader"; + +// Mocks +vi.mock("@/modules/ui/components/avatars", () => ({ + PersonAvatar: ({ personId }: any) =>
    Avatar: {personId}
    , +})); +vi.mock("@/modules/ui/components/survey-status-indicator", () => ({ + SurveyStatusIndicator: ({ status }: any) =>
    Status: {status}
    , +})); +vi.mock("@/modules/ui/components/tooltip", () => ({ + Tooltip: ({ children }: any) =>
    {children}
    , + TooltipContent: ({ children }: any) =>
    {children}
    , + TooltipProvider: ({ children }: any) =>
    {children}
    , + TooltipTrigger: ({ children }: any) =>
    {children}
    , +})); +vi.mock("@formbricks/i18n-utils/src/utils", () => ({ + getLanguageLabel: vi.fn(), +})); +vi.mock("@/modules/lib/time", () => ({ + timeSince: vi.fn(() => "5 minutes ago"), +})); +vi.mock("@/modules/lib/utils/contact", () => ({ + getContactIdentifier: vi.fn((contact, attributes) => attributes?.email || contact?.userId || ""), +})); +vi.mock("../util", () => ({ + isSubmissionTimeMoreThan5Minutes: vi.fn(), +})); + +describe("SingleResponseCardHeader", () => { + afterEach(() => { + cleanup(); + }); + + const dummySurvey = { + id: "survey1", + name: "Test Survey", + environmentId: "env1", + } as TSurvey; + const dummyResponse = { + id: "resp1", + finished: false, + updatedAt: new Date("2023-01-01T12:00:00Z"), + createdAt: new Date("2023-01-01T11:00:00Z"), + language: "en", + contact: { id: "contact1", name: "Alice" }, + contactAttributes: { attr: "value" }, + meta: { + userAgent: { browser: "Chrome", os: "Windows", device: "PC" }, + url: "http://example.com", + action: "click", + source: "web", + country: "USA", + }, + singleUseId: "su123", + } as unknown as TResponse; + const dummyEnvironment = { id: "env1" } as TEnvironment; + const dummyUser = { id: "user1", email: "user1@example.com" } as TUser; + const dummyLocale = "en-US"; + + test("renders response view with contact (user exists)", () => { + vi.mocked(isSubmissionTimeMoreThan5Minutes).mockReturnValue(true); + render( + + ); + // Expect Link wrapping PersonAvatar and display identifier + expect(screen.getByTestId("PersonAvatar")).toHaveTextContent("Avatar: contact1"); + expect(screen.getByRole("link")).toBeInTheDocument(); + }); + + test("renders response view with no contact (anonymous)", () => { + const responseNoContact = { ...dummyResponse, contact: null }; + render( + + ); + expect(screen.getByText("common.anonymous")).toBeInTheDocument(); + }); + + test("renders people view", () => { + render( + + ); + expect(screen.getByRole("link")).toBeInTheDocument(); + expect(screen.getByText("Test Survey")).toBeInTheDocument(); + expect(screen.getByTestId("SurveyStatusIndicator")).toBeInTheDocument(); + }); + + test("renders enabled trash icon and handles click", async () => { + vi.mocked(isSubmissionTimeMoreThan5Minutes).mockReturnValue(true); + const setDeleteDialogOpen = vi.fn(); + render( + + ); + const trashIcon = screen.getByLabelText("Delete response"); + await userEvent.click(trashIcon); + expect(setDeleteDialogOpen).toHaveBeenCalledWith(true); + }); + + test("renders disabled trash icon when deletion not allowed", async () => { + vi.mocked(isSubmissionTimeMoreThan5Minutes).mockReturnValue(false); + render( + + ); + const disabledTrash = screen.getByLabelText("Cannot delete response in progress"); + expect(disabledTrash).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardHeader.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardHeader.tsx index eeedfd492ceb..b773940a2334 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardHeader.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardHeader.tsx @@ -1,15 +1,16 @@ "use client"; +import { timeSince } from "@/lib/time"; +import { getContactIdentifier } from "@/lib/utils/contact"; import { PersonAvatar } from "@/modules/ui/components/avatars"; +import { IdBadge } from "@/modules/ui/components/id-badge"; import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { useTranslate } from "@tolgee/react"; import { LanguagesIcon, TrashIcon } from "lucide-react"; import Link from "next/link"; import { ReactNode } from "react"; -import { getLanguageLabel } from "@formbricks/lib/i18n/utils"; -import { timeSince } from "@formbricks/lib/time"; -import { getContactIdentifier } from "@formbricks/lib/utils/contact"; +import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils"; import { TEnvironment } from "@formbricks/types/environment"; import { TResponse } from "@formbricks/types/responses"; import { TSurvey } from "@formbricks/types/surveys/types"; @@ -162,19 +163,21 @@ export const SingleResponseCardHeader = ({ {response.contact?.id ? ( user ? (

    {displayIdentifier}

    + {response.contact.userId && } ) : ( -
    +

    {displayIdentifier}

    + {response.contact.userId && }
    ) ) : ( diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx deleted file mode 100644 index 6e45fc5877d4..000000000000 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx +++ /dev/null @@ -1,462 +0,0 @@ -export const TiredFace: React.FC> = (props) => { - return ( - - - - - - - - - - - ); -}; - -export const WearyFace: React.FC> = (props) => { - return ( - - - - - - - - - - - ); -}; - -export const PerseveringFace: React.FC> = (props) => { - return ( - - - - - - - - - - - ); -}; - -export const FrowningFace: React.FC> = (props) => { - return ( - - - - - - - - - ); -}; - -export const ConfusedFace: React.FC> = (props) => { - return ( - - - - - - - - - ); -}; - -export const NeutralFace: React.FC> = (props) => { - return ( - - - - - - - - - ); -}; - -export const SlightlySmilingFace: React.FC> = (props) => { - return ( - - - - - - - - - ); -}; - -export const SmilingFaceWithSmilingEyes: React.FC> = (props) => { - return ( - - - - - - - - - ); -}; - -export const GrinningFaceWithSmilingEyes: React.FC> = (props) => { - return ( - - - - - - - - - - ); -}; - -export const GrinningSquintingFace: React.FC> = (props) => { - return ( - - - - - - - - - - - ); -}; diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/VerifiedEmail.test.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/VerifiedEmail.test.tsx new file mode 100644 index 000000000000..092d802139c3 --- /dev/null +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/VerifiedEmail.test.tsx @@ -0,0 +1,31 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { VerifiedEmail } from "./VerifiedEmail"; + +vi.mock("lucide-react", () => ({ + MailIcon: (props: any) => ( +
    + MailIcon +
    + ), +})); + +describe("VerifiedEmail", () => { + afterEach(() => { + cleanup(); + }); + + test("renders verified email text and value when provided", () => { + render(); + expect(screen.getByText("common.verified_email")).toBeInTheDocument(); + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + expect(screen.getByTestId("MailIcon")).toBeInTheDocument(); + }); + + test("renders empty value when verifiedEmail is not a string", () => { + render(); + expect(screen.getByText("common.verified_email")).toBeInTheDocument(); + const emptyParagraph = screen.getByText("", { selector: "p.ph-no-capture" }); + expect(emptyParagraph.textContent).toBe(""); + }); +}); diff --git a/apps/web/modules/analysis/components/SingleResponseCard/index.test.tsx b/apps/web/modules/analysis/components/SingleResponseCard/index.test.tsx new file mode 100644 index 000000000000..c2eabe2b0870 --- /dev/null +++ b/apps/web/modules/analysis/components/SingleResponseCard/index.test.tsx @@ -0,0 +1,180 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import toast from "react-hot-toast"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TResponse } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUser } from "@formbricks/types/user"; +import { deleteResponseAction, getResponseAction } from "./actions"; +import { SingleResponseCard } from "./index"; + +// Dummy data for props +const dummySurvey = { + id: "survey1", + environmentId: "env1", + name: "Test Survey", + status: "completed", + type: "link", + questions: [{ id: "q1" }, { id: "q2" }], + responseCount: 10, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +} as unknown as TSurvey; +const dummyResponse = { + id: "resp1", + finished: true, + data: { q1: "answer1", q2: null }, + tags: [], +} as unknown as TResponse; +const dummyEnvironment = { id: "env1" } as TEnvironment; +const dummyUser = { id: "user1", email: "user1@example.com", name: "User One" } as TUser; +const dummyLocale = "en-US"; + +const dummyDeleteResponses = vi.fn(); +const dummyUpdateResponse = vi.fn(); +const dummySetSelectedResponseId = vi.fn(); + +// Mock internal components to return identifiable elements +vi.mock("./components/SingleResponseCardHeader", () => ({ + SingleResponseCardHeader: (props: any) => ( +
    + +
    + ), +})); +vi.mock("./components/SingleResponseCardBody", () => ({ + SingleResponseCardBody: () =>
    Body Content
    , +})); +vi.mock("./components/ResponseTagsWrapper", () => ({ + ResponseTagsWrapper: (props: any) => ( +
    + +
    + ), +})); +vi.mock("@/modules/ui/components/delete-dialog", () => ({ + DeleteDialog: ({ open, onDelete }: any) => + open ? ( + + ) : null, +})); +vi.mock("./actions", () => ({ + deleteResponseAction: vi.fn().mockResolvedValue("deletedResponse"), + getResponseAction: vi.fn(), +})); + +vi.mock("./util", () => ({ + isValidValue: (value: any) => value !== null && value !== undefined, +})); + +describe("SingleResponseCard", () => { + afterEach(() => { + cleanup(); + }); + + test("renders as a plain div when survey is draft and isReadOnly", () => { + const draftSurvey = { ...dummySurvey, status: "draft" } as TSurvey; + render( + + ); + + expect(screen.getByTestId("SingleResponseCardHeader")).toBeInTheDocument(); + expect(screen.queryByRole("link")).toBeNull(); + }); + + test("calls deleteResponseAction and refreshes router on successful deletion", async () => { + render( + + ); + + userEvent.click(screen.getByText("Open Delete")); + + const deleteButton = await screen.findByTestId("DeleteDialog"); + await userEvent.click(deleteButton); + await waitFor(() => { + expect(deleteResponseAction).toHaveBeenCalledWith({ responseId: dummyResponse.id }); + }); + + expect(dummyDeleteResponses).toHaveBeenCalledWith([dummyResponse.id]); + }); + + test("calls toast.error when deleteResponseAction throws error", async () => { + vi.mocked(deleteResponseAction).mockRejectedValueOnce(new Error("Delete failed")); + render( + + ); + await userEvent.click(screen.getByText("Open Delete")); + const deleteButton = await screen.findByTestId("DeleteDialog"); + await userEvent.click(deleteButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Delete failed"); + }); + }); + + test("calls updateResponse when getResponseAction returns updated response", async () => { + vi.mocked(getResponseAction).mockResolvedValueOnce({ data: { updated: true } as any }); + render( + + ); + + expect(screen.getByTestId("ResponseTagsWrapper")).toBeInTheDocument(); + + await userEvent.click(screen.getByText("Update Responses")); + + await waitFor(() => { + expect(getResponseAction).toHaveBeenCalledWith({ responseId: dummyResponse.id }); + }); + + await waitFor(() => { + expect(dummyUpdateResponse).toHaveBeenCalledWith(dummyResponse.id, { updated: true }); + }); + }); +}); diff --git a/apps/web/modules/analysis/components/SingleResponseCard/index.tsx b/apps/web/modules/analysis/components/SingleResponseCard/index.tsx index 64cbe02ea62b..5c589d853f57 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/index.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/index.tsx @@ -2,19 +2,15 @@ import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { useTranslate } from "@tolgee/react"; -import clsx from "clsx"; import { useRouter } from "next/navigation"; import { useState } from "react"; import toast from "react-hot-toast"; -import { cn } from "@formbricks/lib/cn"; import { TEnvironment } from "@formbricks/types/environment"; import { TResponse } from "@formbricks/types/responses"; import { TSurvey } from "@formbricks/types/surveys/types"; import { TTag } from "@formbricks/types/tags"; -import { TUser } from "@formbricks/types/user"; -import { TUserLocale } from "@formbricks/types/user"; +import { TUser, TUserLocale } from "@formbricks/types/user"; import { deleteResponseAction, getResponseAction } from "./actions"; -import { ResponseNotes } from "./components/ResponseNote"; import { ResponseTagsWrapper } from "./components/ResponseTagsWrapper"; import { SingleResponseCardBody } from "./components/SingleResponseCardBody"; import { SingleResponseCardHeader } from "./components/SingleResponseCardHeader"; @@ -24,7 +20,6 @@ interface SingleResponseCardProps { survey: TSurvey; response: TResponse; user?: TUser; - pageType: "people" | "response"; environmentTags: TTag[]; environment: TEnvironment; updateResponse?: (responseId: string, responses: TResponse) => void; @@ -38,7 +33,6 @@ export const SingleResponseCard = ({ survey, response, user, - pageType, environmentTags, environment, updateResponse, @@ -52,7 +46,6 @@ export const SingleResponseCard = ({ const router = useRouter(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const [isOpen, setIsOpen] = useState(false); let skippedQuestions: string[][] = []; let temp: string[] = []; @@ -61,28 +54,24 @@ export const SingleResponseCard = ({ survey.questions.forEach((question) => { if (!isValidValue(response.data[question.id])) { temp.push(question.id); - } else { - if (temp.length > 0) { - skippedQuestions.push([...temp]); - temp = []; - } + } else if (temp.length > 0) { + skippedQuestions.push([...temp]); + temp = []; } }); } else { for (let index = survey.questions.length - 1; index >= 0; index--) { const question = survey.questions[index]; - if (!response.data[question.id]) { - if (skippedQuestions.length === 0) { - temp.push(question.id); - } else if (skippedQuestions.length > 0 && !isValidValue(response.data[question.id])) { - temp.push(question.id); - } - } else { - if (temp.length > 0) { - temp.reverse(); - skippedQuestions.push([...temp]); - temp = []; - } + if ( + !response.data[question.id] && + (skippedQuestions.length === 0 || + (skippedQuestions.length > 0 && !isValidValue(response.data[question.id]))) + ) { + temp.push(question.id); + } else if (temp.length > 0) { + temp.reverse(); + skippedQuestions.push([...temp]); + temp = []; } } } @@ -118,17 +107,8 @@ export const SingleResponseCard = ({ }; return ( -
    -
    +
    +
    - {user && pageType === "response" && ( - - )}
    ); }; diff --git a/apps/web/modules/analysis/components/SingleResponseCard/util.test.ts b/apps/web/modules/analysis/components/SingleResponseCard/util.test.ts new file mode 100644 index 000000000000..ebfc8f55303f --- /dev/null +++ b/apps/web/modules/analysis/components/SingleResponseCard/util.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; +import { isSubmissionTimeMoreThan5Minutes, isValidValue } from "./util"; + +describe("isValidValue", () => { + test("returns false for an empty string", () => { + expect(isValidValue("")).toBe(false); + }); + + test("returns false for a blank string", () => { + expect(isValidValue(" ")).toBe(false); + }); + + test("returns true for a non-empty string", () => { + expect(isValidValue("hello")).toBe(true); + }); + + test("returns true for numbers", () => { + expect(isValidValue(0)).toBe(true); + expect(isValidValue(42)).toBe(true); + }); + + test("returns false for an empty array", () => { + expect(isValidValue([])).toBe(false); + }); + + test("returns true for a non-empty array", () => { + expect(isValidValue(["item"])).toBe(true); + }); + + test("returns false for an empty object", () => { + expect(isValidValue({})).toBe(false); + }); + + test("returns true for a non-empty object", () => { + expect(isValidValue({ key: "value" })).toBe(true); + }); +}); + +describe("isSubmissionTimeMoreThan5Minutes", () => { + test("returns true if submission time is more than 5 minutes ago", () => { + const currentTime = new Date(); + const oldTime = new Date(currentTime.getTime() - 6 * 60 * 1000); // 6 minutes ago + expect(isSubmissionTimeMoreThan5Minutes(oldTime)).toBe(true); + }); + + test("returns false if submission time is less than or equal to 5 minutes ago", () => { + const currentTime = new Date(); + const recentTime = new Date(currentTime.getTime() - 4 * 60 * 1000); // 4 minutes ago + expect(isSubmissionTimeMoreThan5Minutes(recentTime)).toBe(false); + }); +}); diff --git a/apps/web/modules/analysis/utils.test.tsx b/apps/web/modules/analysis/utils.test.tsx new file mode 100644 index 000000000000..424ef6f46113 --- /dev/null +++ b/apps/web/modules/analysis/utils.test.tsx @@ -0,0 +1,75 @@ +import { cleanup } from "@testing-library/react"; +import { isValidElement } from "react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { renderHyperlinkedContent } from "./utils"; + +vi.mock("@/lib/utils/helper", () => ({ + getFormattedErrorMessage: vi.fn(), +})); + +vi.mock("@/modules/survey/list/actions", () => ({ + generateSingleUseIdAction: vi.fn(), +})); + +describe("renderHyperlinkedContent", () => { + afterEach(() => { + cleanup(); + }); + + test("returns a single span element when input has no url", () => { + const input = "Hello world"; + const elements = renderHyperlinkedContent(input); + expect(elements).toHaveLength(1); + const element = elements[0]; + expect(isValidElement(element)).toBe(true); + // element.type should be "span" + expect(element.type).toBe("span"); + expect(element.props.children).toEqual("Hello world"); + }); + + test("splits input with a valid url into span, anchor, span", () => { + const input = "Visit https://example.com for info"; + const elements = renderHyperlinkedContent(input); + // Expect three elements: before text, URL link, after text. + expect(elements).toHaveLength(3); + // First element should be span with "Visit " + expect(elements[0].type).toBe("span"); + expect(elements[0].props.children).toEqual("Visit "); + // Second element should be an anchor with the URL. + expect(elements[1].type).toBe("a"); + expect(elements[1].props.href).toEqual("https://example.com"); + expect(elements[1].props.className).toContain("text-blue-500"); + // Third element: span with " for info" + expect(elements[2].type).toBe("span"); + expect(elements[2].props.children).toEqual(" for info"); + }); + + test("handles multiple valid urls in the input", () => { + const input = "Link1: https://example.com and Link2: https://vitejs.dev"; + const elements = renderHyperlinkedContent(input); + // Expected parts: "Link1: ", "https://example.com", " and Link2: ", "https://vitejs.dev", "" + expect(elements).toHaveLength(5); + expect(elements[1].type).toBe("a"); + expect(elements[1].props.href).toEqual("https://example.com"); + expect(elements[3].type).toBe("a"); + expect(elements[3].props.href).toEqual("https://vitejs.dev"); + }); + + test("renders a span instead of anchor when URL constructor throws", () => { + // Force global.URL to throw for this test. + const originalURL = global.URL; + vi.spyOn(global, "URL").mockImplementation(() => { + throw new Error("Invalid URL"); + }); + const input = "Visit https://broken-url.com now"; + const elements = renderHyperlinkedContent(input); + // Expect the URL not to be rendered as anchor because isValidUrl returns false + // The split will still occur, but the element corresponding to the URL should be a span. + expect(elements).toHaveLength(3); + // Check the element that would have been an anchor is now a span. + expect(elements[1].type).toBe("span"); + expect(elements[1].props.children).toEqual("https://broken-url.com"); + // Restore original URL + global.URL = originalURL; + }); +}); diff --git a/apps/web/modules/analysis/utils.tsx b/apps/web/modules/analysis/utils.tsx index 3e123c4fe20c..70978d04c662 100644 --- a/apps/web/modules/analysis/utils.tsx +++ b/apps/web/modules/analysis/utils.tsx @@ -1,4 +1,5 @@ import { JSX } from "react"; +import { TSurvey } from "@formbricks/types/surveys/types"; // Utility function to render hyperlinked content export const renderHyperlinkedContent = (data: string): JSX.Element[] => { @@ -26,3 +27,18 @@ export const renderHyperlinkedContent = (data: string): JSX.Element[] => { ) ); }; + +export const getSurveyUrl = (survey: TSurvey, publicDomain: string, language: string): string => { + let url = `${publicDomain}/s/${survey.id}`; + const queryParams: string[] = []; + + if (language !== "default") { + queryParams.push(`lang=${language}`); + } + + if (queryParams.length) { + url += `?${queryParams.join("&")}`; + } + + return url; +}; diff --git a/apps/web/modules/api/v2/auth/api-wrapper.ts b/apps/web/modules/api/v2/auth/api-wrapper.ts index 1a4cc8d1c8d9..abffc57f6fb7 100644 --- a/apps/web/modules/api/v2/auth/api-wrapper.ts +++ b/apps/web/modules/api/v2/auth/api-wrapper.ts @@ -1,5 +1,7 @@ -import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit"; +import { TApiAuditLog } from "@/app/lib/api/with-api-logging"; import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { ZodRawShape, z } from "zod"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { authenticateRequest } from "./authenticate-request"; @@ -8,10 +10,12 @@ export type HandlerFn> = ({ authentication, parsedInput, request, + auditLog, }: { authentication: TAuthenticationApiKey; parsedInput: TInput; request: Request; + auditLog?: TApiAuditLog; }) => Promise; export type ExtendedSchemas = { @@ -21,11 +25,19 @@ export type ExtendedSchemas = { }; // Define a type that returns separate keys for each input type. -export type ParsedSchemas = { - body?: S extends { body: z.ZodObject } ? z.infer : undefined; - query?: S extends { query: z.ZodObject } ? z.infer : undefined; - params?: S extends { params: z.ZodObject } ? z.infer : undefined; -}; +// It uses mapped types to create a new type based on the input schemas. +// It checks if each schema is defined and if it is a ZodObject, then infers the type from it. +// It also uses conditional types to ensure that the keys are only included if the schema is defined and valid. +// This allows for more flexibility and type safety when working with the input schemas. +export type ParsedSchemas = S extends object + ? { + [K in keyof S as NonNullable extends z.ZodObject ? K : never]: NonNullable< + S[K] + > extends z.ZodObject + ? z.infer> + : never; + } + : {}; export const apiWrapper = async ({ request, @@ -33,18 +45,25 @@ export const apiWrapper = async ({ externalParams, rateLimit = true, handler, + auditLog, }: { request: Request; schemas?: S; externalParams?: Promise>; rateLimit?: boolean; handler: HandlerFn>; + auditLog?: TApiAuditLog; }): Promise => { const authentication = await authenticateRequest(request); if (!authentication.ok) { return handleApiError(request, authentication.error); } + if (auditLog) { + auditLog.userId = authentication.data.apiKeyId; + auditLog.organizationId = authentication.data.organizationId; + } + let parsedInput: ParsedSchemas = {} as ParsedSchemas; if (schemas?.body) { @@ -86,11 +105,10 @@ export const apiWrapper = async ({ } if (rateLimit) { - const rateLimitResponse = await checkRateLimitAndThrowError({ - identifier: authentication.data.hashedApiKey, - }); - if (!rateLimitResponse.ok) { - return handleApiError(request, rateLimitResponse.error); + try { + await applyRateLimit(rateLimitConfigs.api.v2, authentication.data.hashedApiKey); + } catch (error) { + return handleApiError(request, { type: "too_many_requests", details: error.message }); } } @@ -98,5 +116,6 @@ export const apiWrapper = async ({ authentication: authentication.data, parsedInput, request, + auditLog, }); }; diff --git a/apps/web/modules/api/v2/auth/authenticated-api-client.ts b/apps/web/modules/api/v2/auth/authenticated-api-client.ts index 7a14151732b9..8eb0dcd5b73b 100644 --- a/apps/web/modules/api/v2/auth/authenticated-api-client.ts +++ b/apps/web/modules/api/v2/auth/authenticated-api-client.ts @@ -1,5 +1,7 @@ +import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging"; import { handleApiError, logApiRequest } from "@/modules/api/v2/lib/utils"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log"; import { ExtendedSchemas, HandlerFn, ParsedSchemas, apiWrapper } from "./api-wrapper"; export const authenticatedApiClient = async ({ @@ -8,24 +10,35 @@ export const authenticatedApiClient = async ({ externalParams, rateLimit = true, handler, + action, + targetType, }: { request: Request; schemas?: S; externalParams?: Promise>; rateLimit?: boolean; handler: HandlerFn>; + action?: TAuditAction; + targetType?: TAuditTarget; }): Promise => { try { + const auditLog = + action && targetType ? buildAuditLogBaseObject(action, targetType, request.url) : undefined; + const response = await apiWrapper({ request, schemas, externalParams, rateLimit, handler, + auditLog, }); if (response.ok) { - logApiRequest(request, response.status); + if (auditLog) { + auditLog.status = "success"; + } + logApiRequest(request, response.status, auditLog); } return response; diff --git a/apps/web/modules/api/v2/auth/tests/api-wrapper.test.ts b/apps/web/modules/api/v2/auth/tests/api-wrapper.test.ts index dba952054f76..a5e737655119 100644 --- a/apps/web/modules/api/v2/auth/tests/api-wrapper.test.ts +++ b/apps/web/modules/api/v2/auth/tests/api-wrapper.test.ts @@ -1,22 +1,26 @@ import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper"; import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request"; -import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit"; import { handleApiError } from "@/modules/api/v2/lib/utils"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { describe, expect, it, vi } from "vitest"; +import { checkRateLimit } from "@/modules/core/rate-limit/rate-limit"; +import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; -import { err, ok, okVoid } from "@formbricks/types/error-handlers"; +import { err, ok } from "@formbricks/types/error-handlers"; vi.mock("../authenticate-request", () => ({ authenticateRequest: vi.fn(), })); -vi.mock("@/modules/api/v2/lib/rate-limit", () => ({ - checkRateLimitAndThrowError: vi.fn(), +vi.mock("@/modules/core/rate-limit/rate-limit", () => ({ + checkRateLimit: vi.fn(), })); -vi.mock("@/modules/api/v2/lib/utils", () => ({ - handleApiError: vi.fn(), +vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({ + rateLimitConfigs: { + api: { + v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" }, + }, + }, })); vi.mock("@/modules/api/v2/lib/utils", () => ({ @@ -24,20 +28,31 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({ handleApiError: vi.fn(), })); +const mockAuthentication = { + type: "apiKey" as const, + environmentPermissions: [ + { + environmentId: "env-id", + environmentType: "development" as const, + projectId: "project-id", + projectName: "Project Name", + permission: "manage" as const, + }, + ], + hashedApiKey: "hashed-api-key", + apiKeyId: "api-key-id", + organizationId: "org-id", + organizationAccess: {} as any, +} as any; + describe("apiWrapper", () => { - it("should handle request and return response", async () => { + test("should handle request and return response", async () => { const request = new Request("http://localhost", { headers: { "x-api-key": "valid-api-key" }, }); - vi.mocked(authenticateRequest).mockResolvedValue( - ok({ - type: "apiKey", - environmentId: "env-id", - hashedApiKey: "hashed-api-key", - }) - ); - vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(okVoid()); + vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication)); + vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: true })); const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 })); const response = await apiWrapper({ @@ -49,7 +64,7 @@ describe("apiWrapper", () => { expect(handler).toHaveBeenCalled(); }); - it("should handle errors and return error response", async () => { + test("should handle errors and return error response", async () => { const request = new Request("http://localhost", { headers: { "x-api-key": "invalid-api-key" }, }); @@ -67,20 +82,14 @@ describe("apiWrapper", () => { expect(handler).not.toHaveBeenCalled(); }); - it("should parse body schema correctly", async () => { + test("should parse body schema correctly", async () => { const request = new Request("http://localhost", { method: "POST", body: JSON.stringify({ key: "value" }), headers: { "Content-Type": "application/json" }, }); - vi.mocked(authenticateRequest).mockResolvedValue( - ok({ - type: "apiKey", - environmentId: "env-id", - hashedApiKey: "hashed-api-key", - }) - ); + vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication)); const bodySchema = z.object({ key: z.string() }); const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 })); @@ -100,21 +109,14 @@ describe("apiWrapper", () => { ); }); - it("should handle body schema errors", async () => { + test("should handle body schema errors", async () => { const request = new Request("http://localhost", { method: "POST", body: JSON.stringify({ key: 123 }), headers: { "Content-Type": "application/json" }, }); - vi.mocked(authenticateRequest).mockResolvedValue( - ok({ - type: "apiKey", - environmentId: "env-id", - hashedApiKey: "hashed-api-key", - }) - ); - + vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication)); vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 })); const bodySchema = z.object({ key: z.string() }); @@ -131,16 +133,10 @@ describe("apiWrapper", () => { expect(handler).not.toHaveBeenCalled(); }); - it("should parse query schema correctly", async () => { + test("should parse query schema correctly", async () => { const request = new Request("http://localhost?key=value"); - vi.mocked(authenticateRequest).mockResolvedValue( - ok({ - type: "apiKey", - environmentId: "env-id", - hashedApiKey: "hashed-api-key", - }) - ); + vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication)); const querySchema = z.object({ key: z.string() }); const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 })); @@ -160,17 +156,10 @@ describe("apiWrapper", () => { ); }); - it("should handle query schema errors", async () => { + test("should handle query schema errors", async () => { const request = new Request("http://localhost?foo%ZZ=abc"); - vi.mocked(authenticateRequest).mockResolvedValue( - ok({ - type: "apiKey", - environmentId: "env-id", - hashedApiKey: "hashed-api-key", - }) - ); - + vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication)); vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 })); const querySchema = z.object({ key: z.string() }); @@ -187,16 +176,10 @@ describe("apiWrapper", () => { expect(handler).not.toHaveBeenCalled(); }); - it("should parse params schema correctly", async () => { + test("should parse params schema correctly", async () => { const request = new Request("http://localhost"); - vi.mocked(authenticateRequest).mockResolvedValue( - ok({ - type: "apiKey", - environmentId: "env-id", - hashedApiKey: "hashed-api-key", - }) - ); + vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication)); const paramsSchema = z.object({ key: z.string() }); const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 })); @@ -217,17 +200,10 @@ describe("apiWrapper", () => { ); }); - it("should handle no external params", async () => { + test("should handle no external params", async () => { const request = new Request("http://localhost"); - vi.mocked(authenticateRequest).mockResolvedValue( - ok({ - type: "apiKey", - environmentId: "env-id", - hashedApiKey: "hashed-api-key", - }) - ); - + vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication)); vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 })); const paramsSchema = z.object({ key: z.string() }); @@ -245,17 +221,10 @@ describe("apiWrapper", () => { expect(handler).not.toHaveBeenCalled(); }); - it("should handle params schema errors", async () => { + test("should handle params schema errors", async () => { const request = new Request("http://localhost"); - vi.mocked(authenticateRequest).mockResolvedValue( - ok({ - type: "apiKey", - environmentId: "env-id", - hashedApiKey: "hashed-api-key", - }) - ); - + vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication)); vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 })); const paramsSchema = z.object({ key: z.string() }); @@ -273,21 +242,13 @@ describe("apiWrapper", () => { expect(handler).not.toHaveBeenCalled(); }); - it("should handle rate limit errors", async () => { + test("should handle rate limit exceeded", async () => { const request = new Request("http://localhost", { headers: { "x-api-key": "valid-api-key" }, }); - vi.mocked(authenticateRequest).mockResolvedValue( - ok({ - type: "apiKey", - environmentId: "env-id", - hashedApiKey: "hashed-api-key", - }) - ); - vi.mocked(checkRateLimitAndThrowError).mockResolvedValue( - err({ type: "rateLimitExceeded" } as unknown as ApiErrorResponseV2) - ); + vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication)); + vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: false })); vi.mocked(handleApiError).mockImplementation( (_request: Request, _error: ApiErrorResponseV2): Response => new Response("rate limit exceeded", { status: 429 }) @@ -302,4 +263,24 @@ describe("apiWrapper", () => { expect(response.status).toBe(429); expect(handler).not.toHaveBeenCalled(); }); + + test("should handle rate limit check failure gracefully", async () => { + const request = new Request("http://localhost", { + headers: { "x-api-key": "valid-api-key" }, + }); + + vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication)); + // When rate limiting fails (e.g., Redis connection issues), checkRateLimit fails open by returning allowed: true + vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: true })); + + const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 })); + const response = await apiWrapper({ + request, + handler, + }); + + // Should fail open for availability + expect(response.status).toBe(200); + expect(handler).toHaveBeenCalled(); + }); }); diff --git a/apps/web/modules/api/v2/auth/tests/authenticate-request.test.ts b/apps/web/modules/api/v2/auth/tests/authenticate-request.test.ts index 27f4f78caeb7..459d5e526ef0 100644 --- a/apps/web/modules/api/v2/auth/tests/authenticate-request.test.ts +++ b/apps/web/modules/api/v2/auth/tests/authenticate-request.test.ts @@ -1,5 +1,5 @@ import { hashApiKey } from "@/modules/api/v2/management/lib/utils"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { authenticateRequest } from "../authenticate-request"; @@ -17,7 +17,7 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({ })); describe("authenticateRequest", () => { - it("should return authentication data if apiKey is valid", async () => { + test("should return authentication data if apiKey is valid", async () => { const request = new Request("http://localhost", { headers: { "x-api-key": "valid-api-key" }, }); @@ -87,7 +87,7 @@ describe("authenticateRequest", () => { } }); - it("should return unauthorized error if apiKey is not found", async () => { + test("should return unauthorized error if apiKey is not found", async () => { const request = new Request("http://localhost", { headers: { "x-api-key": "invalid-api-key" }, }); @@ -101,7 +101,7 @@ describe("authenticateRequest", () => { } }); - it("should return unauthorized error if apiKey is missing", async () => { + test("should return unauthorized error if apiKey is missing", async () => { const request = new Request("http://localhost"); const result = await authenticateRequest(request); diff --git a/apps/web/modules/api/v2/auth/tests/authenticated-api-client.test.ts b/apps/web/modules/api/v2/auth/tests/authenticated-api-client.test.ts index 77fc37a95152..900633e62b45 100644 --- a/apps/web/modules/api/v2/auth/tests/authenticated-api-client.test.ts +++ b/apps/web/modules/api/v2/auth/tests/authenticated-api-client.test.ts @@ -1,5 +1,5 @@ import { logApiRequest } from "@/modules/api/v2/lib/utils"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { apiWrapper } from "../api-wrapper"; import { authenticatedApiClient } from "../authenticated-api-client"; @@ -12,7 +12,7 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({ })); describe("authenticatedApiClient", () => { - it("should log request and return response", async () => { + test("should log request and return response", async () => { const request = new Request("http://localhost", { headers: { "x-api-key": "valid-api-key" }, }); diff --git a/apps/web/modules/api/v2/lib/question.ts b/apps/web/modules/api/v2/lib/question.ts new file mode 100644 index 000000000000..232b2d01a006 --- /dev/null +++ b/apps/web/modules/api/v2/lib/question.ts @@ -0,0 +1,78 @@ +import { MAX_OTHER_OPTION_LENGTH } from "@/lib/constants"; +import { getLocalizedValue } from "@/lib/i18n/utils"; +import { TResponseData } from "@formbricks/types/responses"; +import { + TSurveyQuestion, + TSurveyQuestionChoice, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; + +/** + * Helper function to check if a string value is a valid "other" option + * @returns BadRequestResponse if the value exceeds the limit, undefined otherwise + */ +export const validateOtherOptionLength = ( + value: string, + choices: TSurveyQuestionChoice[], + questionId: string, + language?: string +): string | undefined => { + // Check if this is an "other" option (not in predefined choices) + const matchingChoice = choices.find( + (choice) => getLocalizedValue(choice.label, language ?? "default") === value + ); + + // If this is an "other" option with value that's too long, reject the response + if (!matchingChoice && value.length > MAX_OTHER_OPTION_LENGTH) { + return questionId; + } +}; + +export const validateOtherOptionLengthForMultipleChoice = ({ + responseData, + surveyQuestions, + responseLanguage, +}: { + responseData?: TResponseData; + surveyQuestions: TSurveyQuestion[]; + responseLanguage?: string; +}): string | undefined => { + if (!responseData) return undefined; + for (const [questionId, answer] of Object.entries(responseData)) { + const question = surveyQuestions.find((q) => q.id === questionId); + if (!question) continue; + + const isMultiChoice = + question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti || + question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle; + + if (!isMultiChoice) continue; + + const error = validateAnswer(answer, question.choices, questionId, responseLanguage); + if (error) return error; + } + + return undefined; +}; + +function validateAnswer( + answer: unknown, + choices: TSurveyQuestionChoice[], + questionId: string, + language?: string +): string | undefined { + if (typeof answer === "string") { + return validateOtherOptionLength(answer, choices, questionId, language); + } + + if (Array.isArray(answer)) { + for (const item of answer) { + if (typeof item === "string") { + const result = validateOtherOptionLength(item, choices, questionId, language); + if (result) return result; + } + } + } + + return undefined; +} diff --git a/apps/web/modules/api/v2/lib/rate-limit.ts b/apps/web/modules/api/v2/lib/rate-limit.ts deleted file mode 100644 index 2ca3d695eb01..000000000000 --- a/apps/web/modules/api/v2/lib/rate-limit.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { type LimitOptions, Ratelimit, type RatelimitResponse } from "@unkey/ratelimit"; -import { MANAGEMENT_API_RATE_LIMIT, RATE_LIMITING_DISABLED, UNKEY_ROOT_KEY } from "@formbricks/lib/constants"; -import { logger } from "@formbricks/logger"; -import { Result, err, okVoid } from "@formbricks/types/error-handlers"; - -export type RateLimitHelper = { - identifier: string; - opts?: LimitOptions; - /** - * Using a callback instead of a regular return to provide headers even - * when the rate limit is reached and an error is thrown. - **/ - onRateLimiterResponse?: (response: RatelimitResponse) => void; -}; - -let warningDisplayed = false; - -/** Prevent flooding the logs while testing/building */ -function logOnce(message: string) { - if (warningDisplayed) return; - logger.warn(message); - warningDisplayed = true; -} - -export function rateLimiter() { - if (RATE_LIMITING_DISABLED) { - logOnce("Rate limiting disabled"); - return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse; - } - - if (!UNKEY_ROOT_KEY) { - logOnce("Disabled due to not finding UNKEY_ROOT_KEY env variable"); - return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse; - } - const timeout = { - fallback: { success: true, limit: 10, remaining: 999, reset: 0 }, - ms: 5000, - }; - - const limiter = { - api: new Ratelimit({ - rootKey: UNKEY_ROOT_KEY, - namespace: "api", - limit: MANAGEMENT_API_RATE_LIMIT.allowedPerInterval, - duration: MANAGEMENT_API_RATE_LIMIT.interval * 1000, - timeout, - }), - }; - - async function rateLimit({ identifier, opts }: RateLimitHelper) { - return await limiter.api.limit(identifier, opts); - } - - return rateLimit; -} - -export const checkRateLimitAndThrowError = async ({ - identifier, - opts, -}: RateLimitHelper): Promise> => { - const response = await rateLimiter()({ identifier, opts }); - const { success } = response; - - if (!success) { - return err({ - type: "too_many_requests", - }); - } - return okVoid(); -}; diff --git a/apps/web/modules/api/v2/lib/response.ts b/apps/web/modules/api/v2/lib/response.ts index a378f75a366a..4aa2689c904c 100644 --- a/apps/web/modules/api/v2/lib/response.ts +++ b/apps/web/modules/api/v2/lib/response.ts @@ -260,6 +260,34 @@ const successResponse = ({ ); }; +export const createdResponse = ({ + data, + meta, + cors = false, + cache = "private, no-store", +}: { + data: Object; + meta?: Record; + cors?: boolean; + cache?: string; +}) => { + const headers = { + ...(cors && corsHeaders), + "Cache-Control": cache, + }; + + return Response.json( + { + data, + meta, + } as ApiSuccessResponse, + { + status: 201, + headers, + } + ); +}; + export const multiStatusResponse = ({ data, meta, @@ -298,5 +326,6 @@ export const responses = { tooManyRequestsResponse, internalServerErrorResponse, successResponse, + createdResponse, multiStatusResponse, }; diff --git a/apps/web/modules/api/v2/lib/tests/question.test.ts b/apps/web/modules/api/v2/lib/tests/question.test.ts new file mode 100644 index 000000000000..4f9568cf4739 --- /dev/null +++ b/apps/web/modules/api/v2/lib/tests/question.test.ts @@ -0,0 +1,150 @@ +import { MAX_OTHER_OPTION_LENGTH } from "@/lib/constants"; +import { describe, expect, test, vi } from "vitest"; +import { + TSurveyQuestion, + TSurveyQuestionChoice, + TSurveyQuestionTypeEnum, +} from "@formbricks/types/surveys/types"; +import { validateOtherOptionLength, validateOtherOptionLengthForMultipleChoice } from "../question"; + +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: vi.fn().mockImplementation((value, language) => { + return typeof value === "string" ? value : value[language] || value["default"] || ""; + }), +})); + +vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/recaptcha", () => ({ + verifyRecaptchaToken: vi.fn(), +})); + +vi.mock("@/app/lib/api/response", () => ({ + responses: { + badRequestResponse: vi.fn((message) => new Response(message, { status: 400 })), + notFoundResponse: vi.fn((message) => new Response(message, { status: 404 })), + }, +})); + +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getIsSpamProtectionEnabled: vi.fn(), +})); + +vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/organization", () => ({ + getOrganizationBillingByEnvironmentId: vi.fn(), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +const mockChoices: TSurveyQuestionChoice[] = [ + { id: "1", label: { default: "Option 1" } }, + { id: "2", label: { default: "Option 2" } }, +]; + +const surveyQuestions = [ + { + id: "q1", + type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + choices: mockChoices, + }, + { + id: "q2", + type: TSurveyQuestionTypeEnum.MultipleChoiceMulti, + choices: mockChoices, + }, +] as unknown as TSurveyQuestion[]; + +describe("validateOtherOptionLength", () => { + const mockChoices: TSurveyQuestionChoice[] = [ + { id: "1", label: { default: "Option 1", fr: "Option one" } }, + { id: "2", label: { default: "Option 2", fr: "Option two" } }, + { id: "3", label: { default: "Option 3", fr: "Option Trois" } }, + ]; + + test("returns undefined when value matches a choice", () => { + const result = validateOtherOptionLength("Option 1", mockChoices, "q1"); + expect(result).toBeUndefined(); + }); + + test("returns undefined when other option is within length limit", () => { + const shortValue = "A".repeat(MAX_OTHER_OPTION_LENGTH); + const result = validateOtherOptionLength(shortValue, mockChoices, "q1"); + expect(result).toBeUndefined(); + }); + + test("uses default language when no language is provided", () => { + const result = validateOtherOptionLength("Option 3", mockChoices, "q1"); + expect(result).toBeUndefined(); + }); + + test("handles localized choice labels", () => { + const result = validateOtherOptionLength("Option Trois", mockChoices, "q1", "fr"); + expect(result).toBeUndefined(); + }); + + test("returns bad request response when other option exceeds length limit", () => { + const longValue = "A".repeat(MAX_OTHER_OPTION_LENGTH + 1); + const result = validateOtherOptionLength(longValue, mockChoices, "q1"); + expect(result).toBeTruthy(); + }); +}); + +describe("validateOtherOptionLengthForMultipleChoice", () => { + test("returns undefined for single choice that matches a valid option", () => { + const result = validateOtherOptionLengthForMultipleChoice({ + responseData: { q1: "Option 1" }, + surveyQuestions, + }); + + expect(result).toBeUndefined(); + }); + + test("returns undefined for multi-select with all valid options", () => { + const result = validateOtherOptionLengthForMultipleChoice({ + responseData: { q2: ["Option 1", "Option 2"] }, + surveyQuestions, + }); + + expect(result).toBeUndefined(); + }); + + test("returns questionId for single choice with long 'other' option", () => { + const longText = "X".repeat(MAX_OTHER_OPTION_LENGTH + 1); + const result = validateOtherOptionLengthForMultipleChoice({ + responseData: { q1: longText }, + surveyQuestions, + }); + + expect(result).toBe("q1"); + }); + + test("returns questionId for multi-select with one long 'other' option", () => { + const longText = "Y".repeat(MAX_OTHER_OPTION_LENGTH + 1); + const result = validateOtherOptionLengthForMultipleChoice({ + responseData: { q2: [longText] }, + surveyQuestions, + }); + + expect(result).toBe("q2"); + }); + + test("ignores non-matching or unrelated question IDs", () => { + const result = validateOtherOptionLengthForMultipleChoice({ + responseData: { unrelated: "Other: something" }, + surveyQuestions, + }); + + expect(result).toBeUndefined(); + }); + + test("returns undefined if answer is not string or array", () => { + const result = validateOtherOptionLengthForMultipleChoice({ + responseData: { q1: 123 as any }, + surveyQuestions, + }); + + expect(result).toBeUndefined(); + }); +}); diff --git a/apps/web/modules/api/v2/lib/tests/rate-limit.test.ts b/apps/web/modules/api/v2/lib/tests/rate-limit.test.ts deleted file mode 100644 index 323854abc378..000000000000 --- a/apps/web/modules/api/v2/lib/tests/rate-limit.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; -import { logger } from "@formbricks/logger"; - -vi.mock("@formbricks/logger", () => ({ - logger: { - warn: vi.fn(), - }, -})); - -vi.mock("@unkey/ratelimit", () => ({ - Ratelimit: vi.fn(), -})); - -describe("when rate limiting is disabled", () => { - beforeEach(async () => { - vi.resetModules(); - const constants = await vi.importActual("@formbricks/lib/constants"); - vi.doMock("@formbricks/lib/constants", () => ({ - ...constants, - MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 }, - RATE_LIMITING_DISABLED: true, - })); - }); - - test("should log a warning once and return a stubbed response", async () => { - const loggerSpy = vi.spyOn(logger, "warn"); - const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit"); - - const res1 = await rateLimiter()({ identifier: "test-id" }); - expect(res1).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 }); - expect(loggerSpy).toHaveBeenCalled(); - - // Subsequent calls won't log again. - await rateLimiter()({ identifier: "another-id" }); - - expect(loggerSpy).toHaveBeenCalledTimes(1); - loggerSpy.mockRestore(); - }); -}); - -describe("when UNKEY_ROOT_KEY is missing", () => { - beforeEach(async () => { - vi.resetModules(); - const constants = await vi.importActual("@formbricks/lib/constants"); - vi.doMock("@formbricks/lib/constants", () => ({ - ...constants, - MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 }, - RATE_LIMITING_DISABLED: false, - UNKEY_ROOT_KEY: "", - })); - }); - - test("should log a warning about missing UNKEY_ROOT_KEY and return stub response", async () => { - const loggerSpy = vi.spyOn(logger, "warn"); - const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit"); - const limiterFunc = rateLimiter(); - - const res = await limiterFunc({ identifier: "test-id" }); - expect(res).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 }); - expect(loggerSpy).toHaveBeenCalled(); - loggerSpy.mockRestore(); - }); -}); - -describe("when rate limiting is active (enabled)", () => { - const mockResponse = { success: true, limit: 5, remaining: 2, reset: 1000 }; - let limitMock: ReturnType; - - beforeEach(async () => { - vi.resetModules(); - const constants = await vi.importActual("@formbricks/lib/constants"); - vi.doMock("@formbricks/lib/constants", () => ({ - ...constants, - MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 }, - RATE_LIMITING_DISABLED: false, - UNKEY_ROOT_KEY: "valid-key", - })); - - limitMock = vi.fn().mockResolvedValue(mockResponse); - const RatelimitMock = vi.fn().mockImplementation(() => { - return { limit: limitMock }; - }); - vi.doMock("@unkey/ratelimit", () => ({ - Ratelimit: RatelimitMock, - })); - }); - - test("should create a rate limiter that calls the limit method with the proper arguments", async () => { - const { rateLimiter } = await import("../rate-limit"); - const limiterFunc = rateLimiter(); - const res = await limiterFunc({ identifier: "abc", opts: { cost: 1 } }); - expect(limitMock).toHaveBeenCalledWith("abc", { cost: 1 }); - expect(res).toEqual(mockResponse); - }); - - test("checkRateLimitAndThrowError returns okVoid when rate limit is not exceeded", async () => { - limitMock.mockResolvedValueOnce({ success: true, limit: 5, remaining: 3, reset: 1000 }); - - const { checkRateLimitAndThrowError } = await import("../rate-limit"); - const result = await checkRateLimitAndThrowError({ identifier: "abc" }); - expect(result.ok).toBe(true); - }); - - test("checkRateLimitAndThrowError returns an error when the rate limit is exceeded", async () => { - limitMock.mockResolvedValueOnce({ success: false, limit: 5, remaining: 0, reset: 1000 }); - - const { checkRateLimitAndThrowError } = await import("../rate-limit"); - const result = await checkRateLimitAndThrowError({ identifier: "abc" }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error).toEqual({ type: "too_many_requests" }); - } - }); -}); diff --git a/apps/web/modules/api/v2/lib/tests/response.test.ts b/apps/web/modules/api/v2/lib/tests/response.test.ts index c5e5d233d9d5..a58f78fd4e90 100644 --- a/apps/web/modules/api/v2/lib/tests/response.test.ts +++ b/apps/web/modules/api/v2/lib/tests/response.test.ts @@ -120,7 +120,7 @@ describe("API Responses", () => { }); test("include CORS headers when cors is true", () => { - const res = responses.unprocessableEntityResponse({ cors: true }); + const res = responses.unprocessableEntityResponse({ cors: true, details: [] }); expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); }); }); @@ -182,4 +182,38 @@ describe("API Responses", () => { expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); }); }); + + describe("createdResponse", () => { + test("return a success response with the provided data", async () => { + const data = { foo: "bar" }; + const meta = { page: 1 }; + const res = responses.createdResponse({ data, meta }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.data).toEqual(data); + expect(body.meta).toEqual(meta); + }); + + test("include CORS headers when cors is true", () => { + const data = { foo: "bar" }; + const res = responses.createdResponse({ data, cors: true }); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + }); + + describe("multiStatusResponse", () => { + test("return a 207 response with the provided data", async () => { + const data = { foo: "bar" }; + const res = responses.multiStatusResponse({ data }); + expect(res.status).toBe(207); + const body = await res.json(); + expect(body.data).toEqual(data); + }); + + test("include CORS headers when cors is true", () => { + const data = { foo: "bar" }; + const res = responses.multiStatusResponse({ data, cors: true }); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + }); }); diff --git a/apps/web/modules/api/v2/lib/tests/utils.test.ts b/apps/web/modules/api/v2/lib/tests/utils.test.ts index 0885a565cd18..36f5c5c6bb47 100644 --- a/apps/web/modules/api/v2/lib/tests/utils.test.ts +++ b/apps/web/modules/api/v2/lib/tests/utils.test.ts @@ -1,4 +1,5 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import * as Sentry from "@sentry/nextjs"; import { describe, expect, test, vi } from "vitest"; import { ZodError } from "zod"; import { logger } from "@formbricks/logger"; @@ -9,6 +10,19 @@ const mockRequest = new Request("http://localhost"); // Add the request id header mockRequest.headers.set("x-request-id", "123"); +vi.mock("@sentry/nextjs", () => ({ + captureException: vi.fn(), +})); + +// Mock SENTRY_DSN constant +vi.mock("@/lib/constants", () => ({ + SENTRY_DSN: "mocked-sentry-dsn", + IS_PRODUCTION: true, + AUDIT_LOG_ENABLED: true, + ENCRYPTION_KEY: "mocked-encryption-key", + REDIS_URL: undefined, +})); + describe("utils", () => { describe("handleApiError", () => { test('return bad request response for "bad_request" error', async () => { @@ -257,5 +271,45 @@ describe("utils", () => { // Restore the original method logger.withContext = originalWithContext; }); + + test("log API error details with SENTRY_DSN set", () => { + // Mock the withContext method and its returned error method + const errorMock = vi.fn(); + const withContextMock = vi.fn().mockReturnValue({ + error: errorMock, + }); + + // Mock Sentry's captureException method + vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any); + + // Replace the original withContext with our mock + const originalWithContext = logger.withContext; + logger.withContext = withContextMock; + + const mockRequest = new Request("http://localhost/api/test"); + mockRequest.headers.set("x-request-id", "123"); + + const error: ApiErrorResponseV2 = { + type: "internal_server_error", + details: [{ field: "server", issue: "error occurred" }], + }; + + logApiError(mockRequest, error); + + // Verify withContext was called with the expected context + expect(withContextMock).toHaveBeenCalledWith({ + correlationId: "123", + error, + }); + + // Verify error was called on the child logger + expect(errorMock).toHaveBeenCalledWith("API Error Details"); + + // Verify Sentry.captureException was called + expect(Sentry.captureException).toHaveBeenCalled(); + + // Restore the original method + logger.withContext = originalWithContext; + }); }); }); diff --git a/apps/web/modules/api/v2/lib/utils-edge.ts b/apps/web/modules/api/v2/lib/utils-edge.ts new file mode 100644 index 000000000000..0d748f34db30 --- /dev/null +++ b/apps/web/modules/api/v2/lib/utils-edge.ts @@ -0,0 +1,30 @@ +// Function is this file can be used in edge runtime functions, like api routes. +import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import * as Sentry from "@sentry/nextjs"; +import { logger } from "@formbricks/logger"; + +export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => { + const correlationId = request.headers.get("x-request-id") ?? ""; + + // Send the error to Sentry if the DSN is set and the error type is internal_server_error + // This is useful for tracking down issues without overloading Sentry with errors + if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") { + const err = new Error(`API V2 error, id: ${correlationId}`); + + Sentry.captureException(err, { + extra: { + details: error.details, + type: error.type, + correlationId, + }, + }); + } + + logger + .withContext({ + correlationId, + error, + }) + .error("API Error Details"); +}; diff --git a/apps/web/modules/api/v2/lib/utils.ts b/apps/web/modules/api/v2/lib/utils.ts index 845e22a7b6c4..c0ac31ffd399 100644 --- a/apps/web/modules/api/v2/lib/utils.ts +++ b/apps/web/modules/api/v2/lib/utils.ts @@ -1,10 +1,19 @@ +// @ts-nocheck // We can remove this when we update the prisma client and the typescript version +// if we don't add this we get build errors with prisma due to type-nesting +import { AUDIT_LOG_ENABLED } from "@/lib/constants"; import { responses } from "@/modules/api/v2/lib/response"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler"; import { ZodCustomIssue, ZodIssue } from "zod"; import { logger } from "@formbricks/logger"; +import { logApiErrorEdge } from "./utils-edge"; -export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => { - logApiError(request, err); +export const handleApiError = ( + request: Request, + err: ApiErrorResponseV2, + auditLog?: ApiAuditLog +): Response => { + logApiError(request, err, auditLog); switch (err.type) { case "bad_request": @@ -46,7 +55,7 @@ export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] }) }); }; -export const logApiRequest = (request: Request, responseStatus: number): void => { +export const logApiRequest = (request: Request, responseStatus: number, auditLog?: ApiAuditLog): void => { const method = request.method; const url = new URL(request.url); const path = url.pathname; @@ -59,7 +68,6 @@ export const logApiRequest = (request: Request, responseStatus: number): void => Object.entries(queryParams).filter(([key]) => !sensitiveParams.includes(key.toLowerCase())) ); - // Info: Conveys general, operational messages about system progress and state. logger .withContext({ method, @@ -70,14 +78,22 @@ export const logApiRequest = (request: Request, responseStatus: number): void => queryParams: safeQueryParams, }) .info("API Request Details"); + + logAuditLog(request, auditLog); }; -export const logApiError = (request: Request, error: ApiErrorResponseV2): void => { - const correlationId = request.headers.get("x-request-id") || ""; - logger - .withContext({ - correlationId, - error, - }) - .error("API Error Details"); +export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: ApiAuditLog): void => { + logApiErrorEdge(request, error); + + logAuditLog(request, auditLog); +}; + +const logAuditLog = (request: Request, auditLog?: ApiAuditLog): void => { + if (AUDIT_LOG_ENABLED && auditLog) { + const correlationId = request.headers.get("x-request-id") ?? ""; + queueAuditEvent({ + ...auditLog, + eventId: correlationId, + }).catch((err) => logger.error({ err, correlationId }, "Failed to queue audit event from logApiError")); + } }; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts new file mode 100644 index 000000000000..f001f8b074d1 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts @@ -0,0 +1,124 @@ +import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { ContactAttributeKey, Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { Result, err, ok } from "@formbricks/types/error-handlers"; + +export const getContactAttributeKey = reactCache(async (contactAttributeKeyId: string) => { + try { + const contactAttributeKey = await prisma.contactAttributeKey.findUnique({ + where: { + id: contactAttributeKeyId, + }, + }); + + if (!contactAttributeKey) { + return err({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + + return ok(contactAttributeKey); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "contactAttributeKey", issue: error.message }], + }); + } +}); + +export const updateContactAttributeKey = async ( + contactAttributeKeyId: string, + contactAttributeKeyInput: TContactAttributeKeyUpdateSchema +): Promise> => { + try { + const updatedKey = await prisma.contactAttributeKey.update({ + where: { + id: contactAttributeKeyId, + }, + data: contactAttributeKeyInput, + }); + + await prisma.contactAttribute.findMany({ + where: { + attributeKeyId: updatedKey.id, + }, + select: { + id: true, + contactId: true, + }, + }); + + return ok(updatedKey); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if ( + error.code === PrismaErrorType.RecordDoesNotExist || + error.code === PrismaErrorType.RelatedRecordDoesNotExist + ) { + return err({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + if (error.code === PrismaErrorType.UniqueConstraintViolation) { + return err({ + type: "conflict", + details: [ + { + field: "contactAttributeKey", + issue: `Contact attribute key with "${contactAttributeKeyInput.key}" already exists`, + }, + ], + }); + } + } + return err({ + type: "internal_server_error", + details: [{ field: "contactAttributeKey", issue: error.message }], + }); + } +}; + +export const deleteContactAttributeKey = async ( + contactAttributeKeyId: string +): Promise> => { + try { + const deletedKey = await prisma.contactAttributeKey.delete({ + where: { + id: contactAttributeKeyId, + }, + }); + + await prisma.contactAttribute.findMany({ + where: { + attributeKeyId: deletedKey.id, + }, + select: { + id: true, + contactId: true, + }, + }); + + return ok(deletedKey); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if ( + error.code === PrismaErrorType.RecordDoesNotExist || + error.code === PrismaErrorType.RelatedRecordDoesNotExist + ) { + return err({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + } + return err({ + type: "internal_server_error", + details: [{ field: "contactAttributeKey", issue: error.message }], + }); + } +}; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/openapi.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/openapi.ts index e16ce064e6ae..d4eee3481b3d 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/openapi.ts @@ -1,4 +1,8 @@ -import { ZContactAttributeKeyInput } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; +import { + ZContactAttributeKeyIdSchema, + ZContactAttributeKeyUpdateSchema, +} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; +import { makePartialSchema } from "@/modules/api/v2/types/openapi-response"; import { z } from "zod"; import { ZodOpenApiOperationObject } from "zod-openapi"; import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys"; @@ -9,69 +13,69 @@ export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { description: "Gets a contact attribute key from the database.", requestParams: { path: z.object({ - contactAttributeKeyId: z.string().cuid2(), + id: ZContactAttributeKeyIdSchema, }), }, - tags: ["Management API > Contact Attribute Keys"], + tags: ["Management API - Contact Attribute Keys"], responses: { "200": { description: "Contact attribute key retrieved successfully.", content: { "application/json": { - schema: ZContactAttributeKey, + schema: makePartialSchema(ZContactAttributeKey), }, }, }, }, }; -export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { - operationId: "deleteContactAttributeKey", - summary: "Delete a contact attribute key", - description: "Deletes a contact attribute key from the database.", - tags: ["Management API > Contact Attribute Keys"], +export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { + operationId: "updateContactAttributeKey", + summary: "Update a contact attribute key", + description: "Updates a contact attribute key in the database.", + tags: ["Management API - Contact Attribute Keys"], requestParams: { path: z.object({ - contactAttributeId: z.string().cuid2(), + id: ZContactAttributeKeyIdSchema, }), }, + requestBody: { + required: true, + description: "The contact attribute key to update", + content: { + "application/json": { + schema: ZContactAttributeKeyUpdateSchema, + }, + }, + }, responses: { "200": { - description: "Contact attribute key deleted successfully.", + description: "Contact attribute key updated successfully.", content: { "application/json": { - schema: ZContactAttributeKey, + schema: makePartialSchema(ZContactAttributeKey), }, }, }, }, }; -export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { - operationId: "updateContactAttributeKey", - summary: "Update a contact attribute key", - description: "Updates a contact attribute key in the database.", - tags: ["Management API > Contact Attribute Keys"], +export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { + operationId: "deleteContactAttributeKey", + summary: "Delete a contact attribute key", + description: "Deletes a contact attribute key from the database.", + tags: ["Management API - Contact Attribute Keys"], requestParams: { path: z.object({ - contactAttributeKeyId: z.string().cuid2(), + id: ZContactAttributeKeyIdSchema, }), }, - requestBody: { - required: true, - description: "The contact attribute key to update", - content: { - "application/json": { - schema: ZContactAttributeKeyInput, - }, - }, - }, responses: { "200": { - description: "Contact attribute key updated successfully.", + description: "Contact attribute key deleted successfully.", content: { "application/json": { - schema: ZContactAttributeKey, + schema: makePartialSchema(ZContactAttributeKey), }, }, }, diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts new file mode 100644 index 000000000000..0b8361dbaf18 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts @@ -0,0 +1,199 @@ +import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; +import { ContactAttributeKey, Prisma } from "@prisma/client"; +import { describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { + deleteContactAttributeKey, + getContactAttributeKey, + updateContactAttributeKey, +} from "../contact-attribute-key"; + +// Mock dependencies +vi.mock("@formbricks/database", () => ({ + prisma: { + contactAttributeKey: { + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findMany: vi.fn(), + }, + contactAttribute: { + findMany: vi.fn(), + }, + }, +})); + +// Mock data +const mockContactAttributeKey: ContactAttributeKey = { + id: "cak123", + key: "email", + name: "Email", + description: "User's email address", + environmentId: "env123", + isUnique: true, + type: "default", + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockUpdateInput: TContactAttributeKeyUpdateSchema = { + key: "email", + name: "Email Address", + description: "User's verified email address", +}; + +const prismaNotFoundError = new Prisma.PrismaClientKnownRequestError("Mock error message", { + code: PrismaErrorType.RelatedRecordDoesNotExist, + clientVersion: "0.0.1", +}); + +const prismaUniqueConstraintError = new Prisma.PrismaClientKnownRequestError("Mock error message", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", +}); + +describe("getContactAttributeKey", () => { + test("returns ok if contact attribute key is found", async () => { + vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValueOnce(mockContactAttributeKey); + const result = await getContactAttributeKey("cak123"); + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.data).toEqual(mockContactAttributeKey); + } + }); + + test("returns err if contact attribute key not found", async () => { + vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValueOnce(null); + const result = await getContactAttributeKey("cak999"); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + }); + + test("returns err on Prisma error", async () => { + vi.mocked(prisma.contactAttributeKey.findUnique).mockRejectedValueOnce(new Error("DB error")); + const result = await getContactAttributeKey("error"); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "internal_server_error", + details: [{ field: "contactAttributeKey", issue: "DB error" }], + }); + } + }); +}); + +describe("updateContactAttributeKey", () => { + test("returns ok on successful update", async () => { + const updatedKey = { ...mockContactAttributeKey, ...mockUpdateInput }; + vi.mocked(prisma.contactAttributeKey.update).mockResolvedValueOnce(updatedKey); + + vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([ + { id: "contact1", contactId: "contact1" }, + { id: "contact2", contactId: "contact2" }, + ]); + + const result = await updateContactAttributeKey("cak123", mockUpdateInput); + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.data).toEqual(updatedKey); + } + }); + + test("returns not_found if record does not exist", async () => { + vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(prismaNotFoundError); + + const result = await updateContactAttributeKey("cak999", mockUpdateInput); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + }); + + test("returns conflict error if key already exists", async () => { + vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(prismaUniqueConstraintError); + + const result = await updateContactAttributeKey("cak123", mockUpdateInput); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "conflict", + details: [ + { field: "contactAttributeKey", issue: 'Contact attribute key with "email" already exists' }, + ], + }); + } + }); + + test("returns internal_server_error if other error occurs", async () => { + vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(new Error("Unknown error")); + + const result = await updateContactAttributeKey("cak123", mockUpdateInput); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "internal_server_error", + details: [{ field: "contactAttributeKey", issue: "Unknown error" }], + }); + } + }); +}); + +describe("deleteContactAttributeKey", () => { + test("returns ok on successful delete", async () => { + vi.mocked(prisma.contactAttributeKey.delete).mockResolvedValueOnce(mockContactAttributeKey); + vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([ + { id: "contact1", contactId: "contact1" }, + { id: "contact2", contactId: "contact2" }, + ]); + const result = await deleteContactAttributeKey("cak123"); + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.data).toEqual(mockContactAttributeKey); + } + }); + + test("returns not_found if record does not exist", async () => { + vi.mocked(prisma.contactAttributeKey.delete).mockRejectedValueOnce(prismaNotFoundError); + + const result = await deleteContactAttributeKey("cak999"); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + }); + + test("returns internal_server_error on other errors", async () => { + vi.mocked(prisma.contactAttributeKey.delete).mockRejectedValueOnce(new Error("Delete error")); + + const result = await deleteContactAttributeKey("cak123"); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "internal_server_error", + details: [{ field: "contactAttributeKey", issue: "Delete error" }], + }); + } + }); +}); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts new file mode 100644 index 000000000000..6f58345fd8ca --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts @@ -0,0 +1,169 @@ +import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; +import { responses } from "@/modules/api/v2/lib/response"; +import { handleApiError } from "@/modules/api/v2/lib/utils"; +import { + deleteContactAttributeKey, + getContactAttributeKey, + updateContactAttributeKey, +} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key"; +import { + ZContactAttributeKeyIdSchema, + ZContactAttributeKeyUpdateSchema, +} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { NextRequest } from "next/server"; +import { z } from "zod"; + +export const GET = async ( + request: NextRequest, + props: { params: Promise<{ contactAttributeKeyId: string }> } +) => + authenticatedApiClient({ + request, + schemas: { + params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }), + }, + externalParams: props.params, + handler: async ({ authentication, parsedInput }) => { + const { params } = parsedInput; + + const res = await getContactAttributeKey(params.contactAttributeKeyId); + + if (!res.ok) { + return handleApiError(request, res.error as ApiErrorResponseV2); + } + + if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "GET")) { + return handleApiError(request, { + type: "unauthorized", + details: [{ field: "environment", issue: "unauthorized" }], + }); + } + + return responses.successResponse(res); + }, + }); + +export const PUT = async ( + request: NextRequest, + props: { params: Promise<{ contactAttributeKeyId: string }> } +) => + authenticatedApiClient({ + request, + schemas: { + params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }), + body: ZContactAttributeKeyUpdateSchema, + }, + externalParams: props.params, + handler: async ({ authentication, parsedInput, auditLog }) => { + const { params, body } = parsedInput; + + if (auditLog) { + auditLog.targetId = params.contactAttributeKeyId; + } + + const res = await getContactAttributeKey(params.contactAttributeKeyId); + + if (!res.ok) { + return handleApiError(request, res.error as ApiErrorResponseV2, auditLog); + } + if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "PUT")) { + return handleApiError( + request, + { + type: "unauthorized", + details: [{ field: "environment", issue: "unauthorized" }], + }, + auditLog + ); + } + + if (res.data.isUnique) { + return handleApiError( + request, + { + type: "bad_request", + details: [{ field: "contactAttributeKey", issue: "cannot update unique contact attribute key" }], + }, + auditLog + ); + } + + const updatedContactAttributeKey = await updateContactAttributeKey(params.contactAttributeKeyId, body); + + if (!updatedContactAttributeKey.ok) { + return handleApiError(request, updatedContactAttributeKey.error, auditLog); + } + + if (auditLog) { + auditLog.oldObject = res.data; + auditLog.newObject = updatedContactAttributeKey.data; + } + + return responses.successResponse(updatedContactAttributeKey); + }, + action: "updated", + targetType: "contactAttributeKey", + }); + +export const DELETE = async ( + request: NextRequest, + props: { params: Promise<{ contactAttributeKeyId: string }> } +) => + authenticatedApiClient({ + request, + schemas: { + params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }), + }, + externalParams: props.params, + handler: async ({ authentication, parsedInput, auditLog }) => { + const { params } = parsedInput; + + if (auditLog) { + auditLog.targetId = params.contactAttributeKeyId; + } + + const res = await getContactAttributeKey(params.contactAttributeKeyId); + + if (!res.ok) { + return handleApiError(request, res.error as ApiErrorResponseV2, auditLog); + } + + if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "DELETE")) { + return handleApiError( + request, + { + type: "unauthorized", + details: [{ field: "environment", issue: "unauthorized" }], + }, + auditLog + ); + } + + if (res.data.isUnique) { + return handleApiError( + request, + { + type: "bad_request", + details: [{ field: "contactAttributeKey", issue: "cannot delete unique contactAttributeKey" }], + }, + auditLog + ); + } + + const deletedContactAttributeKey = await deleteContactAttributeKey(params.contactAttributeKeyId); + + if (!deletedContactAttributeKey.ok) { + return handleApiError(request, deletedContactAttributeKey.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error + } + + if (auditLog) { + auditLog.oldObject = res.data; + } + + return responses.successResponse(deletedContactAttributeKey); + }, + action: "deleted", + targetType: "contactAttributeKey", + }); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts new file mode 100644 index 000000000000..b855994b92da --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { extendZodWithOpenApi } from "zod-openapi"; +import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys"; + +extendZodWithOpenApi(z); + +export const ZContactAttributeKeyIdSchema = z + .string() + .cuid2() + .openapi({ + ref: "contactAttributeKeyId", + description: "The ID of the contact attribute key", + param: { + name: "id", + in: "path", + }, + }); + +export const ZContactAttributeKeyUpdateSchema = ZContactAttributeKey.pick({ + name: true, + description: true, + key: true, +}).openapi({ + ref: "contactAttributeKeyUpdate", + description: "A contact attribute key to update.", +}); + +export type TContactAttributeKeyUpdateSchema = z.infer; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts new file mode 100644 index 000000000000..fb09bcb952f0 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts @@ -0,0 +1,87 @@ +import { getContactAttributeKeysQuery } from "@/modules/api/v2/management/contact-attribute-keys/lib/utils"; +import { + TContactAttributeKeyInput, + TGetContactAttributeKeysFilter, +} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { ContactAttributeKey, Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { Result, err, ok } from "@formbricks/types/error-handlers"; + +export const getContactAttributeKeys = reactCache( + async (environmentIds: string[], params: TGetContactAttributeKeysFilter) => { + try { + const query = getContactAttributeKeysQuery(environmentIds, params); + + const [keys, count] = await prisma.$transaction([ + prisma.contactAttributeKey.findMany({ + ...query, + }), + prisma.contactAttributeKey.count({ + where: query.where, + }), + ]); + + return ok({ data: keys, meta: { total: count, limit: params.limit, offset: params.skip } }); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "contactAttributeKeys", issue: error.message }], + }); + } + } +); + +export const createContactAttributeKey = async ( + contactAttributeKey: TContactAttributeKeyInput +): Promise> => { + const { environmentId, name, description, key } = contactAttributeKey; + + try { + const prismaData: Prisma.ContactAttributeKeyCreateInput = { + environment: { + connect: { + id: environmentId, + }, + }, + name, + description, + key, + }; + + const createdContactAttributeKey = await prisma.contactAttributeKey.create({ + data: prismaData, + }); + + return ok(createdContactAttributeKey); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if ( + error.code === PrismaErrorType.RecordDoesNotExist || + error.code === PrismaErrorType.RelatedRecordDoesNotExist + ) { + return err({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + if (error.code === PrismaErrorType.UniqueConstraintViolation) { + return err({ + type: "conflict", + details: [ + { + field: "contactAttributeKey", + issue: `Contact attribute key with "${contactAttributeKey.key}" already exists`, + }, + ], + }); + } + } + return err({ + type: "internal_server_error", + details: [{ field: "contactAttributeKey", issue: error.message }], + }); + } +}; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/openapi.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/openapi.ts index d33cdd8dd4f8..760c7d949c9d 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/openapi.ts @@ -8,24 +8,24 @@ import { ZGetContactAttributeKeysFilter, } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; import { managementServer } from "@/modules/api/v2/management/lib/openapi"; -import { z } from "zod"; +import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response"; import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi"; -import { ZContactAttributeKey } from "@formbricks/types/contact-attribute-key"; +import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys"; export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = { operationId: "getContactAttributeKeys", summary: "Get contact attribute keys", description: "Gets contact attribute keys from the database.", - tags: ["Management API > Contact Attribute Keys"], + tags: ["Management API - Contact Attribute Keys"], requestParams: { - query: ZGetContactAttributeKeysFilter, + query: ZGetContactAttributeKeysFilter.sourceType(), }, responses: { "200": { description: "Contact attribute keys retrieved successfully.", content: { "application/json": { - schema: z.array(ZContactAttributeKey), + schema: responseWithMetaSchema(makePartialSchema(ZContactAttributeKey)), }, }, }, @@ -36,7 +36,7 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { operationId: "createContactAttributeKey", summary: "Create a contact attribute key", description: "Creates a contact attribute key in the database.", - tags: ["Management API > Contact Attribute Keys"], + tags: ["Management API - Contact Attribute Keys"], requestBody: { required: true, description: "The contact attribute key to create", @@ -49,6 +49,11 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { responses: { "201": { description: "Contact attribute key created successfully.", + content: { + "application/json": { + schema: makePartialSchema(ZContactAttributeKey), + }, + }, }, }, }; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts new file mode 100644 index 000000000000..455d09afb786 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts @@ -0,0 +1,152 @@ +import { + TContactAttributeKeyInput, + TGetContactAttributeKeysFilter, +} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; +import { ContactAttributeKey, Prisma } from "@prisma/client"; +import { describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { createContactAttributeKey, getContactAttributeKeys } from "../contact-attribute-key"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + $transaction: vi.fn(), + contactAttributeKey: { + findMany: vi.fn(), + count: vi.fn(), + create: vi.fn(), + }, + }, +})); + +describe("getContactAttributeKeys", () => { + const environmentIds = ["env1", "env2"]; + const params: TGetContactAttributeKeysFilter = { + limit: 10, + skip: 0, + order: "asc", + sortBy: "createdAt", + }; + const fakeContactAttributeKeys = [ + { id: "key1", environmentId: "env1", name: "Key One", key: "keyOne" }, + { id: "key2", environmentId: "env1", name: "Key Two", key: "keyTwo" }, + ]; + const count = fakeContactAttributeKeys.length; + + test("returns ok response with contact attribute keys and meta", async () => { + vi.mocked(prisma.$transaction).mockResolvedValueOnce([fakeContactAttributeKeys, count]); + + const result = await getContactAttributeKeys(environmentIds, params); + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.data.data).toEqual(fakeContactAttributeKeys); + expect(result.data.meta).toEqual({ + total: count, + limit: params.limit, + offset: params.skip, + }); + } + }); + + test("returns error when prisma.$transaction throws", async () => { + vi.mocked(prisma.$transaction).mockRejectedValueOnce(new Error("Test error")); + + const result = await getContactAttributeKeys(environmentIds, params); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error?.type).toEqual("internal_server_error"); + } + }); +}); + +describe("createContactAttributeKey", () => { + const inputContactAttributeKey: TContactAttributeKeyInput = { + environmentId: "env1", + name: "New Contact Attribute Key", + key: "newKey", + description: "Description for new key", + }; + + const createdContactAttributeKey: ContactAttributeKey = { + id: "key100", + environmentId: inputContactAttributeKey.environmentId, + name: inputContactAttributeKey.name, + key: inputContactAttributeKey.key, + description: inputContactAttributeKey.description, + isUnique: false, + type: "custom", + createdAt: new Date(), + updatedAt: new Date(), + }; + + test("creates a contact attribute key and revalidates cache", async () => { + vi.mocked(prisma.contactAttributeKey.create).mockResolvedValueOnce(createdContactAttributeKey); + + const result = await createContactAttributeKey(inputContactAttributeKey); + expect(prisma.contactAttributeKey.create).toHaveBeenCalled(); + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.data).toEqual(createdContactAttributeKey); + } + }); + + test("returns error when creation fails", async () => { + vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(new Error("Creation failed")); + + const result = await createContactAttributeKey(inputContactAttributeKey); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error.type).toEqual("internal_server_error"); + } + }); + + test("returns conflict error when key already exists", async () => { + const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(errToThrow); + + const result = await createContactAttributeKey(inputContactAttributeKey); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "conflict", + details: [ + { + field: "contactAttributeKey", + issue: 'Contact attribute key with "newKey" already exists', + }, + ], + }); + } + }); + + test("returns not found error when related record does not exist", async () => { + const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { + code: PrismaErrorType.RelatedRecordDoesNotExist, + clientVersion: "0.0.1", + }); + vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(errToThrow); + + const result = await createContactAttributeKey(inputContactAttributeKey); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "not_found", + details: [ + { + field: "contactAttributeKey", + issue: "not found", + }, + ], + }); + } + }); +}); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/utils.test.ts new file mode 100644 index 000000000000..4146b1f67716 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/utils.test.ts @@ -0,0 +1,106 @@ +import { TGetContactAttributeKeysFilter } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getContactAttributeKeysQuery } from "../utils"; + +describe("getContactAttributeKeysQuery", () => { + const environmentId = "env-123"; + const baseParams: TGetContactAttributeKeysFilter = { + limit: 10, + skip: 0, + order: "asc", + sortBy: "createdAt", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns query with environmentId in array when no params are provided", () => { + const environmentIds = ["env-1", "env-2"]; + const result = getContactAttributeKeysQuery(environmentIds); + + expect(result).toEqual({ + where: { + environmentId: { + in: environmentIds, + }, + }, + }); + }); + + test("applies common filters when provided", () => { + const environmentIds = ["env-1", "env-2"]; + const params: TGetContactAttributeKeysFilter = { + ...baseParams, + environmentId, + }; + const result = getContactAttributeKeysQuery(environmentIds, params); + + expect(result).toEqual({ + where: { + environmentId: { + in: environmentIds, + }, + }, + take: 10, + orderBy: { + createdAt: "asc", + }, + }); + }); + + test("applies date filters when provided", () => { + const environmentIds = ["env-1", "env-2"]; + const startDate = new Date("2023-01-01"); + const endDate = new Date("2023-12-31"); + + const params: TGetContactAttributeKeysFilter = { + ...baseParams, + environmentId, + startDate, + endDate, + }; + const result = getContactAttributeKeysQuery(environmentIds, params); + + expect(result).toEqual({ + where: { + environmentId: { + in: environmentIds, + }, + createdAt: { + gte: startDate, + lte: endDate, + }, + }, + take: 10, + orderBy: { + createdAt: "asc", + }, + }); + }); + + test("handles multiple filter parameters correctly", () => { + const environmentIds = ["env-1", "env-2"]; + const params: TGetContactAttributeKeysFilter = { + environmentId, + limit: 5, + skip: 10, + sortBy: "updatedAt", + order: "asc", + }; + const result = getContactAttributeKeysQuery(environmentIds, params); + + expect(result).toEqual({ + where: { + environmentId: { + in: environmentIds, + }, + }, + take: 5, + skip: 10, + orderBy: { + updatedAt: "asc", + }, + }); + }); +}); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/utils.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/utils.ts new file mode 100644 index 000000000000..5d4e1881c489 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/utils.ts @@ -0,0 +1,26 @@ +import { TGetContactAttributeKeysFilter } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; +import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils"; +import { Prisma } from "@prisma/client"; + +export const getContactAttributeKeysQuery = ( + environmentIds: string[], + params?: TGetContactAttributeKeysFilter +): Prisma.ContactAttributeKeyFindManyArgs => { + let query: Prisma.ContactAttributeKeyFindManyArgs = { + where: { + environmentId: { + in: environmentIds, + }, + }, + }; + + if (!params) return query; + + const baseFilter = pickCommonFilter(params); + + if (baseFilter) { + query = buildCommonFilterQuery(query, baseFilter); + } + + return query; +}; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts new file mode 100644 index 000000000000..719cade8c881 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts @@ -0,0 +1,85 @@ +import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; +import { responses } from "@/modules/api/v2/lib/response"; +import { handleApiError } from "@/modules/api/v2/lib/utils"; +import { + createContactAttributeKey, + getContactAttributeKeys, +} from "@/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key"; +import { + ZContactAttributeKeyInput, + ZGetContactAttributeKeysFilter, +} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { NextRequest } from "next/server"; + +export const GET = async (request: NextRequest) => + authenticatedApiClient({ + request, + schemas: { + query: ZGetContactAttributeKeysFilter.sourceType(), + }, + handler: async ({ authentication, parsedInput }) => { + const { query } = parsedInput; + + let environmentIds: string[] = []; + + if (query.environmentId) { + if (!hasPermission(authentication.environmentPermissions, query.environmentId, "GET")) { + return handleApiError(request, { + type: "unauthorized", + }); + } + environmentIds = [query.environmentId]; + } else { + environmentIds = authentication.environmentPermissions.map((permission) => permission.environmentId); + } + + const res = await getContactAttributeKeys(environmentIds, query); + + if (!res.ok) { + return handleApiError(request, res.error as ApiErrorResponseV2); + } + + return responses.successResponse(res.data); + }, + }); + +export const POST = async (request: NextRequest) => + authenticatedApiClient({ + request, + schemas: { + body: ZContactAttributeKeyInput, + }, + handler: async ({ authentication, parsedInput, auditLog }) => { + const { body } = parsedInput; + + if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) { + return handleApiError( + request, + { + type: "forbidden", + details: [ + { field: "environmentId", issue: "does not have permission to create contact attribute key" }, + ], + }, + auditLog + ); + } + + const createContactAttributeKeyResult = await createContactAttributeKey(body); + + if (!createContactAttributeKeyResult.ok) { + return handleApiError(request, createContactAttributeKeyResult.error, auditLog); + } + + if (auditLog) { + auditLog.targetId = createContactAttributeKeyResult.data.id; + auditLog.newObject = createContactAttributeKeyResult.data; + } + + return responses.createdResponse(createContactAttributeKeyResult); + }, + action: "created", + targetType: "contactAttributeKey", + }); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts index 24e02f2ee492..386d966c53bb 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts @@ -1,18 +1,13 @@ +import { ZGetFilter } from "@/modules/api/v2/types/api-filter"; import { z } from "zod"; import { extendZodWithOpenApi } from "zod-openapi"; import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys"; extendZodWithOpenApi(z); -export const ZGetContactAttributeKeysFilter = z - .object({ - limit: z.coerce.number().positive().min(1).max(100).optional().default(10), - skip: z.coerce.number().nonnegative().optional().default(0), - sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"), - order: z.enum(["asc", "desc"]).optional().default("desc"), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), - }) +export const ZGetContactAttributeKeysFilter = ZGetFilter.extend({ + environmentId: z.string().cuid2().optional().describe("The environment ID to filter by"), +}) .refine( (data) => { if (data.startDate && data.endDate && data.startDate > data.endDate) { @@ -23,13 +18,15 @@ export const ZGetContactAttributeKeysFilter = z { message: "startDate must be before endDate", } - ); + ) + .describe("Filter for retrieving contact attribute keys"); + +export type TGetContactAttributeKeysFilter = z.infer; export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({ key: true, name: true, description: true, - type: true, environmentId: true, }).openapi({ ref: "contactAttributeKeyInput", diff --git a/apps/web/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi.ts b/apps/web/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi.ts deleted file mode 100644 index 40ae2a16e4dc..000000000000 --- a/apps/web/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { ZContactAttributeInput } from "@/modules/api/v2/management/contact-attributes/types/contact-attributes"; -import { z } from "zod"; -import { ZodOpenApiOperationObject } from "zod-openapi"; -import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes"; - -export const getContactAttributeEndpoint: ZodOpenApiOperationObject = { - operationId: "getContactAttribute", - summary: "Get a contact attribute", - description: "Gets a contact attribute from the database.", - requestParams: { - path: z.object({ - contactAttributeId: z.string().cuid2(), - }), - }, - tags: ["Management API > Contact Attributes"], - responses: { - "200": { - description: "Contact retrieved successfully.", - content: { - "application/json": { - schema: ZContactAttribute, - }, - }, - }, - }, -}; - -export const deleteContactAttributeEndpoint: ZodOpenApiOperationObject = { - operationId: "deleteContactAttribute", - summary: "Delete a contact attribute", - description: "Deletes a contact attribute from the database.", - tags: ["Management API > Contact Attributes"], - requestParams: { - path: z.object({ - contactAttributeId: z.string().cuid2(), - }), - }, - responses: { - "200": { - description: "Contact deleted successfully.", - content: { - "application/json": { - schema: ZContactAttribute, - }, - }, - }, - }, -}; - -export const updateContactAttributeEndpoint: ZodOpenApiOperationObject = { - operationId: "updateContactAttribute", - summary: "Update a contact attribute", - description: "Updates a contact attribute in the database.", - tags: ["Management API > Contact Attributes"], - requestParams: { - path: z.object({ - contactAttributeId: z.string().cuid2(), - }), - }, - requestBody: { - required: true, - description: "The response to update", - content: { - "application/json": { - schema: ZContactAttributeInput, - }, - }, - }, - responses: { - "200": { - description: "Response updated successfully.", - content: { - "application/json": { - schema: ZContactAttribute, - }, - }, - }, - }, -}; diff --git a/apps/web/modules/api/v2/management/contact-attributes/lib/openapi.ts b/apps/web/modules/api/v2/management/contact-attributes/lib/openapi.ts deleted file mode 100644 index f7ff2af8200b..000000000000 --- a/apps/web/modules/api/v2/management/contact-attributes/lib/openapi.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - deleteContactAttributeEndpoint, - getContactAttributeEndpoint, - updateContactAttributeEndpoint, -} from "@/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi"; -import { - ZContactAttributeInput, - ZGetContactAttributesFilter, -} from "@/modules/api/v2/management/contact-attributes/types/contact-attributes"; -import { managementServer } from "@/modules/api/v2/management/lib/openapi"; -import { z } from "zod"; -import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi"; -import { ZContactAttribute } from "@formbricks/types/contact-attribute"; - -export const getContactAttributesEndpoint: ZodOpenApiOperationObject = { - operationId: "getContactAttributes", - summary: "Get contact attributes", - description: "Gets contact attributes from the database.", - tags: ["Management API > Contact Attributes"], - requestParams: { - query: ZGetContactAttributesFilter, - }, - responses: { - "200": { - description: "Contact attributes retrieved successfully.", - content: { - "application/json": { - schema: z.array(ZContactAttribute), - }, - }, - }, - }, -}; - -export const createContactAttributeEndpoint: ZodOpenApiOperationObject = { - operationId: "createContactAttribute", - summary: "Create a contact attribute", - description: "Creates a contact attribute in the database.", - tags: ["Management API > Contact Attributes"], - requestBody: { - required: true, - description: "The contact attribute to create", - content: { - "application/json": { - schema: ZContactAttributeInput, - }, - }, - }, - responses: { - "201": { - description: "Contact attribute created successfully.", - }, - }, -}; - -export const contactAttributePaths: ZodOpenApiPathsObject = { - "/contact-attributes": { - servers: managementServer, - get: getContactAttributesEndpoint, - post: createContactAttributeEndpoint, - }, - "/contact-attributes/{id}": { - servers: managementServer, - get: getContactAttributeEndpoint, - put: updateContactAttributeEndpoint, - delete: deleteContactAttributeEndpoint, - }, -}; diff --git a/apps/web/modules/api/v2/management/contact-attributes/types/contact-attributes.ts b/apps/web/modules/api/v2/management/contact-attributes/types/contact-attributes.ts deleted file mode 100644 index c3f3ca4fe82a..000000000000 --- a/apps/web/modules/api/v2/management/contact-attributes/types/contact-attributes.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from "zod"; -import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes"; - -export const ZGetContactAttributesFilter = z - .object({ - limit: z.coerce.number().positive().min(1).max(100).optional().default(10), - skip: z.coerce.number().nonnegative().optional().default(0), - sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"), - order: z.enum(["asc", "desc"]).optional().default("desc"), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), - }) - .refine( - (data) => { - if (data.startDate && data.endDate && data.startDate > data.endDate) { - return false; - } - return true; - }, - { - message: "startDate must be before endDate", - } - ); - -export const ZContactAttributeInput = ZContactAttribute.pick({ - attributeKeyId: true, - contactId: true, - value: true, -}).openapi({ - ref: "contactAttributeInput", - description: "Input data for creating or updating a contact attribute", -}); - -export type TContactAttributeInput = z.infer; diff --git a/apps/web/modules/api/v2/management/contacts/[contactId]/lib/openapi.ts b/apps/web/modules/api/v2/management/contacts/[contactId]/lib/openapi.ts deleted file mode 100644 index 481f37d53f87..000000000000 --- a/apps/web/modules/api/v2/management/contacts/[contactId]/lib/openapi.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { ZContactInput } from "@/modules/api/v2/management/contacts/types/contacts"; -import { z } from "zod"; -import { ZodOpenApiOperationObject } from "zod-openapi"; -import { ZContact } from "@formbricks/database/zod/contact"; - -export const getContactEndpoint: ZodOpenApiOperationObject = { - operationId: "getContact", - summary: "Get a contact", - description: "Gets a contact from the database.", - requestParams: { - path: z.object({ - contactId: z.string().cuid2(), - }), - }, - tags: ["Management API > Contacts"], - responses: { - "200": { - description: "Contact retrieved successfully.", - content: { - "application/json": { - schema: ZContact, - }, - }, - }, - }, -}; - -export const deleteContactEndpoint: ZodOpenApiOperationObject = { - operationId: "deleteContact", - summary: "Delete a contact", - description: "Deletes a contact from the database.", - tags: ["Management API > Contacts"], - requestParams: { - path: z.object({ - contactId: z.string().cuid2(), - }), - }, - responses: { - "200": { - description: "Contact deleted successfully.", - content: { - "application/json": { - schema: ZContact, - }, - }, - }, - }, -}; - -export const updateContactEndpoint: ZodOpenApiOperationObject = { - operationId: "updateContact", - summary: "Update a contact", - description: "Updates a contact in the database.", - tags: ["Management API > Contacts"], - requestParams: { - path: z.object({ - contactId: z.string().cuid2(), - }), - }, - requestBody: { - required: true, - description: "The response to update", - content: { - "application/json": { - schema: ZContactInput, - }, - }, - }, - responses: { - "200": { - description: "Response updated successfully.", - content: { - "application/json": { - schema: ZContact, - }, - }, - }, - }, -}; diff --git a/apps/web/modules/api/v2/management/contacts/lib/openapi.ts b/apps/web/modules/api/v2/management/contacts/lib/openapi.ts deleted file mode 100644 index 7ba8f433e1b8..000000000000 --- a/apps/web/modules/api/v2/management/contacts/lib/openapi.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - deleteContactEndpoint, - getContactEndpoint, - updateContactEndpoint, -} from "@/modules/api/v2/management/contacts/[contactId]/lib/openapi"; -import { ZContactInput, ZGetContactsFilter } from "@/modules/api/v2/management/contacts/types/contacts"; -import { managementServer } from "@/modules/api/v2/management/lib/openapi"; -import { z } from "zod"; -import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi"; -import { ZContact } from "@formbricks/database/zod/contact"; - -export const getContactsEndpoint: ZodOpenApiOperationObject = { - operationId: "getContacts", - summary: "Get contacts", - description: "Gets contacts from the database.", - requestParams: { - query: ZGetContactsFilter, - }, - tags: ["Management API > Contacts"], - responses: { - "200": { - description: "Contacts retrieved successfully.", - content: { - "application/json": { - schema: z.array(ZContact), - }, - }, - }, - }, -}; - -export const createContactEndpoint: ZodOpenApiOperationObject = { - operationId: "createContact", - summary: "Create a contact", - description: "Creates a contact in the database.", - tags: ["Management API > Contacts"], - requestBody: { - required: true, - description: "The contact to create", - content: { - "application/json": { - schema: ZContactInput, - }, - }, - }, - responses: { - "201": { - description: "Contact created successfully.", - content: { - "application/json": { - schema: ZContact, - }, - }, - }, - }, -}; - -export const contactPaths: ZodOpenApiPathsObject = { - "/contacts": { - servers: managementServer, - get: getContactsEndpoint, - post: createContactEndpoint, - }, - "/contacts/{id}": { - servers: managementServer, - get: getContactEndpoint, - put: updateContactEndpoint, - delete: deleteContactEndpoint, - }, -}; diff --git a/apps/web/modules/api/v2/management/contacts/types/contacts.ts b/apps/web/modules/api/v2/management/contacts/types/contacts.ts deleted file mode 100644 index acc5b7a93000..000000000000 --- a/apps/web/modules/api/v2/management/contacts/types/contacts.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { z } from "zod"; -import { extendZodWithOpenApi } from "zod-openapi"; -import { ZContact } from "@formbricks/database/zod/contact"; - -extendZodWithOpenApi(z); - -export const ZGetContactsFilter = z - .object({ - limit: z.coerce.number().positive().min(1).max(100).optional().default(10), - skip: z.coerce.number().nonnegative().optional().default(0), - sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"), - order: z.enum(["asc", "desc"]).optional().default("desc"), - startDate: z.coerce.date().optional(), - endDate: z.coerce.date().optional(), - }) - .refine( - (data) => { - if (data.startDate && data.endDate && data.startDate > data.endDate) { - return false; - } - return true; - }, - { - message: "startDate must be before endDate", - } - ); - -export const ZContactInput = ZContact.pick({ - userId: true, - environmentId: true, -}) - .partial({ - userId: true, - }) - .openapi({ - ref: "contactCreate", - description: "A contact to create", - }); - -export type TContactInput = z.infer; diff --git a/apps/web/modules/api/v2/management/lib/helper.ts b/apps/web/modules/api/v2/management/lib/helper.ts index 0e86d002e181..7b6be72c1d43 100644 --- a/apps/web/modules/api/v2/management/lib/helper.ts +++ b/apps/web/modules/api/v2/management/lib/helper.ts @@ -12,7 +12,7 @@ export const getEnvironmentId = async ( const result = await fetchEnvironmentId(id, isResponseId); if (!result.ok) { - return result; + return { ok: false, error: result.error as ApiErrorResponseV2 }; } return ok(result.data.environmentId); @@ -25,11 +25,13 @@ export const getEnvironmentId = async ( */ export const getEnvironmentIdFromSurveyIds = async ( surveyIds: string[] -): Promise> => { +): Promise> => { + if (surveyIds.length === 0) return ok(null); + const result = await fetchEnvironmentIdFromSurveyIds(surveyIds); if (!result.ok) { - return result; + return { ok: false, error: result.error as ApiErrorResponseV2 }; } // Check if all items in the array are the same diff --git a/apps/web/modules/api/v2/management/lib/services.ts b/apps/web/modules/api/v2/management/lib/services.ts index 94201657252a..611874d4613d 100644 --- a/apps/web/modules/api/v2/management/lib/services.ts +++ b/apps/web/modules/api/v2/management/lib/services.ts @@ -1,76 +1,55 @@ "use server"; -import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { responseCache } from "@formbricks/lib/response/cache"; -import { responseNoteCache } from "@formbricks/lib/responseNote/cache"; -import { surveyCache } from "@formbricks/lib/survey/cache"; -import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { err, ok } from "@formbricks/types/error-handlers"; -export const fetchEnvironmentId = reactCache(async (id: string, isResponseId: boolean) => - cache( - async (): Promise> => { - try { - const result = await prisma.survey.findFirst({ - where: isResponseId ? { responses: { some: { id } } } : { id }, - select: { - environmentId: true, - }, - }); +export const fetchEnvironmentId = reactCache(async (id: string, isResponseId: boolean) => { + try { + const result = await prisma.survey.findFirst({ + where: isResponseId ? { responses: { some: { id } } } : { id }, + select: { + environmentId: true, + }, + }); - if (!result) { - return err({ - type: "not_found", - details: [{ field: isResponseId ? "response" : "survey", issue: "not found" }], - }); - } - - return ok({ environmentId: result.environmentId }); - } catch (error) { - return err({ - type: "internal_server_error", - details: [{ field: isResponseId ? "response" : "survey", issue: error.message }], - }); - } - }, - [`services-getEnvironmentId-${id}-${isResponseId}`], - { - tags: [responseCache.tag.byId(id), responseNoteCache.tag.byResponseId(id), surveyCache.tag.byId(id)], + if (!result) { + return err({ + type: "not_found", + details: [{ field: isResponseId ? "response" : "survey", issue: "not found" }], + }); } - )() -); -export const fetchEnvironmentIdFromSurveyIds = reactCache(async (surveyIds: string[]) => - cache( - async (): Promise> => { - try { - const results = await prisma.survey.findMany({ - where: { id: { in: surveyIds } }, - select: { - environmentId: true, - }, - }); + return ok({ environmentId: result.environmentId }); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: isResponseId ? "response" : "survey", issue: error.message }], + }); + } +}); - if (results.length !== surveyIds.length) { - return err({ - type: "not_found", - details: [{ field: "survey", issue: "not found" }], - }); - } +export const fetchEnvironmentIdFromSurveyIds = reactCache(async (surveyIds: string[]) => { + try { + const results = await prisma.survey.findMany({ + where: { id: { in: surveyIds } }, + select: { + environmentId: true, + }, + }); - return ok(results.map((result) => result.environmentId)); - } catch (error) { - return err({ - type: "internal_server_error", - details: [{ field: "survey", issue: error.message }], - }); - } - }, - [`services-fetchEnvironmentIdFromSurveyIds-${surveyIds.join("-")}`], - { - tags: surveyIds.map((surveyId) => surveyCache.tag.byId(surveyId)), + if (results.length !== surveyIds.length) { + return err({ + type: "not_found", + details: [{ field: "survey", issue: "not found" }], + }); } - )() -); + + return ok(results.map((result) => result.environmentId)); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "survey", issue: error.message }], + }); + } +}); diff --git a/apps/web/modules/api/v2/management/lib/tests/helper.test.ts b/apps/web/modules/api/v2/management/lib/tests/helper.test.ts index 845c61cd15d6..e2558706b58b 100644 --- a/apps/web/modules/api/v2/management/lib/tests/helper.test.ts +++ b/apps/web/modules/api/v2/management/lib/tests/helper.test.ts @@ -1,7 +1,7 @@ import { fetchEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/services"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { createId } from "@paralleldrive/cuid2"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { err, ok } from "@formbricks/types/error-handlers"; import { getEnvironmentId, getEnvironmentIdFromSurveyIds } from "../helper"; import { fetchEnvironmentId } from "../services"; @@ -12,7 +12,7 @@ vi.mock("../services", () => ({ })); describe("Tests for getEnvironmentId", () => { - it("should return environmentId for surveyId", async () => { + test("should return environmentId for surveyId", async () => { vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" })); const result = await getEnvironmentId("survey-id", false); @@ -22,7 +22,7 @@ describe("Tests for getEnvironmentId", () => { } }); - it("should return environmentId for responseId", async () => { + test("should return environmentId for responseId", async () => { vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" })); const result = await getEnvironmentId("response-id", true); @@ -32,7 +32,7 @@ describe("Tests for getEnvironmentId", () => { } }); - it("should return error if getSurveyAndEnvironmentId fails", async () => { + test("should return error if getSurveyAndEnvironmentId fails", async () => { vi.mocked(fetchEnvironmentId).mockResolvedValue( err({ type: "not_found" } as unknown as ApiErrorResponseV2) ); @@ -49,7 +49,7 @@ describe("getEnvironmentIdFromSurveyIds", () => { const envId1 = createId(); const envId2 = createId(); - it("returns the common environment id when all survey ids are in the same environment", async () => { + test("returns the common environment id when all survey ids are in the same environment", async () => { vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({ ok: true, data: [envId1, envId1], @@ -58,7 +58,7 @@ describe("getEnvironmentIdFromSurveyIds", () => { expect(result).toEqual(ok(envId1)); }); - it("returns error when surveys are not in the same environment", async () => { + test("returns error when surveys are not in the same environment", async () => { vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({ ok: true, data: [envId1, envId2], @@ -73,7 +73,7 @@ describe("getEnvironmentIdFromSurveyIds", () => { } }); - it("returns error when API call fails", async () => { + test("returns error when API call fails", async () => { const apiError = { type: "server_error", details: [{ field: "api", issue: "failed" }], diff --git a/apps/web/modules/api/v2/management/lib/utils.ts b/apps/web/modules/api/v2/management/lib/utils.ts index 105cda612220..36d46ce1a1a1 100644 --- a/apps/web/modules/api/v2/management/lib/utils.ts +++ b/apps/web/modules/api/v2/management/lib/utils.ts @@ -14,7 +14,8 @@ type HasFindMany = | Prisma.ResponseFindManyArgs | Prisma.TeamFindManyArgs | Prisma.ProjectTeamFindManyArgs - | Prisma.UserFindManyArgs; + | Prisma.UserFindManyArgs + | Prisma.ContactAttributeKeyFindManyArgs; export function buildCommonFilterQuery(query: T, params: TGetFilter): T { const { limit, skip, sortBy, order, startDate, endDate } = params || {}; diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts index b13245d34352..3b3fd92a5a44 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts @@ -1,13 +1,12 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { Prisma } from "@prisma/client"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; -import { displayCache } from "@formbricks/lib/display/cache"; import { Result, err, ok } from "@formbricks/types/error-handlers"; export const deleteDisplay = async (displayId: string): Promise> => { try { - const display = await prisma.display.delete({ + await prisma.display.delete({ where: { id: displayId, }, @@ -18,15 +17,9 @@ export const deleteDisplay = async (displayId: string): Promise Responses"], + tags: ["Management API - Responses"], responses: { "200": { description: "Response retrieved successfully.", @@ -31,7 +31,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = { operationId: "deleteResponse", summary: "Delete a response", description: "Deletes a response from the database.", - tags: ["Management API > Responses"], + tags: ["Management API - Responses"], requestParams: { path: z.object({ id: ZResponseIdSchema, @@ -53,7 +53,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = { operationId: "updateResponse", summary: "Update a response", description: "Updates a response in the database.", - tags: ["Management API > Responses"], + tags: ["Management API - Responses"], requestParams: { path: z.object({ id: ZResponseIdSchema, diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts index a9d890fe2896..666670eefdf7 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts @@ -3,45 +3,33 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils"; import { ZResponseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { Response } from "@prisma/client"; -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { Prisma, Response } from "@prisma/client"; import { cache as reactCache } from "react"; import { z } from "zod"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; -import { cache } from "@formbricks/lib/cache"; -import { responseCache } from "@formbricks/lib/response/cache"; -import { responseNoteCache } from "@formbricks/lib/responseNote/cache"; import { Result, err, ok } from "@formbricks/types/error-handlers"; -export const getResponse = reactCache(async (responseId: string) => - cache( - async (): Promise> => { - try { - const responsePrisma = await prisma.response.findUnique({ - where: { - id: responseId, - }, - }); - - if (!responsePrisma) { - return err({ type: "not_found", details: [{ field: "response", issue: "not found" }] }); - } +export const getResponse = reactCache(async (responseId: string) => { + try { + const responsePrisma = await prisma.response.findUnique({ + where: { + id: responseId, + }, + }); - return ok(responsePrisma); - } catch (error) { - return err({ - type: "internal_server_error", - details: [{ field: "response", issue: error.message }], - }); - } - }, - [`management-getResponse-${responseId}`], - { - tags: [responseCache.tag.byId(responseId), responseNoteCache.tag.byResponseId(responseId)], + if (!responsePrisma) { + return err({ type: "not_found", details: [{ field: "response", issue: "not found" }] }); } - )() -); + + return ok(responsePrisma); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "response", issue: error.message }], + }); + } +}); export const deleteResponse = async (responseId: string): Promise> => { try { @@ -60,24 +48,14 @@ export const deleteResponse = async (responseId: string): Promise - cache( - async (): Promise, ApiErrorResponseV2>> => { - try { - const survey = await prisma.survey.findUnique({ - where: { - id: surveyId, - }, - select: { - environmentId: true, - questions: true, - }, - }); +export const getSurveyQuestions = reactCache(async (surveyId: string) => { + try { + const survey = await prisma.survey.findUnique({ + where: { + id: surveyId, + }, + select: { + environmentId: true, + questions: true, + }, + }); - if (!survey) { - return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] }); - } - - return ok(survey); - } catch (error) { - return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] }); - } - }, - [`management-getSurveyQuestions-${surveyId}`], - { - tags: [surveyCache.tag.byId(surveyId)], + if (!survey) { + return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] }); } - )() -); + + return ok(survey); + } catch (error) { + return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] }); + } +}); diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/utils.mock.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/utils.mock.ts index bf1d7c53e716..9d9fb4ace8f2 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/utils.mock.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/utils.mock.ts @@ -12,7 +12,6 @@ export const openTextQuestion: Survey["questions"][number] = { inputType: "text", required: true, headline: { en: "Open Text Question" }, - insightsEnabled: true, }; export const fileUploadQuestion: Survey["questions"][number] = { diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/display.test.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/display.test.ts index d0cf12064741..7e50ddecff5b 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/display.test.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/display.test.ts @@ -1,5 +1,5 @@ import { displayId, mockDisplay } from "./__mocks__/display.mock"; -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { Prisma } from "@prisma/client"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; @@ -39,7 +39,7 @@ describe("Display Lib", () => { test("return a not_found error when the display is not found", async () => { vi.mocked(prisma.display.delete).mockRejectedValue( - new PrismaClientKnownRequestError("Display not found", { + new Prisma.PrismaClientKnownRequestError("Display not found", { code: PrismaErrorType.RelatedRecordDoesNotExist, clientVersion: "1.0.0", meta: { diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts index edd9fb78d60a..c22b0fe7f9ec 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts @@ -1,5 +1,5 @@ import { response, responseId, responseInput, survey } from "./__mocks__/response.mock"; -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { Prisma } from "@prisma/client"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; @@ -154,7 +154,7 @@ describe("Response Lib", () => { test("handle prisma client error code P2025", async () => { vi.mocked(prisma.response.delete).mockRejectedValue( - new PrismaClientKnownRequestError("Response not found", { + new Prisma.PrismaClientKnownRequestError("Response not found", { code: PrismaErrorType.RelatedRecordDoesNotExist, clientVersion: "1.0.0", meta: { @@ -175,7 +175,7 @@ describe("Response Lib", () => { }); describe("updateResponse", () => { - test("update the response and revalidate caches", async () => { + test("update the response and revalidate caches including singleUseId", async () => { vi.mocked(prisma.response.update).mockResolvedValue(response); const result = await updateResponse(responseId, responseInput); @@ -190,9 +190,25 @@ describe("Response Lib", () => { } }); + test("update the response and revalidate caches", async () => { + const responseWithoutSingleUseId = { ...response, singleUseId: null }; + vi.mocked(prisma.response.update).mockResolvedValue(responseWithoutSingleUseId); + + const result = await updateResponse(responseId, responseInput); + expect(prisma.response.update).toHaveBeenCalledWith({ + where: { id: responseId }, + data: responseInput, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toEqual(responseWithoutSingleUseId); + } + }); + test("return a not_found error when the response is not found", async () => { vi.mocked(prisma.response.update).mockRejectedValue( - new PrismaClientKnownRequestError("Response not found", { + new Prisma.PrismaClientKnownRequestError("Response not found", { code: PrismaErrorType.RelatedRecordDoesNotExist, clientVersion: "1.0.0", meta: { diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts index b1908799b8c8..a19b040c4ec6 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/utils.test.ts @@ -1,6 +1,6 @@ import { environmentId, fileUploadQuestion, openTextQuestion, responseData } from "./__mocks__/utils.mock"; +import { deleteFile } from "@/lib/storage/service"; import { beforeEach, describe, expect, test, vi } from "vitest"; -import { deleteFile } from "@formbricks/lib/storage/service"; import { logger } from "@formbricks/logger"; import { okVoid } from "@formbricks/types/error-handlers"; import { findAndDeleteUploadedFilesInResponse } from "../utils"; @@ -11,7 +11,7 @@ vi.mock("@formbricks/logger", () => ({ }, })); -vi.mock("@formbricks/lib/storage/service", () => ({ +vi.mock("@/lib/storage/service", () => ({ deleteFile: vi.fn(), })); diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts index 11655b2e09c3..b76fbd62f245 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts @@ -1,6 +1,6 @@ +import { deleteFile } from "@/lib/storage/service"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { Response, Survey } from "@prisma/client"; -import { deleteFile } from "@formbricks/lib/storage/service"; import { logger } from "@formbricks/logger"; import { Result, okVoid } from "@formbricks/types/error-handlers"; import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts index f71b66d7b156..3d85273e7e9b 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts @@ -1,4 +1,6 @@ +import { validateFileUploads } from "@/lib/fileValidation"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; +import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question"; import { responses } from "@/modules/api/v2/lib/response"; import { handleApiError } from "@/modules/api/v2/lib/utils"; import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper"; @@ -7,6 +9,8 @@ import { getResponse, updateResponse, } from "@/modules/api/v2/management/responses/[responseId]/lib/response"; +import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { z } from "zod"; import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses"; @@ -41,7 +45,7 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI const response = await getResponse(params.responseId); if (!response.ok) { - return handleApiError(request, response.error); + return handleApiError(request, response.error as ApiErrorResponseV2); } return responses.successResponse(response); @@ -55,35 +59,53 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon params: z.object({ responseId: ZResponseIdSchema }), }, externalParams: props.params, - handler: async ({ authentication, parsedInput }) => { + handler: async ({ authentication, parsedInput, auditLog }) => { const { params } = parsedInput; + if (auditLog) { + auditLog.targetId = params.responseId; + } + if (!params) { - return handleApiError(request, { - type: "bad_request", - details: [{ field: "params", issue: "missing" }], - }); + return handleApiError( + request, + { + type: "bad_request", + details: [{ field: "params", issue: "missing" }], + }, + auditLog + ); } const environmentIdResult = await getEnvironmentId(params.responseId, true); if (!environmentIdResult.ok) { - return handleApiError(request, environmentIdResult.error); + return handleApiError(request, environmentIdResult.error, auditLog); } if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "DELETE")) { - return handleApiError(request, { - type: "unauthorized", - }); + return handleApiError( + request, + { + type: "unauthorized", + }, + auditLog + ); } const response = await deleteResponse(params.responseId); if (!response.ok) { - return handleApiError(request, response.error); + return handleApiError(request, response.error, auditLog); + } + + if (auditLog) { + auditLog.oldObject = response.data; } return responses.successResponse(response); }, + action: "deleted", + targetType: "response", }); export const PUT = (request: Request, props: { params: Promise<{ responseId: string }> }) => @@ -94,33 +116,93 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str params: z.object({ responseId: ZResponseIdSchema }), body: ZResponseUpdateSchema, }, - handler: async ({ authentication, parsedInput }) => { + handler: async ({ authentication, parsedInput, auditLog }) => { const { body, params } = parsedInput; if (!body || !params) { - return handleApiError(request, { - type: "bad_request", - details: [{ field: !body ? "body" : "params", issue: "missing" }], - }); + return handleApiError( + request, + { + type: "bad_request", + details: [{ field: !body ? "body" : "params", issue: "missing" }], + }, + auditLog + ); } const environmentIdResult = await getEnvironmentId(params.responseId, true); if (!environmentIdResult.ok) { - return handleApiError(request, environmentIdResult.error); + return handleApiError(request, environmentIdResult.error, auditLog); } if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "PUT")) { + return handleApiError( + request, + { + type: "unauthorized", + }, + auditLog + ); + } + + const existingResponse = await getResponse(params.responseId); + + if (!existingResponse.ok) { + return handleApiError(request, existingResponse.error as ApiErrorResponseV2, auditLog); + } + + const questionsResponse = await getSurveyQuestions(existingResponse.data.surveyId); + + if (!questionsResponse.ok) { + return handleApiError(request, questionsResponse.error as ApiErrorResponseV2, auditLog); + } + + if (!validateFileUploads(body.data, questionsResponse.data.questions)) { + return handleApiError( + request, + { + type: "bad_request", + details: [{ field: "response", issue: "Invalid file upload response" }], + }, + auditLog + ); + } + + // Validate response data for "other" options exceeding character limit + const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({ + responseData: body.data, + surveyQuestions: questionsResponse.data.questions, + responseLanguage: body.language ?? undefined, + }); + + if (otherResponseInvalidQuestionId) { return handleApiError(request, { - type: "unauthorized", + type: "bad_request", + details: [ + { + field: "response", + issue: `Response for question ${otherResponseInvalidQuestionId} exceeds character limit`, + meta: { + questionId: otherResponseInvalidQuestionId, + }, + }, + ], }); } const response = await updateResponse(params.responseId, body); if (!response.ok) { - return handleApiError(request, response.error); + return handleApiError(request, response.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error + } + + if (auditLog) { + auditLog.oldObject = existingResponse.data; + auditLog.newObject = response.data; } return responses.successResponse(response); }, + action: "updated", + targetType: "response", }); diff --git a/apps/web/modules/api/v2/management/responses/lib/openapi.ts b/apps/web/modules/api/v2/management/responses/lib/openapi.ts index 62ee0c87cb4e..b99eafae6654 100644 --- a/apps/web/modules/api/v2/management/responses/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/responses/lib/openapi.ts @@ -16,7 +16,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = { requestParams: { query: ZGetResponsesFilter.sourceType(), }, - tags: ["Management API > Responses"], + tags: ["Management API - Responses"], responses: { "200": { description: "Responses retrieved successfully.", @@ -33,7 +33,7 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = { operationId: "createResponse", summary: "Create a response", description: "Creates a response in the database.", - tags: ["Management API > Responses"], + tags: ["Management API - Responses"], requestBody: { required: true, description: "The response to create", diff --git a/apps/web/modules/api/v2/management/responses/lib/organization.ts b/apps/web/modules/api/v2/management/responses/lib/organization.ts index 334f892e0279..cb15fc497ab5 100644 --- a/apps/web/modules/api/v2/management/responses/lib/organization.ts +++ b/apps/web/modules/api/v2/management/responses/lib/organization.ts @@ -1,186 +1,136 @@ -import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { Organization } from "@prisma/client"; +import { getBillingPeriodStartDate } from "@/lib/utils/billing"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { organizationCache } from "@formbricks/lib/organization/cache"; -import { Result, err, ok } from "@formbricks/types/error-handlers"; - -export const getOrganizationIdFromEnvironmentId = reactCache(async (environmentId: string) => - cache( - async (): Promise> => { - try { - const organization = await prisma.organization.findFirst({ - where: { - projects: { +import { err, ok } from "@formbricks/types/error-handlers"; + +export const getOrganizationIdFromEnvironmentId = reactCache(async (environmentId: string) => { + try { + const organization = await prisma.organization.findFirst({ + where: { + projects: { + some: { + environments: { some: { - environments: { - some: { - id: environmentId, - }, - }, + id: environmentId, }, }, }, - select: { - id: true, - }, - }); - - if (!organization) { - return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] }); - } - - return ok(organization.id); - } catch (error) { - return err({ - type: "internal_server_error", - details: [{ field: "organization", issue: error.message }], - }); - } - }, - [`management-getOrganizationIdFromEnvironmentId-${environmentId}`], - { - tags: [organizationCache.tag.byEnvironmentId(environmentId)], + }, + }, + select: { + id: true, + }, + }); + + if (!organization) { + return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] }); } - )() -); - -export const getOrganizationBilling = reactCache(async (organizationId: string) => - cache( - async (): Promise> => { - try { - const organization = await prisma.organization.findFirst({ - where: { - id: organizationId, - }, - select: { - billing: true, - }, - }); - - if (!organization) { - return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] }); - } - - return ok(organization.billing); - } catch (error) { - return err({ - type: "internal_server_error", - details: [{ field: "organization", issue: error.message }], - }); - } - }, - [`management-getOrganizationBilling-${organizationId}`], - { - tags: [organizationCache.tag.byId(organizationId)], + + return ok(organization.id); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "organization", issue: error.message }], + }); + } +}); + +export const getOrganizationBilling = reactCache(async (organizationId: string) => { + try { + const organization = await prisma.organization.findFirst({ + where: { + id: organizationId, + }, + select: { + billing: true, + }, + }); + + if (!organization) { + return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] }); } - )() -); - -export const getAllEnvironmentsFromOrganizationId = reactCache(async (organizationId: string) => - cache( - async (): Promise> => { - try { - const organization = await prisma.organization.findUnique({ - where: { - id: organizationId, - }, + return ok(organization.billing); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "organization", issue: error.message }], + }); + } +}); + +export const getAllEnvironmentsFromOrganizationId = reactCache(async (organizationId: string) => { + try { + const organization = await prisma.organization.findUnique({ + where: { + id: organizationId, + }, + + select: { + projects: { select: { - projects: { + environments: { select: { - environments: { - select: { - id: true, - }, - }, + id: true, }, }, }, - }); - - if (!organization) { - return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] }); - } - - const environmentIds = organization.projects - .flatMap((project) => project.environments) - .map((environment) => environment.id); - - return ok(environmentIds); - } catch (error) { - return err({ - type: "internal_server_error", - details: [{ field: "organization", issue: error.message }], - }); - } - }, - [`management-getAllEnvironmentsFromOrganizationId-${organizationId}`], - { - tags: [organizationCache.tag.byId(organizationId)], + }, + }, + }); + + if (!organization) { + return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] }); } - )() -); - -export const getMonthlyOrganizationResponseCount = reactCache(async (organizationId: string) => - cache( - async (): Promise> => { - try { - const billing = await getOrganizationBilling(organizationId); - if (!billing.ok) { - return err(billing.error); - } - - // Determine the start date based on the plan type - let startDate: Date; - - if (billing.data.plan === "free") { - // For free plans, use the first day of the current calendar month - const now = new Date(); - startDate = new Date(now.getFullYear(), now.getMonth(), 1); - } else { - // For other plans, use the periodStart from billing - if (!billing.data.periodStart) { - return err({ - type: "internal_server_error", - details: [{ field: "organization", issue: "billing period start is not set" }], - }); - } - startDate = billing.data.periodStart; - } - - // Get all environment IDs for the organization - const environmentIdsResult = await getAllEnvironmentsFromOrganizationId(organizationId); - if (!environmentIdsResult.ok) { - return err(environmentIdsResult.error); - } - - // Use Prisma's aggregate to count responses for all environments - const responseAggregations = await prisma.response.aggregate({ - _count: { - id: true, - }, - where: { - AND: [ - { survey: { environmentId: { in: environmentIdsResult.data } } }, - { createdAt: { gte: startDate } }, - ], - }, - }); - - // The result is an aggregation of the total count - return ok(responseAggregations._count.id); - } catch (error) { - return err({ - type: "internal_server_error", - details: [{ field: "organization", issue: error.message }], - }); - } - }, - [`management-getMonthlyOrganizationResponseCount-${organizationId}`], - { - revalidate: 60 * 60 * 2, // 2 hours + + const environmentIds = organization.projects + .flatMap((project) => project.environments) + .map((environment) => environment.id); + + return ok(environmentIds); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "organization", issue: error.message }], + }); + } +}); + +export const getMonthlyOrganizationResponseCount = reactCache(async (organizationId: string) => { + try { + const billing = await getOrganizationBilling(organizationId); + if (!billing.ok) { + return err(billing.error); } - )() -); + + // Determine the start date based on the plan type + const startDate = getBillingPeriodStartDate(billing.data); + + // Get all environment IDs for the organization + const environmentIdsResult = await getAllEnvironmentsFromOrganizationId(organizationId); + if (!environmentIdsResult.ok) { + return err(environmentIdsResult.error); + } + + // Use Prisma's aggregate to count responses for all environments + const responseAggregations = await prisma.response.aggregate({ + _count: { + id: true, + }, + where: { + AND: [ + { survey: { environmentId: { in: environmentIdsResult.data } } }, + { createdAt: { gte: startDate } }, + ], + }, + }); + + // The result is an aggregation of the total count + return ok(responseAggregations._count.id); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "organization", issue: error.message }], + }); + } +}); diff --git a/apps/web/modules/api/v2/management/responses/lib/response.ts b/apps/web/modules/api/v2/management/responses/lib/response.ts index 2eb80bf9ed5e..e119ba9c32fb 100644 --- a/apps/web/modules/api/v2/management/responses/lib/response.ts +++ b/apps/web/modules/api/v2/management/responses/lib/response.ts @@ -1,4 +1,8 @@ import "server-only"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer"; +import { calculateTtcTotal } from "@/lib/response/utils"; +import { captureTelemetry } from "@/lib/telemetry"; import { getMonthlyOrganizationResponseCount, getOrganizationBilling, @@ -10,12 +14,6 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success"; import { Prisma, Response } from "@prisma/client"; import { prisma } from "@formbricks/database"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; -import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer"; -import { responseCache } from "@formbricks/lib/response/cache"; -import { calculateTtcTotal } from "@formbricks/lib/response/utils"; -import { responseNoteCache } from "@formbricks/lib/responseNote/cache"; -import { captureTelemetry } from "@formbricks/lib/telemetry"; import { logger } from "@formbricks/logger"; import { Result, err, ok } from "@formbricks/types/error-handlers"; @@ -71,12 +69,12 @@ export const createResponse = async ( const organizationIdResult = await getOrganizationIdFromEnvironmentId(environmentId); if (!organizationIdResult.ok) { - return err(organizationIdResult.error); + return err(organizationIdResult.error as ApiErrorResponseV2); } const billing = await getOrganizationBilling(organizationIdResult.data); if (!billing.ok) { - return err(billing.error); + return err(billing.error as ApiErrorResponseV2); } const billingData = billing.data; @@ -84,21 +82,10 @@ export const createResponse = async ( data: prismaData, }); - responseCache.revalidate({ - environmentId, - id: response.id, - ...(singleUseId && { singleUseId }), - surveyId, - }); - - responseNoteCache.revalidate({ - responseId: response.id, - }); - if (IS_FORMBRICKS_CLOUD) { const responsesCountResult = await getMonthlyOrganizationResponseCount(organizationIdResult.data); if (!responsesCountResult.ok) { - return err(responsesCountResult.error); + return err(responsesCountResult.error as ApiErrorResponseV2); } const responsesCount = responsesCountResult.data; @@ -135,14 +122,11 @@ export const getResponses = async ( ): Promise, ApiErrorResponseV2>> => { try { const query = getResponsesQuery(environmentIds, params); + const whereClause = query.where; - const [responses, count] = await prisma.$transaction([ - prisma.response.findMany({ - ...query, - }), - prisma.response.count({ - where: query.where, - }), + const [responses, totalCount] = await Promise.all([ + prisma.response.findMany(query), + prisma.response.count({ where: whereClause }), ]); if (!responses) { @@ -152,7 +136,7 @@ export const getResponses = async ( return ok({ data: responses, meta: { - total: count, + total: totalCount, limit: params.limit, offset: params.skip, }, diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts index 524749896cc6..07d3b5dfcb88 100644 --- a/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts +++ b/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts @@ -9,6 +9,7 @@ import { responseInputWithoutDisplay, responseInputWithoutTtc, } from "./__mocks__/response.mock"; +import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer"; import { getMonthlyOrganizationResponseCount, getOrganizationBilling, @@ -16,11 +17,10 @@ import { } from "@/modules/api/v2/management/responses/lib/organization"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer"; import { err, ok } from "@formbricks/types/error-handlers"; import { createResponse, getResponses } from "../response"; -vi.mock("@formbricks/lib/posthogServer", () => ({ +vi.mock("@/lib/posthogServer", () => ({ sendPlanLimitsReachedEventToPosthogWeekly: vi.fn().mockResolvedValue(undefined), })); @@ -40,7 +40,7 @@ vi.mock("@formbricks/database", () => ({ }, })); -vi.mock("@formbricks/lib/constants", () => ({ +vi.mock("@/lib/constants", () => ({ IS_FORMBRICKS_CLOUD: true, IS_PRODUCTION: false, })); @@ -214,17 +214,18 @@ describe("Response Lib", () => { describe("getResponses", () => { test("return responses with meta information", async () => { - const responses = [response]; - prisma.$transaction = vi.fn().mockResolvedValue([responses, responses.length]); + (prisma.response.findMany as any).mockResolvedValue([response]); + (prisma.response.count as any).mockResolvedValue(1); - const result = await getResponses(environmentId, responseFilter); - expect(prisma.$transaction).toHaveBeenCalled(); + const result = await getResponses([environmentId], responseFilter); + expect(prisma.response.findMany).toHaveBeenCalled(); + expect(prisma.response.count).toHaveBeenCalled(); expect(result.ok).toBe(true); if (result.ok) { expect(result.data).toEqual({ data: [response], meta: { - total: responses.length, + total: 1, limit: responseFilter.limit, offset: responseFilter.skip, }, @@ -233,9 +234,10 @@ describe("Response Lib", () => { }); test("return a not_found error if responses are not found", async () => { - prisma.$transaction = vi.fn().mockResolvedValue([null, 0]); + (prisma.response.findMany as any).mockResolvedValue(null); + (prisma.response.count as any).mockResolvedValue(0); - const result = await getResponses(environmentId, responseFilter); + const result = await getResponses([environmentId], responseFilter); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toEqual({ @@ -245,10 +247,25 @@ describe("Response Lib", () => { } }); - test("return an internal_server_error error if prisma transaction fails", async () => { - prisma.$transaction = vi.fn().mockRejectedValue(new Error("Internal server error")); + test("return an internal_server_error error if prisma findMany fails", async () => { + (prisma.response.findMany as any).mockRejectedValue(new Error("Internal server error")); + (prisma.response.count as any).mockResolvedValue(0); - const result = await getResponses(environmentId, responseFilter); + const result = await getResponses([environmentId], responseFilter); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toEqual({ + type: "internal_server_error", + details: [{ field: "responses", issue: "Internal server error" }], + }); + } + }); + + test("return an internal_server_error error if prisma count fails", async () => { + (prisma.response.findMany as any).mockResolvedValue([response]); + (prisma.response.count as any).mockRejectedValue(new Error("Internal server error")); + + const result = await getResponses([environmentId], responseFilter); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toEqual({ diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts index 14c0ab4fce18..4c4331b6a294 100644 --- a/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts +++ b/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts @@ -1,7 +1,7 @@ import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils"; import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses"; import { Prisma } from "@prisma/client"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { getResponsesQuery } from "../utils"; vi.mock("@/modules/api/v2/management/lib/utils", () => ({ @@ -10,17 +10,17 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({ })); describe("getResponsesQuery", () => { - it("adds surveyId to where clause if provided", () => { + test("adds surveyId to where clause if provided", () => { const result = getResponsesQuery(["env-id"], { surveyId: "survey123" } as TGetResponsesFilter); expect(result?.where?.surveyId).toBe("survey123"); }); - it("adds contactId to where clause if provided", () => { + test("adds contactId to where clause if provided", () => { const result = getResponsesQuery(["env-id"], { contactId: "contact123" } as TGetResponsesFilter); expect(result?.where?.contactId).toBe("contact123"); }); - it("calls pickCommonFilter & buildCommonFilterQuery with correct arguments", () => { + test("calls pickCommonFilter & buildCommonFilterQuery with correct arguments", () => { vi.mocked(pickCommonFilter).mockReturnValueOnce({ someFilter: true } as any); vi.mocked(buildCommonFilterQuery).mockReturnValueOnce({ where: { combined: true } as any }); diff --git a/apps/web/modules/api/v2/management/responses/route.ts b/apps/web/modules/api/v2/management/responses/route.ts index 43961806ec81..3e0879a26288 100644 --- a/apps/web/modules/api/v2/management/responses/route.ts +++ b/apps/web/modules/api/v2/management/responses/route.ts @@ -1,8 +1,12 @@ +import { validateFileUploads } from "@/lib/fileValidation"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; +import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question"; import { responses } from "@/modules/api/v2/lib/response"; import { handleApiError } from "@/modules/api/v2/lib/utils"; import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper"; +import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey"; import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { Response } from "@prisma/client"; import { NextRequest } from "next/server"; @@ -47,28 +51,36 @@ export const POST = async (request: Request) => schemas: { body: ZResponseInput, }, - handler: async ({ authentication, parsedInput }) => { + handler: async ({ authentication, parsedInput, auditLog }) => { const { body } = parsedInput; if (!body) { - return handleApiError(request, { - type: "bad_request", - details: [{ field: "body", issue: "missing" }], - }); + return handleApiError( + request, + { + type: "bad_request", + details: [{ field: "body", issue: "missing" }], + }, + auditLog + ); } const environmentIdResult = await getEnvironmentId(body.surveyId, false); if (!environmentIdResult.ok) { - return handleApiError(request, environmentIdResult.error); + return handleApiError(request, environmentIdResult.error, auditLog); } const environmentId = environmentIdResult.data; if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) { - return handleApiError(request, { - type: "unauthorized", - }); + return handleApiError( + request, + { + type: "unauthorized", + }, + auditLog + ); } // if there is a createdAt but no updatedAt, set updatedAt to createdAt @@ -76,11 +88,56 @@ export const POST = async (request: Request) => body.updatedAt = body.createdAt; } + const surveyQuestions = await getSurveyQuestions(body.surveyId); + if (!surveyQuestions.ok) { + return handleApiError(request, surveyQuestions.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error + } + + if (!validateFileUploads(body.data, surveyQuestions.data.questions)) { + return handleApiError( + request, + { + type: "bad_request", + details: [{ field: "response", issue: "Invalid file upload response" }], + }, + auditLog + ); + } + + // Validate response data for "other" options exceeding character limit + const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({ + responseData: body.data, + surveyQuestions: surveyQuestions.data.questions, + responseLanguage: body.language ?? undefined, + }); + + if (otherResponseInvalidQuestionId) { + return handleApiError(request, { + type: "bad_request", + details: [ + { + field: "response", + issue: `Response for question ${otherResponseInvalidQuestionId} exceeds character limit`, + meta: { + questionId: otherResponseInvalidQuestionId, + }, + }, + ], + }); + } + const createResponseResult = await createResponse(environmentId, body); if (!createResponseResult.ok) { - return handleApiError(request, createResponseResult.error); + return handleApiError(request, createResponseResult.error, auditLog); + } + + if (auditLog) { + auditLog.targetId = createResponseResult.data.id; + auditLog.newObject = createResponseResult.data; } - return responses.successResponse({ data: createResponseResult.data }); + return responses.createdResponse({ data: createResponseResult.data }); }, + action: "created", + targetType: "response", }); diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/contacts.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/contacts.ts index 470709b1ea8a..9195b1243f55 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/contacts.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/contacts.ts @@ -1,37 +1,25 @@ -import { contactCache } from "@/lib/cache/contact"; -import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { Contact } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { err, ok } from "@formbricks/types/error-handlers"; -export const getContact = reactCache(async (contactId: string, environmentId: string) => - cache( - async (): Promise, ApiErrorResponseV2>> => { - try { - const contact = await prisma.contact.findUnique({ - where: { - id: contactId, - environmentId, - }, - select: { - id: true, - }, - }); +export const getContact = reactCache(async (contactId: string, environmentId: string) => { + try { + const contact = await prisma.contact.findUnique({ + where: { + id: contactId, + environmentId, + }, + select: { + id: true, + }, + }); - if (!contact) { - return err({ type: "not_found", details: [{ field: "contact", issue: "not found" }] }); - } - - return ok(contact); - } catch (error) { - return err({ type: "internal_server_error", details: [{ field: "contact", issue: error.message }] }); - } - }, - [`contact-link-getContact-${contactId}-${environmentId}`], - { - tags: [contactCache.tag.byId(contactId), contactCache.tag.byEnvironmentId(environmentId)], + if (!contact) { + return err({ type: "not_found", details: [{ field: "contact", issue: "not found" }] }); } - )() -); + + return ok(contact); + } catch (error) { + return err({ type: "internal_server_error", details: [{ field: "contact", issue: error.message }] }); + } +}); diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/openapi.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/openapi.ts index cd24956cb34d..96c2049fa5b1 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/openapi.ts @@ -10,7 +10,7 @@ export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = { requestParams: { path: ZContactLinkParams, }, - tags: ["Management API > Surveys > Contact Links"], + tags: ["Management API - Surveys - Contact Links"], responses: { "200": { description: "Personalized survey link retrieved successfully.", diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/response.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/response.ts index f1056bbd326e..489bfadf1ed1 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/response.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/response.ts @@ -1,37 +1,25 @@ -import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { Response } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { responseCache } from "@formbricks/lib/response/cache"; -import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { err, ok } from "@formbricks/types/error-handlers"; -export const getResponse = reactCache(async (contactId: string, surveyId: string) => - cache( - async (): Promise, ApiErrorResponseV2>> => { - try { - const response = await prisma.response.findFirst({ - where: { - contactId, - surveyId, - }, - select: { - id: true, - }, - }); +export const getResponse = reactCache(async (contactId: string, surveyId: string) => { + try { + const response = await prisma.response.findFirst({ + where: { + contactId, + surveyId, + }, + select: { + id: true, + }, + }); - if (!response) { - return err({ type: "not_found", details: [{ field: "response", issue: "not found" }] }); - } - - return ok(response); - } catch (error) { - return err({ type: "internal_server_error", details: [{ field: "response", issue: error.message }] }); - } - }, - [`contact-link-getResponse-${contactId}-${surveyId}`], - { - tags: [responseCache.tag.byId(contactId), responseCache.tag.bySurveyId(surveyId)], + if (!response) { + return err({ type: "not_found", details: [{ field: "response", issue: "not found" }] }); } - )() -); + + return ok(response); + } catch (error) { + return err({ type: "internal_server_error", details: [{ field: "response", issue: error.message }] }); + } +}); diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/surveys.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/surveys.ts index 10961540774a..6cc006d1dc93 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/surveys.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/surveys.ts @@ -1,35 +1,23 @@ -import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { Survey } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { surveyCache } from "@formbricks/lib/survey/cache"; -import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { err, ok } from "@formbricks/types/error-handlers"; -export const getSurvey = reactCache(async (surveyId: string) => - cache( - async (): Promise, ApiErrorResponseV2>> => { - try { - const survey = await prisma.survey.findUnique({ - where: { id: surveyId }, - select: { - id: true, - type: true, - }, - }); +export const getSurvey = reactCache(async (surveyId: string) => { + try { + const survey = await prisma.survey.findUnique({ + where: { id: surveyId }, + select: { + id: true, + type: true, + }, + }); - if (!survey) { - return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] }); - } - - return ok(survey); - } catch (error) { - return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] }); - } - }, - [`contact-link-getSurvey-${surveyId}`], - { - tags: [surveyCache.tag.byId(surveyId)], + if (!survey) { + return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] }); } - )() -); + + return ok(survey); + } catch (error) { + return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] }); + } +}); diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts index a428d826ce9a..8bf733ecfa4b 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts @@ -9,6 +9,7 @@ import { TContactLinkParams, ZContactLinkParams, } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; @@ -46,7 +47,7 @@ export const GET = async (request: Request, props: { params: Promise - cache( - async (): Promise> => { - try { - const contactAttributeKeys = await prisma.contactAttributeKey.findMany({ - where: { environmentId }, - select: { - key: true, - }, - }); +export const getContactAttributeKeys = reactCache(async (environmentId: string) => { + try { + const contactAttributeKeys = await prisma.contactAttributeKey.findMany({ + where: { environmentId }, + select: { + key: true, + }, + }); - const keys = contactAttributeKeys.map((key) => key.key); - return ok(keys); - } catch (error) { - return err({ - type: "internal_server_error", - details: [{ field: "contact attribute keys", issue: error.message }], - }); - } - }, - [`getContactAttributeKeys-contact-links-${environmentId}`], - { - tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)], - } - )() -); + const keys = contactAttributeKeys.map((key) => key.key); + return ok(keys); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "contact attribute keys", issue: error.message }], + }); + } +}); diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact.ts index 2dcaea191386..034f2276d3d4 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact.ts @@ -1,147 +1,135 @@ import { getContactAttributeKeys } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact-attribute-key"; import { getSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/segment"; import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/surveys"; -import { TContactWithAttributes } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success"; import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { segmentCache } from "@formbricks/lib/cache/segment"; -import { surveyCache } from "@formbricks/lib/survey/cache"; import { logger } from "@formbricks/logger"; -import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { err, ok } from "@formbricks/types/error-handlers"; export const getContactsInSegment = reactCache( - (surveyId: string, segmentId: string, limit: number, skip: number, attributeKeys?: string) => - cache( - async (): Promise, ApiErrorResponseV2>> => { - try { - const surveyResult = await getSurvey(surveyId); - if (!surveyResult.ok) { - return err(surveyResult.error); - } - - const survey = surveyResult.data; - - if (survey.type !== "link" || survey.status !== "inProgress") { - logger.error({ surveyId, segmentId }, "Survey is not a link survey or is not in progress"); - const error: ApiErrorResponseV2 = { - type: "forbidden", - details: [{ field: "surveyId", issue: "Invalid survey" }], - }; - return err(error); - } - - const segmentResult = await getSegment(segmentId); - if (!segmentResult.ok) { - return err(segmentResult.error); - } - - const segment = segmentResult.data; - - if (survey.environmentId !== segment.environmentId) { - logger.error({ surveyId, segmentId }, "Survey and segment are not in the same environment"); - const error: ApiErrorResponseV2 = { - type: "bad_request", - details: [{ field: "segmentId", issue: "Environment mismatch" }], - }; - return err(error); - } - - const segmentFilterToPrismaQueryResult = await segmentFilterToPrismaQuery( - segment.id, - segment.filters, - segment.environmentId - ); - - if (!segmentFilterToPrismaQueryResult.ok) { - return err(segmentFilterToPrismaQueryResult.error); - } - - const { whereClause } = segmentFilterToPrismaQueryResult.data; - - const contactAttributeKeysResult = await getContactAttributeKeys(segment.environmentId); - if (!contactAttributeKeysResult.ok) { - return err(contactAttributeKeysResult.error); - } - - const allAttributeKeys = contactAttributeKeysResult.data; - - const fieldArray = (attributeKeys || "").split(",").map((field) => field.trim()); - const attributesToInclude = fieldArray.filter((field) => allAttributeKeys.includes(field)); - - const allowedAttributes = attributesToInclude.slice(0, 20); - - const [totalContacts, contacts] = await prisma.$transaction([ - prisma.contact.count({ - where: whereClause, - }), - - prisma.contact.findMany({ - where: whereClause, - select: { - id: true, - attributes: { - where: { - attributeKey: { - key: { - in: allowedAttributes, - }, - }, + async (surveyId: string, segmentId: string, limit: number, skip: number, attributeKeys?: string) => { + try { + const surveyResult = await getSurvey(surveyId); + if (!surveyResult.ok) { + return err(surveyResult.error); + } + + const survey = surveyResult.data; + + if (survey.type !== "link" || survey.status !== "inProgress") { + logger.error({ surveyId, segmentId }, "Survey is not a link survey or is not in progress"); + const error: ApiErrorResponseV2 = { + type: "forbidden", + details: [{ field: "surveyId", issue: "Invalid survey" }], + }; + return err(error); + } + + const segmentResult = await getSegment(segmentId); + if (!segmentResult.ok) { + return err(segmentResult.error); + } + + const segment = segmentResult.data; + + if (survey.environmentId !== segment.environmentId) { + logger.error({ surveyId, segmentId }, "Survey and segment are not in the same environment"); + const error: ApiErrorResponseV2 = { + type: "bad_request", + details: [{ field: "segmentId", issue: "Environment mismatch" }], + }; + return err(error); + } + + const segmentFilterToPrismaQueryResult = await segmentFilterToPrismaQuery( + segment.id, + segment.filters, + segment.environmentId + ); + + if (!segmentFilterToPrismaQueryResult.ok) { + return err(segmentFilterToPrismaQueryResult.error); + } + + const { whereClause } = segmentFilterToPrismaQueryResult.data; + + const contactAttributeKeysResult = await getContactAttributeKeys(segment.environmentId); + if (!contactAttributeKeysResult.ok) { + return err(contactAttributeKeysResult.error); + } + + const allAttributeKeys = contactAttributeKeysResult.data; + + const fieldArray = (attributeKeys || "").split(",").map((field) => field.trim()); + const attributesToInclude = fieldArray.filter((field) => allAttributeKeys.includes(field)); + + const allowedAttributes = attributesToInclude.slice(0, 20); + + const [totalContacts, contacts] = await prisma.$transaction([ + prisma.contact.count({ + where: whereClause, + }), + + prisma.contact.findMany({ + where: whereClause, + select: { + id: true, + attributes: { + where: { + attributeKey: { + key: { + in: allowedAttributes, }, + }, + }, + select: { + attributeKey: { select: { - attributeKey: { - select: { - key: true, - }, - }, - value: true, + key: true, }, }, + value: true, }, - take: limit, - skip: skip, - orderBy: { - createdAt: "desc", - }, - }), - ]); - - const contactsWithAttributes = contacts.map((contact) => { - const attributes = contact.attributes.reduce( - (acc, attr) => { - acc[attr.attributeKey.key] = attr.value; - return acc; - }, - {} as Record - ); - return { - contactId: contact.id, - ...(Object.keys(attributes).length > 0 ? { attributes } : {}), - }; - }); - - return ok({ - data: contactsWithAttributes, - meta: { - total: totalContacts, - limit: limit, - offset: skip, }, - }); - } catch (error) { - logger.error({ error, surveyId, segmentId }, "Error getting contacts in segment"); - const apiError: ApiErrorResponseV2 = { - type: "internal_server_error", - }; - return err(apiError); - } - }, - [`getContactsInSegment-${surveyId}-${segmentId}-${attributeKeys}-${limit}-${skip}`], - { - tags: [segmentCache.tag.byId(segmentId), surveyCache.tag.byId(surveyId)], - } - )() + }, + take: limit, + skip: skip, + orderBy: { + createdAt: "desc", + }, + }), + ]); + + const contactsWithAttributes = contacts.map((contact) => { + const attributes = contact.attributes.reduce( + (acc, attr) => { + acc[attr.attributeKey.key] = attr.value; + return acc; + }, + {} as Record + ); + return { + contactId: contact.id, + ...(Object.keys(attributes).length > 0 ? { attributes } : {}), + }; + }); + + return ok({ + data: contactsWithAttributes, + meta: { + total: totalContacts, + limit: limit, + offset: skip, + }, + }); + } catch (error) { + logger.error({ error, surveyId, segmentId }, "Error getting contacts in segment"); + const apiError: ApiErrorResponseV2 = { + type: "internal_server_error", + }; + return err(apiError); + } + } ); diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/openapi.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/openapi.ts index efefb3502502..6ef2540bd803 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/openapi.ts @@ -10,7 +10,7 @@ export const getContactLinksBySegmentEndpoint: ZodOpenApiOperationObject = { operationId: "getContactLinksBySegment", summary: "Get survey links for contacts in a segment", description: "Generates personalized survey links for contacts in a segment.", - tags: ["Management API > Surveys > Contact Links"], + tags: ["Management API - Surveys - Contact Links"], requestParams: { path: ZContactLinksBySegmentParams, query: ZContactLinksBySegmentQuery, diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/segment.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/segment.ts index 0fe206a16a62..96a185770cfa 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/segment.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/segment.ts @@ -1,36 +1,24 @@ -import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { Segment } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { segmentCache } from "@formbricks/lib/cache/segment"; -import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { err, ok } from "@formbricks/types/error-handlers"; -export const getSegment = reactCache(async (segmentId: string) => - cache( - async (): Promise, ApiErrorResponseV2>> => { - try { - const segment = await prisma.segment.findUnique({ - where: { id: segmentId }, - select: { - id: true, - environmentId: true, - filters: true, - }, - }); +export const getSegment = reactCache(async (segmentId: string) => { + try { + const segment = await prisma.segment.findUnique({ + where: { id: segmentId }, + select: { + id: true, + environmentId: true, + filters: true, + }, + }); - if (!segment) { - return err({ type: "not_found", details: [{ field: "segment", issue: "not found" }] }); - } - - return ok(segment); - } catch (error) { - return err({ type: "internal_server_error", details: [{ field: "segment", issue: error.message }] }); - } - }, - [`contact-link-getSegment-${segmentId}`], - { - tags: [segmentCache.tag.byId(segmentId)], + if (!segment) { + return err({ type: "not_found", details: [{ field: "segment", issue: "not found" }] }); } - )() -); + + return ok(segment); + } catch (error) { + return err({ type: "internal_server_error", details: [{ field: "segment", issue: error.message }] }); + } +}); diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/surveys.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/surveys.ts index 8347d018fcf2..a43df3883eda 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/surveys.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/surveys.ts @@ -1,39 +1,25 @@ -import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { Survey } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { surveyCache } from "@formbricks/lib/survey/cache"; -import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { err, ok } from "@formbricks/types/error-handlers"; -export const getSurvey = reactCache(async (surveyId: string) => - cache( - async (): Promise< - Result, ApiErrorResponseV2> - > => { - try { - const survey = await prisma.survey.findUnique({ - where: { id: surveyId }, - select: { - id: true, - environmentId: true, - type: true, - status: true, - }, - }); +export const getSurvey = reactCache(async (surveyId: string) => { + try { + const survey = await prisma.survey.findUnique({ + where: { id: surveyId }, + select: { + id: true, + environmentId: true, + type: true, + status: true, + }, + }); - if (!survey) { - return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] }); - } - - return ok(survey); - } catch (error) { - return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] }); - } - }, - [`contact-link-getSurvey-${surveyId}`], - { - tags: [surveyCache.tag.byId(surveyId)], + if (!survey) { + return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] }); } - )() -); + + return ok(survey); + } catch (error) { + return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] }); + } +}); diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/tests/segment.test.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/tests/segment.test.ts index 0c8ffab670ed..76534ad9728d 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/tests/segment.test.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/tests/segment.test.ts @@ -1,8 +1,6 @@ import { Segment } from "@prisma/client"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { segmentCache } from "@formbricks/lib/cache/segment"; import { getSegment } from "../segment"; // Mock dependencies @@ -14,18 +12,6 @@ vi.mock("@formbricks/database", () => ({ }, })); -vi.mock("@formbricks/lib/cache", () => ({ - cache: vi.fn((fn) => fn), -})); - -vi.mock("@formbricks/lib/cache/segment", () => ({ - segmentCache: { - tag: { - byId: vi.fn((id) => `segment-${id}`), - }, - }, -})); - describe("getSegment", () => { const mockSegmentId = "segment-123"; const mockSegment: Pick = { @@ -74,8 +60,6 @@ describe("getSegment", () => { if (result.ok) { expect(result.data).toEqual(mockSegment); } - - expect(segmentCache.tag.byId).toHaveBeenCalledWith(mockSegmentId); }); test("should return not_found error when segment doesn't exist", async () => { @@ -116,14 +100,4 @@ describe("getSegment", () => { }); } }); - - test("should use correct cache key", async () => { - vi.mocked(prisma.segment.findUnique).mockResolvedValueOnce(mockSegment); - - await getSegment(mockSegmentId); - - expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSegment-${mockSegmentId}`], { - tags: [`segment-${mockSegmentId}`], - }); - }); }); diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/tests/surveys.test.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/tests/surveys.test.ts index 3559dc580c07..58eaaa3e641a 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/tests/surveys.test.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/tests/surveys.test.ts @@ -1,7 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { surveyCache } from "@formbricks/lib/survey/cache"; import { getSurvey } from "../surveys"; // Mock dependencies @@ -13,18 +11,6 @@ vi.mock("@formbricks/database", () => ({ }, })); -vi.mock("@formbricks/lib/cache", () => ({ - cache: vi.fn((fn) => fn), -})); - -vi.mock("@formbricks/lib/survey/cache", () => ({ - surveyCache: { - tag: { - byId: vi.fn((id) => `survey-${id}`), - }, - }, -})); - describe("getSurvey", () => { const mockSurveyId = "survey-123"; const mockEnvironmentId = "env-456"; @@ -60,11 +46,6 @@ describe("getSurvey", () => { if (result.ok) { expect(result.data).toEqual(mockSurvey); } - - expect(surveyCache.tag.byId).toHaveBeenCalledWith(mockSurveyId); - expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSurvey-${mockSurveyId}`], { - tags: [`survey-${mockSurveyId}`], - }); }); test("should return not_found error when survey doesn't exist", async () => { @@ -106,15 +87,4 @@ describe("getSurvey", () => { }); } }); - - test("should use correct cache key and tags", async () => { - vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey); - - await getSurvey(mockSurveyId); - - expect(cache).toHaveBeenCalledWith(expect.any(Function), [`contact-link-getSurvey-${mockSurveyId}`], { - tags: [`survey-${mockSurveyId}`], - }); - expect(surveyCache.tag.byId).toHaveBeenCalledWith(mockSurveyId); - }); }); diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route.ts index 56a3632c2413..2e67aa9bd169 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route.ts @@ -7,6 +7,7 @@ import { ZContactLinksBySegmentParams, ZContactLinksBySegmentQuery, } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; @@ -67,7 +68,7 @@ export const GET = async ( ); if (!contactsResult.ok) { - return handleApiError(request, contactsResult.error); + return handleApiError(request, contactsResult.error as ApiErrorResponseV2); } const { data: contacts, meta } = contactsResult.data; @@ -81,11 +82,11 @@ export const GET = async ( } // Generate survey links for each contact - const contactLinks = contacts - .map((contact) => { + const contactLinks = await Promise.all( + contacts.map(async (contact) => { const { contactId, attributes } = contact; - const surveyUrlResult = getContactSurveyLink( + const surveyUrlResult = await getContactSurveyLink( contactId, params.surveyId, query?.expirationDays || undefined @@ -106,10 +107,11 @@ export const GET = async ( expiresAt, }; }) - .filter(Boolean); + ); + const filteredContactLinks = contactLinks.filter(Boolean); return responses.successResponse({ - data: contactLinks, + data: filteredContactLinks, meta, }); }, diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/lib/openapi.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/lib/openapi.ts index 7b124889caaf..f218a40725f0 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/lib/openapi.ts @@ -13,7 +13,7 @@ export const getSurveyEndpoint: ZodOpenApiOperationObject = { id: surveyIdSchema, }), }, - tags: ["Management API > Surveys"], + tags: ["Management API - Surveys"], responses: { "200": { description: "Response retrieved successfully.", @@ -30,7 +30,7 @@ export const deleteSurveyEndpoint: ZodOpenApiOperationObject = { operationId: "deleteSurvey", summary: "Delete a survey", description: "Deletes a survey from the database.", - tags: ["Management API > Surveys"], + tags: ["Management API - Surveys"], requestParams: { path: z.object({ id: surveyIdSchema, @@ -52,7 +52,7 @@ export const updateSurveyEndpoint: ZodOpenApiOperationObject = { operationId: "updateSurvey", summary: "Update a survey", description: "Updates a survey in the database.", - tags: ["Management API > Surveys"], + tags: ["Management API - Surveys"], requestParams: { path: z.object({ id: surveyIdSchema, diff --git a/apps/web/modules/api/v2/management/surveys/lib/openapi.ts b/apps/web/modules/api/v2/management/surveys/lib/openapi.ts index 29e99fe501ed..5239f3d981bc 100644 --- a/apps/web/modules/api/v2/management/surveys/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/surveys/lib/openapi.ts @@ -1,8 +1,3 @@ -// import { -// deleteSurveyEndpoint, -// getSurveyEndpoint, -// updateSurveyEndpoint, -// } from "@/modules/api/v2/management/surveys/[surveyId]/lib/openapi"; import { managementServer } from "@/modules/api/v2/management/lib/openapi"; import { getPersonalizedSurveyLink } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/openapi"; import { ZGetSurveysFilter, ZSurveyInput } from "@/modules/api/v2/management/surveys/types/surveys"; @@ -17,7 +12,7 @@ export const getSurveysEndpoint: ZodOpenApiOperationObject = { requestParams: { query: ZGetSurveysFilter, }, - tags: ["Management API > Surveys"], + tags: ["Management API - Surveys"], responses: { "200": { description: "Surveys retrieved successfully.", @@ -34,7 +29,7 @@ export const createSurveyEndpoint: ZodOpenApiOperationObject = { operationId: "createSurvey", summary: "Create a survey", description: "Creates a survey in the database.", - tags: ["Management API > Surveys"], + tags: ["Management API - Surveys"], requestBody: { required: true, description: "The survey to create", diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts index 43eb2ce69606..2790d0b8c74a 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts @@ -14,7 +14,7 @@ export const getWebhookEndpoint: ZodOpenApiOperationObject = { id: ZWebhookIdSchema, }), }, - tags: ["Management API > Webhooks"], + tags: ["Management API - Webhooks"], responses: { "200": { description: "Webhook retrieved successfully.", @@ -31,7 +31,7 @@ export const deleteWebhookEndpoint: ZodOpenApiOperationObject = { operationId: "deleteWebhook", summary: "Delete a webhook", description: "Deletes a webhook from the database.", - tags: ["Management API > Webhooks"], + tags: ["Management API - Webhooks"], requestParams: { path: z.object({ id: ZWebhookIdSchema, @@ -53,7 +53,7 @@ export const updateWebhookEndpoint: ZodOpenApiOperationObject = { operationId: "updateWebhook", summary: "Update a webhook", description: "Updates a webhook in the database.", - tags: ["Management API > Webhooks"], + tags: ["Management API - Webhooks"], requestParams: { path: z.object({ id: ZWebhookIdSchema, diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts index a6b335ba5e91..3a4d6a6e75de 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts @@ -1,5 +1,4 @@ -import { WebhookSource } from "@prisma/client"; -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { Prisma, WebhookSource } from "@prisma/client"; import { PrismaErrorType } from "@formbricks/database/types/error"; export const mockedPrismaWebhookUpdateReturn = { @@ -14,7 +13,7 @@ export const mockedPrismaWebhookUpdateReturn = { surveyIds: [], }; -export const prismaNotFoundError = new PrismaClientKnownRequestError("Record does not exist", { +export const prismaNotFoundError = new Prisma.PrismaClientKnownRequestError("Record does not exist", { code: PrismaErrorType.RecordDoesNotExist, clientVersion: "PrismaClient 4.0.0", }); diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts index 192685577a43..851e196ec4bf 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts @@ -1,4 +1,3 @@ -import { webhookCache } from "@/lib/cache/webhook"; import { mockedPrismaWebhookUpdateReturn, prismaNotFoundError, @@ -19,15 +18,6 @@ vi.mock("@formbricks/database", () => ({ }, })); -vi.mock("@/lib/cache/webhook", () => ({ - webhookCache: { - tag: { - byId: () => "mockTag", - }, - revalidate: vi.fn(), - }, -})); - describe("getWebhook", () => { test("returns ok if webhook is found", async () => { vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce({ id: "123" }); @@ -71,8 +61,6 @@ describe("updateWebhook", () => { if (result.ok) { expect(result.data).toEqual(mockedPrismaWebhookUpdateReturn); } - - expect(webhookCache.revalidate).toHaveBeenCalled(); }); test("returns not_found if record does not exist", async () => { @@ -101,7 +89,6 @@ describe("deleteWebhook", () => { vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn); const result = await deleteWebhook("123"); expect(result.ok).toBe(true); - expect(webhookCache.revalidate).toHaveBeenCalled(); }); test("returns not_found if record does not exist", async () => { diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts index a11645713ea7..7201315c9c93 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts @@ -1,44 +1,34 @@ -import { webhookCache } from "@/lib/cache/webhook"; import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { Webhook } from "@prisma/client"; -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { Prisma, Webhook } from "@prisma/client"; import { z } from "zod"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; -import { cache } from "@formbricks/lib/cache"; import { Result, err, ok } from "@formbricks/types/error-handlers"; -export const getWebhook = async (webhookId: string) => - cache( - async (): Promise> => { - try { - const webhook = await prisma.webhook.findUnique({ - where: { - id: webhookId, - }, - }); - - if (!webhook) { - return err({ - type: "not_found", - details: [{ field: "webhook", issue: "not found" }], - }); - } +export const getWebhook = async (webhookId: string) => { + try { + const webhook = await prisma.webhook.findUnique({ + where: { + id: webhookId, + }, + }); - return ok(webhook); - } catch (error) { - return err({ - type: "internal_server_error", - details: [{ field: "webhook", issue: error.message }], - }); - } - }, - [`management-getWebhook-${webhookId}`], - { - tags: [webhookCache.tag.byId(webhookId)], + if (!webhook) { + return err({ + type: "not_found", + details: [{ field: "webhook", issue: "not found" }], + }); } - )(); + + return ok(webhook); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "webhook", issue: error.message }], + }); + } +}; export const updateWebhook = async ( webhookId: string, @@ -52,13 +42,9 @@ export const updateWebhook = async ( data: webhookInput, }); - webhookCache.revalidate({ - id: webhookId, - }); - return ok(updatedWebhook); } catch (error) { - if (error instanceof PrismaClientKnownRequestError) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { if ( error.code === PrismaErrorType.RecordDoesNotExist || error.code === PrismaErrorType.RelatedRecordDoesNotExist @@ -84,15 +70,9 @@ export const deleteWebhook = async (webhookId: string): Promise { + handler: async ({ authentication, parsedInput, auditLog }) => { const { params, body } = parsedInput; + if (auditLog) { + auditLog.targetId = params?.webhookId; + } if (!body || !params) { - return handleApiError(request, { - type: "bad_request", - details: [{ field: !body ? "body" : "params", issue: "missing" }], - }); + return handleApiError( + request, + { + type: "bad_request", + details: [{ field: !body ? "body" : "params", issue: "missing" }], + }, + auditLog + ); } - // get surveys environment - const surveysEnvironmentId = await getEnvironmentIdFromSurveyIds(body.surveyIds); + const surveysEnvironmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds); - if (!surveysEnvironmentId.ok) { - return handleApiError(request, surveysEnvironmentId.error); + if (!surveysEnvironmentIdResult.ok) { + return handleApiError(request, surveysEnvironmentIdResult.error, auditLog); } + const surveysEnvironmentId = surveysEnvironmentIdResult.data; + // get webhook environment const webhook = await getWebhook(params.webhookId); if (!webhook.ok) { - return handleApiError(request, webhook.error); + return handleApiError(request, webhook.error as ApiErrorResponseV2, auditLog); } if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "PUT")) { - return handleApiError(request, { - type: "unauthorized", - details: [{ field: "webhook", issue: "unauthorized" }], - }); + return handleApiError( + request, + { + type: "unauthorized", + details: [{ field: "webhook", issue: "unauthorized" }], + }, + auditLog + ); } // check if webhook environment matches the surveys environment - if (webhook.data.environmentId !== surveysEnvironmentId.data) { - return handleApiError(request, { - type: "bad_request", - details: [ - { field: "surveys id", issue: "webhook environment does not match the surveys environment" }, - ], - }); + if (surveysEnvironmentId && webhook.data.environmentId !== surveysEnvironmentId) { + return handleApiError( + request, + { + type: "bad_request", + details: [ + { field: "surveys id", issue: "webhook environment does not match the surveys environment" }, + ], + }, + auditLog + ); } const updatedWebhook = await updateWebhook(params.webhookId, body); if (!updatedWebhook.ok) { - return handleApiError(request, updatedWebhook.error); + return handleApiError(request, updatedWebhook.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error + } + + if (auditLog) { + auditLog.oldObject = webhook.data; + auditLog.newObject = updatedWebhook.data; } return responses.successResponse(updatedWebhook); }, + action: "updated", + targetType: "webhook", }); export const DELETE = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) => @@ -115,35 +139,52 @@ export const DELETE = async (request: NextRequest, props: { params: Promise<{ we params: z.object({ webhookId: ZWebhookIdSchema }), }, externalParams: props.params, - handler: async ({ authentication, parsedInput }) => { + handler: async ({ authentication, parsedInput, auditLog }) => { const { params } = parsedInput; + if (auditLog) { + auditLog.targetId = params?.webhookId; + } if (!params) { - return handleApiError(request, { - type: "bad_request", - details: [{ field: "params", issue: "missing" }], - }); + return handleApiError( + request, + { + type: "bad_request", + details: [{ field: "params", issue: "missing" }], + }, + auditLog + ); } const webhook = await getWebhook(params.webhookId); if (!webhook.ok) { - return handleApiError(request, webhook.error); + return handleApiError(request, webhook.error as ApiErrorResponseV2, auditLog); } if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "DELETE")) { - return handleApiError(request, { - type: "unauthorized", - details: [{ field: "webhook", issue: "unauthorized" }], - }); + return handleApiError( + request, + { + type: "unauthorized", + details: [{ field: "webhook", issue: "unauthorized" }], + }, + auditLog + ); } const deletedWebhook = await deleteWebhook(params.webhookId); if (!deletedWebhook.ok) { - return handleApiError(request, deletedWebhook.error); + return handleApiError(request, deletedWebhook.error as ApiErrorResponseV2, auditLog); // NOSONAR // We need to assert or we get a type error + } + + if (auditLog) { + auditLog.oldObject = webhook.data; } return responses.successResponse(deletedWebhook); }, + action: "deleted", + targetType: "webhook", }); diff --git a/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts b/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts index 377c262f3c2a..8d78d56472f7 100644 --- a/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts @@ -16,7 +16,7 @@ export const getWebhooksEndpoint: ZodOpenApiOperationObject = { requestParams: { query: ZGetWebhooksFilter.sourceType(), }, - tags: ["Management API > Webhooks"], + tags: ["Management API - Webhooks"], responses: { "200": { description: "Webhooks retrieved successfully.", @@ -33,7 +33,7 @@ export const createWebhookEndpoint: ZodOpenApiOperationObject = { operationId: "createWebhook", summary: "Create a webhook", description: "Creates a webhook in the database.", - tags: ["Management API > Webhooks"], + tags: ["Management API - Webhooks"], requestBody: { required: true, description: "The webhook to create", diff --git a/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts index 278428e5b615..c95bede10a19 100644 --- a/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts +++ b/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts @@ -1,6 +1,6 @@ import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils"; import { TGetWebhooksFilter } from "@/modules/api/v2/management/webhooks/types/webhooks"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { getWebhooksQuery } from "../utils"; vi.mock("@/modules/api/v2/management/lib/utils", () => ({ @@ -11,7 +11,7 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({ describe("getWebhooksQuery", () => { const environmentId = "env-123"; - it("adds surveyIds condition when provided", () => { + test("adds surveyIds condition when provided", () => { const params = { surveyIds: ["survey1"] } as TGetWebhooksFilter; const result = getWebhooksQuery([environmentId], params); expect(result).toBeDefined(); @@ -21,14 +21,14 @@ describe("getWebhooksQuery", () => { }); }); - it("calls pickCommonFilter and buildCommonFilterQuery when baseFilter is present", () => { + test("calls pickCommonFilter and buildCommonFilterQuery when baseFilter is present", () => { vi.mocked(pickCommonFilter).mockReturnValue({ someFilter: "test" } as any); getWebhooksQuery([environmentId], { surveyIds: ["survey1"] } as TGetWebhooksFilter); expect(pickCommonFilter).toHaveBeenCalled(); expect(buildCommonFilterQuery).toHaveBeenCalled(); }); - it("buildCommonFilterQuery is not called if no baseFilter is picked", () => { + test("buildCommonFilterQuery is not called if no baseFilter is picked", () => { vi.mocked(pickCommonFilter).mockReturnValue(undefined as any); getWebhooksQuery([environmentId], {} as any); expect(buildCommonFilterQuery).not.toHaveBeenCalled(); diff --git a/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts b/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts index b0e2104d9c73..6ee7c6c46061 100644 --- a/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts +++ b/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts @@ -1,9 +1,8 @@ -import { webhookCache } from "@/lib/cache/webhook"; +import { captureTelemetry } from "@/lib/telemetry"; import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks"; import { WebhookSource } from "@prisma/client"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { captureTelemetry } from "@formbricks/lib/telemetry"; import { createWebhook, getWebhooks } from "../webhook"; vi.mock("@formbricks/database", () => ({ @@ -16,12 +15,8 @@ vi.mock("@formbricks/database", () => ({ }, }, })); -vi.mock("@/lib/cache/webhook", () => ({ - webhookCache: { - revalidate: vi.fn(), - }, -})); -vi.mock("@formbricks/lib/telemetry", () => ({ + +vi.mock("@/lib/telemetry", () => ({ captureTelemetry: vi.fn(), })); @@ -37,7 +32,7 @@ describe("getWebhooks", () => { ]; const count = fakeWebhooks.length; - it("returns ok response with webhooks and meta", async () => { + test("returns ok response with webhooks and meta", async () => { vi.mocked(prisma.$transaction).mockResolvedValueOnce([fakeWebhooks, count]); const result = await getWebhooks(environmentId, params as TGetWebhooksFilter); @@ -53,7 +48,7 @@ describe("getWebhooks", () => { } }); - it("returns error when prisma.$transaction throws", async () => { + test("returns error when prisma.$transaction throws", async () => { vi.mocked(prisma.$transaction).mockRejectedValueOnce(new Error("Test error")); const result = await getWebhooks(environmentId, params as TGetWebhooksFilter); @@ -87,16 +82,12 @@ describe("createWebhook", () => { updatedAt: new Date(), }; - it("creates a webhook and revalidates cache", async () => { + test("creates a webhook", async () => { vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook); const result = await createWebhook(inputWebhook); expect(captureTelemetry).toHaveBeenCalledWith("webhook_created"); expect(prisma.webhook.create).toHaveBeenCalled(); - expect(webhookCache.revalidate).toHaveBeenCalledWith({ - environmentId: createdWebhook.environmentId, - source: createdWebhook.source, - }); expect(result.ok).toBe(true); if (result.ok) { @@ -104,7 +95,7 @@ describe("createWebhook", () => { } }); - it("returns error when creation fails", async () => { + test("returns error when creation fails", async () => { vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Creation failed")); const result = await createWebhook(inputWebhook); diff --git a/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts b/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts index 175c6660b8c2..9189eace7496 100644 --- a/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts +++ b/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts @@ -1,11 +1,10 @@ -import { webhookCache } from "@/lib/cache/webhook"; +import { captureTelemetry } from "@/lib/telemetry"; import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils"; import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success"; import { Prisma, Webhook } from "@prisma/client"; import { prisma } from "@formbricks/database"; -import { captureTelemetry } from "@formbricks/lib/telemetry"; import { Result, err, ok } from "@formbricks/types/error-handlers"; export const getWebhooks = async ( @@ -70,11 +69,6 @@ export const createWebhook = async (webhook: TWebhookInput): Promise schemas: { body: ZWebhookInput, }, - handler: async ({ authentication, parsedInput }) => { + handler: async ({ authentication, parsedInput, auditLog }) => { const { body } = parsedInput; if (!body) { - return handleApiError(request, { - type: "bad_request", - details: [{ field: "body", issue: "missing" }], - }); + return handleApiError( + request, + { + type: "bad_request", + details: [{ field: "body", issue: "missing" }], + }, + auditLog + ); } - const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds); + if (body.surveyIds && body.surveyIds.length > 0) { + const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds); - if (!environmentIdResult.ok) { - return handleApiError(request, environmentIdResult.error); + if (!environmentIdResult.ok) { + return handleApiError(request, environmentIdResult.error, auditLog); + } } if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) { - return handleApiError(request, { - type: "forbidden", - details: [{ field: "environmentId", issue: "does not have permission to create webhook" }], - }); + return handleApiError( + request, + { + type: "forbidden", + details: [{ field: "environmentId", issue: "does not have permission to create webhook" }], + }, + auditLog + ); } const createWebhookResult = await createWebhook(body); if (!createWebhookResult.ok) { - return handleApiError(request, createWebhookResult.error); + return handleApiError(request, createWebhookResult.error, auditLog); + } + + if (auditLog) { + auditLog.targetId = createWebhookResult.data.id; + auditLog.newObject = createWebhookResult.data; } - return responses.successResponse(createWebhookResult); + return responses.createdResponse(createWebhookResult); }, + action: "created", + targetType: "webhook", }); diff --git a/apps/web/modules/api/v2/openapi-document.ts b/apps/web/modules/api/v2/openapi-document.ts index d099f912c8b2..f67c6a3e1d70 100644 --- a/apps/web/modules/api/v2/openapi-document.ts +++ b/apps/web/modules/api/v2/openapi-document.ts @@ -1,6 +1,4 @@ -// import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi"; -// import { contactAttributePaths } from "@/modules/api/v2/management/contact-attributes/lib/openapi"; -// import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi"; +import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi"; import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi"; import { surveyContactLinksBySegmentPaths } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/lib/openapi"; import { surveyPaths } from "@/modules/api/v2/management/surveys/lib/openapi"; @@ -11,6 +9,7 @@ import { teamPaths } from "@/modules/api/v2/organizations/[organizationId]/teams import { userPaths } from "@/modules/api/v2/organizations/[organizationId]/users/lib/openapi"; import { rolePaths } from "@/modules/api/v2/roles/lib/openapi"; import { bulkContactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/openapi"; +import { contactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/lib/openapi"; import * as yaml from "yaml"; import { z } from "zod"; import { createDocument, extendZodWithOpenApi } from "zod-openapi"; @@ -40,9 +39,8 @@ const document = createDocument({ ...mePaths, ...responsePaths, ...bulkContactPaths, - // ...contactPaths, - // ...contactAttributePaths, - // ...contactAttributeKeyPaths, + ...contactPaths, + ...contactAttributeKeyPaths, ...surveyPaths, ...surveyContactLinksBySegmentPaths, ...webhookPaths, @@ -66,43 +64,43 @@ const document = createDocument({ description: "Operations for managing your API key.", }, { - name: "Management API > Responses", + name: "Management API - Responses", description: "Operations for managing responses.", }, { - name: "Management API > Contacts", + name: "Management API - Contacts", description: "Operations for managing contacts.", }, { - name: "Management API > Contact Attributes", + name: "Management API - Contact Attributes", description: "Operations for managing contact attributes.", }, { - name: "Management API > Contact Attributes Keys", - description: "Operations for managing contact attributes keys.", + name: "Management API - Contact Attribute Keys", + description: "Operations for managing contact attribute keys.", }, { - name: "Management API > Surveys", + name: "Management API - Surveys", description: "Operations for managing surveys.", }, { - name: "Management API > Surveys > Contact Links", + name: "Management API - Surveys - Contact Links", description: "Operations for generating personalized survey links for contacts.", }, { - name: "Management API > Webhooks", + name: "Management API - Webhooks", description: "Operations for managing webhooks.", }, { - name: "Organizations API > Teams", + name: "Organizations API - Teams", description: "Operations for managing teams.", }, { - name: "Organizations API > Project Teams", + name: "Organizations API - Project Teams", description: "Operations for managing project teams.", }, { - name: "Organizations API > Users", + name: "Organizations API - Users", description: "Operations for managing users.", }, ], diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.test.ts index d89131950b82..61abd41ec6b0 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { logger } from "@formbricks/logger"; import { OrganizationAccessType } from "@formbricks/types/api-key"; import { hasOrganizationIdAndAccess } from "./utils"; @@ -8,7 +8,7 @@ describe("hasOrganizationIdAndAccess", () => { vi.restoreAllMocks(); }); - it("should return false and log error if authentication has no organizationId", () => { + test("should return false and log error if authentication has no organizationId", () => { const spyError = vi.spyOn(logger, "error").mockImplementation(() => {}); const authentication = { organizationAccess: { accessControl: { read: true } }, @@ -21,7 +21,7 @@ describe("hasOrganizationIdAndAccess", () => { ); }); - it("should return false and log error if param organizationId does not match authentication organizationId", () => { + test("should return false and log error if param organizationId does not match authentication organizationId", () => { const spyError = vi.spyOn(logger, "error").mockImplementation(() => {}); const authentication = { organizationId: "org2", @@ -35,7 +35,7 @@ describe("hasOrganizationIdAndAccess", () => { ); }); - it("should return false if access type is missing in organizationAccess", () => { + test("should return false if access type is missing in organizationAccess", () => { const authentication = { organizationId: "org1", organizationAccess: { accessControl: {} }, @@ -45,7 +45,7 @@ describe("hasOrganizationIdAndAccess", () => { expect(result).toBe(false); }); - it("should return true if organizationId and access type are valid", () => { + test("should return true if organizationId and access type are valid", () => { const authentication = { organizationId: "org1", organizationAccess: { accessControl: { read: true } }, diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/openapi.ts b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/openapi.ts index 283023aaf648..910f701e1719 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/openapi.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/openapi.ts @@ -20,7 +20,7 @@ export const getProjectTeamsEndpoint: ZodOpenApiOperationObject = { organizationId: ZOrganizationIdSchema, }), }, - tags: ["Organizations API > Project Teams"], + tags: ["Organizations API - Project Teams"], responses: { "200": { description: "Project teams retrieved successfully.", @@ -42,7 +42,7 @@ export const createProjectTeamEndpoint: ZodOpenApiOperationObject = { organizationId: ZOrganizationIdSchema, }), }, - tags: ["Organizations API > Project Teams"], + tags: ["Organizations API - Project Teams"], requestBody: { required: true, description: "The project team to create", @@ -68,7 +68,7 @@ export const deleteProjectTeamEndpoint: ZodOpenApiOperationObject = { operationId: "deleteProjectTeam", summary: "Delete a project team", description: "Deletes a project team from the database.", - tags: ["Organizations API > Project Teams"], + tags: ["Organizations API - Project Teams"], requestParams: { query: ZGetProjectTeamUpdateFilter.required(), path: z.object({ @@ -91,7 +91,7 @@ export const updateProjectTeamEndpoint: ZodOpenApiOperationObject = { operationId: "updateProjectTeam", summary: "Update a project team", description: "Updates a project team in the database.", - tags: ["Organizations API > Project Teams"], + tags: ["Organizations API - Project Teams"], requestParams: { path: z.object({ organizationId: ZOrganizationIdSchema, diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/project-teams.ts b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/project-teams.ts index 3c06e0423735..aab667959123 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/project-teams.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/project-teams.ts @@ -1,4 +1,4 @@ -import { teamCache } from "@/lib/cache/team"; +import { captureTelemetry } from "@/lib/telemetry"; import { getProjectTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils"; import { TGetProjectTeamsFilter, @@ -10,8 +10,6 @@ import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success"; import { ProjectTeam } from "@prisma/client"; import { z } from "zod"; import { prisma } from "@formbricks/database"; -import { projectCache } from "@formbricks/lib/project/cache"; -import { captureTelemetry } from "@formbricks/lib/telemetry"; import { Result, err, ok } from "@formbricks/types/error-handlers"; export const getProjectTeams = async ( @@ -59,14 +57,6 @@ export const createProjectTeam = async ( }, }); - projectCache.revalidate({ - id: projectId, - }); - - teamCache.revalidate({ - id: teamId, - }); - return ok(projectTeam); } catch (error) { return err({ type: "internal_server_error", details: [{ field: "projectTeam", issue: error.message }] }); @@ -89,14 +79,6 @@ export const updateProjectTeam = async ( data: teamInput, }); - projectCache.revalidate({ - id: projectId, - }); - - teamCache.revalidate({ - id: teamId, - }); - return ok(updatedProjectTeam); } catch (error) { return err({ type: "internal_server_error", details: [{ field: "projectTeam", issue: error.message }] }); @@ -117,14 +99,6 @@ export const deleteProjectTeam = async ( }, }); - projectCache.revalidate({ - id: projectId, - }); - - teamCache.revalidate({ - id: teamId, - }); - return ok(deletedProjectTeam); } catch (error) { return err({ type: "internal_server_error", details: [{ field: "projectTeam", issue: error.message }] }); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/tests/project-teams.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/tests/project-teams.test.ts index bf7c7dc4b6d6..e5ba8ae9a859 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/tests/project-teams.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/tests/project-teams.test.ts @@ -3,7 +3,7 @@ import { TProjectTeamInput, ZProjectZTeamUpdateSchema, } from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { TypeOf } from "zod"; import { prisma } from "@formbricks/database"; import { createProjectTeam, deleteProjectTeam, getProjectTeams, updateProjectTeam } from "../project-teams"; @@ -27,7 +27,7 @@ describe("ProjectTeams Lib", () => { }); describe("getProjectTeams", () => { - it("returns projectTeams with meta on success", async () => { + test("returns projectTeams with meta on success", async () => { const mockTeams = [{ id: "projTeam1", organizationId: "orgx", projectId: "p1", teamId: "t1" }]; (prisma.$transaction as any).mockResolvedValueOnce([mockTeams, mockTeams.length]); const result = await getProjectTeams("orgx", { skip: 0, limit: 10 } as TGetProjectTeamsFilter); @@ -41,7 +41,7 @@ describe("ProjectTeams Lib", () => { } }); - it("returns internal_server_error on exception", async () => { + test("returns internal_server_error on exception", async () => { (prisma.$transaction as any).mockRejectedValueOnce(new Error("DB error")); const result = await getProjectTeams("orgx", { skip: 0, limit: 10 } as TGetProjectTeamsFilter); expect(result.ok).toBe(false); @@ -52,7 +52,7 @@ describe("ProjectTeams Lib", () => { }); describe("createProjectTeam", () => { - it("creates a projectTeam successfully", async () => { + test("creates a projectTeam successfully", async () => { const mockCreated = { id: "ptx", projectId: "p1", teamId: "t1", organizationId: "orgx" }; (prisma.projectTeam.create as any).mockResolvedValueOnce(mockCreated); const result = await createProjectTeam({ @@ -65,7 +65,7 @@ describe("ProjectTeams Lib", () => { } }); - it("returns internal_server_error on error", async () => { + test("returns internal_server_error on error", async () => { (prisma.projectTeam.create as any).mockRejectedValueOnce(new Error("Create error")); const result = await createProjectTeam({ projectId: "p1", @@ -79,7 +79,7 @@ describe("ProjectTeams Lib", () => { }); describe("updateProjectTeam", () => { - it("updates a projectTeam successfully", async () => { + test("updates a projectTeam successfully", async () => { (prisma.projectTeam.update as any).mockResolvedValueOnce({ id: "pt01", projectId: "p1", @@ -95,7 +95,7 @@ describe("ProjectTeams Lib", () => { } }); - it("returns internal_server_error on error", async () => { + test("returns internal_server_error on error", async () => { (prisma.projectTeam.update as any).mockRejectedValueOnce(new Error("Update error")); const result = await updateProjectTeam("t1", "p1", { permission: "READ" } as unknown as TypeOf< typeof ZProjectZTeamUpdateSchema @@ -108,7 +108,7 @@ describe("ProjectTeams Lib", () => { }); describe("deleteProjectTeam", () => { - it("deletes a projectTeam successfully", async () => { + test("deletes a projectTeam successfully", async () => { (prisma.projectTeam.delete as any).mockResolvedValueOnce({ projectId: "p1", teamId: "t1", @@ -122,7 +122,7 @@ describe("ProjectTeams Lib", () => { } }); - it("returns internal_server_error on error", async () => { + test("returns internal_server_error on error", async () => { (prisma.projectTeam.delete as any).mockRejectedValueOnce(new Error("Delete error")); const result = await deleteProjectTeam("t1", "p1"); expect(result.ok).toBe(false); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils.ts b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils.ts index a1cdbea50170..2186565b07a6 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils.ts @@ -1,13 +1,9 @@ -import { teamCache } from "@/lib/cache/team"; import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils"; import { TGetProjectTeamsFilter } from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; -import { organizationCache } from "@formbricks/lib/organization/cache"; -import { projectCache } from "@formbricks/lib/project/cache"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { Result, err, ok } from "@formbricks/types/error-handlers"; @@ -55,47 +51,36 @@ export const getProjectTeamsQuery = (organizationId: string, params: TGetProject }; export const validateTeamIdAndProjectId = reactCache( - async (organizationId: string, teamId: string, projectId: string) => - cache( - async (): Promise> => { - try { - const hasAccess = await prisma.organization.findFirst({ - where: { - id: organizationId, - teams: { - some: { - id: teamId, - }, - }, - projects: { - some: { - id: projectId, - }, - }, + async (organizationId: string, teamId: string, projectId: string) => { + try { + const hasAccess = await prisma.organization.findFirst({ + where: { + id: organizationId, + teams: { + some: { + id: teamId, }, - }); - - if (!hasAccess) { - return err({ type: "not_found", details: [{ field: "teamId/projectId", issue: "not_found" }] }); - } + }, + projects: { + some: { + id: projectId, + }, + }, + }, + }); - return ok(true); - } catch (error) { - return err({ - type: "internal_server_error", - details: [{ field: "teamId/projectId", issue: error.message }], - }); - } - }, - [`validateTeamIdAndProjectId-${organizationId}-${teamId}-${projectId}`], - { - tags: [ - teamCache.tag.byId(teamId), - projectCache.tag.byId(projectId), - organizationCache.tag.byId(organizationId), - ], + if (!hasAccess) { + return err({ type: "not_found", details: [{ field: "teamId/projectId", issue: "not_found" }] }); } - )() + + return ok(true); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "teamId/projectId", issue: error.message }], + }); + } + } ); export const checkAuthenticationAndAccess = async ( @@ -106,7 +91,7 @@ export const checkAuthenticationAndAccess = async ( const hasAccess = await validateTeamIdAndProjectId(authentication.organizationId, teamId, projectId); if (!hasAccess.ok) { - return err(hasAccess.error); + return err(hasAccess.error as ApiErrorResponseV2); } return ok(true); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/route.ts index 15ced42242ad..6bc9b2d6d7e2 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/route.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/route.ts @@ -4,7 +4,9 @@ import { handleApiError } from "@/modules/api/v2/lib/utils"; import { hasOrganizationIdAndAccess } from "@/modules/api/v2/organizations/[organizationId]/lib/utils"; import { checkAuthenticationAndAccess } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils"; import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations"; +import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { z } from "zod"; +import { logger } from "@formbricks/logger"; import { OrganizationAccessType } from "@formbricks/types/api-key"; import { createProjectTeam, @@ -53,20 +55,28 @@ export async function POST(request: Request, props: { params: Promise<{ organiza params: z.object({ organizationId: ZOrganizationIdSchema }), }, externalParams: props.params, - handler: async ({ parsedInput: { body, params }, authentication }) => { + handler: async ({ parsedInput: { body, params }, authentication, auditLog }) => { const { teamId, projectId } = body!; + if (auditLog) { + auditLog.targetId = `${projectId}-${teamId}`; + } + if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { - return handleApiError(request, { - type: "unauthorized", - details: [{ field: "organizationId", issue: "unauthorized" }], - }); + return handleApiError( + request, + { + type: "unauthorized", + details: [{ field: "organizationId", issue: "unauthorized" }], + }, + auditLog + ); } const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication); if (!hasAccess.ok) { - return handleApiError(request, hasAccess.error); + return handleApiError(request, hasAccess.error, auditLog); } // check if project team already exists @@ -80,22 +90,32 @@ export async function POST(request: Request, props: { params: Promise<{ organiza }); if (!existingProjectTeam.ok) { - return handleApiError(request, existingProjectTeam.error); + return handleApiError(request, existingProjectTeam.error, auditLog); } if (existingProjectTeam.data.data.length > 0) { - return handleApiError(request, { - type: "conflict", - details: [{ field: "projectTeam", issue: "Project team already exists" }], - }); + return handleApiError( + request, + { + type: "conflict", + details: [{ field: "projectTeam", issue: "Project team already exists" }], + }, + auditLog + ); } const result = await createProjectTeam(body!); if (!result.ok) { - return handleApiError(request, result.error); + return handleApiError(request, result.error, auditLog); + } + + if (auditLog) { + auditLog.newObject = result.data; } return responses.successResponse({ data: result.data }); }, + action: "created", + targetType: "projectTeam", }); } @@ -107,29 +127,65 @@ export async function PUT(request: Request, props: { params: Promise<{ organizat params: z.object({ organizationId: ZOrganizationIdSchema }), }, externalParams: props.params, - handler: async ({ parsedInput: { body, params }, authentication }) => { + handler: async ({ parsedInput: { body, params }, authentication, auditLog }) => { const { teamId, projectId } = body!; + if (auditLog) { + auditLog.targetId = `${projectId}-${teamId}`; + } + if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { - return handleApiError(request, { - type: "unauthorized", - details: [{ field: "organizationId", issue: "unauthorized" }], - }); + return handleApiError( + request, + { + type: "unauthorized", + details: [{ field: "organizationId", issue: "unauthorized" }], + }, + auditLog + ); } const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication); if (!hasAccess.ok) { - return handleApiError(request, hasAccess.error); + return handleApiError(request, hasAccess.error, auditLog); + } + + // Fetch old object for audit log + let oldProjectTeamData: any = UNKNOWN_DATA; + try { + const oldProjectTeamResult = await getProjectTeams(authentication.organizationId, { + teamId, + projectId, + limit: 1, + skip: 0, + sortBy: "createdAt", + order: "desc", + }); + + if (oldProjectTeamResult.ok && oldProjectTeamResult.data.data.length > 0) { + oldProjectTeamData = oldProjectTeamResult.data.data[0]; + } else { + logger.error(`Failed to fetch old project team data for audit log`); + } + } catch (error) { + logger.error(error, `Failed to fetch old project team data for audit log`); } const result = await updateProjectTeam(teamId, projectId, body!); if (!result.ok) { - return handleApiError(request, result.error); + return handleApiError(request, result.error, auditLog); + } + + if (auditLog) { + auditLog.oldObject = oldProjectTeamData; + auditLog.newObject = result.data; } return responses.successResponse({ data: result.data }); }, + action: "updated", + targetType: "projectTeam", }); } @@ -141,28 +197,63 @@ export async function DELETE(request: Request, props: { params: Promise<{ organi params: z.object({ organizationId: ZOrganizationIdSchema }), }, externalParams: props.params, - handler: async ({ parsedInput: { query, params }, authentication }) => { + handler: async ({ parsedInput: { query, params }, authentication, auditLog }) => { const { teamId, projectId } = query!; + if (auditLog) { + auditLog.targetId = `${projectId}-${teamId}`; + } + if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { - return handleApiError(request, { - type: "unauthorized", - details: [{ field: "organizationId", issue: "unauthorized" }], - }); + return handleApiError( + request, + { + type: "unauthorized", + details: [{ field: "organizationId", issue: "unauthorized" }], + }, + auditLog + ); } const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication); if (!hasAccess.ok) { - return handleApiError(request, hasAccess.error); + return handleApiError(request, hasAccess.error, auditLog); + } + + // Fetch old object for audit log + let oldProjectTeamData: any = UNKNOWN_DATA; + try { + const oldProjectTeamResult = await getProjectTeams(authentication.organizationId, { + teamId, + projectId, + limit: 1, + skip: 0, + sortBy: "createdAt", + order: "desc", + }); + + if (oldProjectTeamResult.ok && oldProjectTeamResult.data.data.length > 0) { + oldProjectTeamData = oldProjectTeamResult.data.data[0]; + } else { + logger.error(`Failed to fetch old project team data for audit log`); + } + } catch (error) { + logger.error(error, `Failed to fetch old project team data for audit log`); } const result = await deleteProjectTeam(teamId, projectId); if (!result.ok) { - return handleApiError(request, result.error); + return handleApiError(request, result.error, auditLog); + } + + if (auditLog) { + auditLog.oldObject = oldProjectTeamData; } return responses.successResponse({ data: result.data }); }, + action: "deleted", + targetType: "projectTeam", }); } diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/openapi.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/openapi.ts index 18bd73ed568c..8460a33702e2 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/openapi.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/openapi.ts @@ -16,7 +16,7 @@ export const getTeamEndpoint: ZodOpenApiOperationObject = { organizationId: ZOrganizationIdSchema, }), }, - tags: ["Organizations API > Teams"], + tags: ["Organizations API - Teams"], responses: { "200": { description: "Team retrieved successfully.", @@ -33,7 +33,7 @@ export const deleteTeamEndpoint: ZodOpenApiOperationObject = { operationId: "deleteTeam", summary: "Delete a team", description: "Deletes a team from the database.", - tags: ["Organizations API > Teams"], + tags: ["Organizations API - Teams"], requestParams: { path: z.object({ id: ZTeamIdSchema, @@ -56,7 +56,7 @@ export const updateTeamEndpoint: ZodOpenApiOperationObject = { operationId: "updateTeam", summary: "Update a team", description: "Updates a team in the database.", - tags: ["Organizations API > Teams"], + tags: ["Organizations API - Teams"], requestParams: { path: z.object({ id: ZTeamIdSchema, diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts index 90a9d43c8c66..f10e564c2114 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts @@ -1,45 +1,33 @@ -import { organizationCache } from "@/lib/cache/organization"; -import { teamCache } from "@/lib/cache/team"; import { ZTeamUpdateSchema } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { Team } from "@prisma/client"; -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { Prisma, Team } from "@prisma/client"; import { cache as reactCache } from "react"; import { z } from "zod"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; -import { cache } from "@formbricks/lib/cache"; import { Result, err, ok } from "@formbricks/types/error-handlers"; -export const getTeam = reactCache(async (organizationId: string, teamId: string) => - cache( - async (): Promise> => { - try { - const responsePrisma = await prisma.team.findUnique({ - where: { - id: teamId, - organizationId, - }, - }); - - if (!responsePrisma) { - return err({ type: "not_found", details: [{ field: "team", issue: "not found" }] }); - } +export const getTeam = reactCache(async (organizationId: string, teamId: string) => { + try { + const responsePrisma = await prisma.team.findUnique({ + where: { + id: teamId, + organizationId, + }, + }); - return ok(responsePrisma); - } catch (error) { - return err({ - type: "internal_server_error", - details: [{ field: "team", issue: error.message }], - }); - } - }, - [`organizationId-${organizationId}-getTeam-${teamId}`], - { - tags: [teamCache.tag.byId(teamId), organizationCache.tag.byId(organizationId)], + if (!responsePrisma) { + return err({ type: "not_found", details: [{ field: "team", issue: "not found" }] }); } - )() -); + + return ok(responsePrisma); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "team", issue: error.message }], + }); + } +}); export const deleteTeam = async ( organizationId: string, @@ -60,20 +48,9 @@ export const deleteTeam = async ( }, }); - teamCache.revalidate({ - id: deletedTeam.id, - organizationId: deletedTeam.organizationId, - }); - - for (const projectTeam of deletedTeam.projectTeams) { - teamCache.revalidate({ - projectId: projectTeam.projectId, - }); - } - return ok(deletedTeam); } catch (error) { - if (error instanceof PrismaClientKnownRequestError) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { if ( error.code === PrismaErrorType.RecordDoesNotExist || error.code === PrismaErrorType.RelatedRecordDoesNotExist @@ -109,20 +86,9 @@ export const updateTeam = async ( }, }); - teamCache.revalidate({ - id: updatedTeam.id, - organizationId: updatedTeam.organizationId, - }); - - for (const projectTeam of updatedTeam.projectTeams) { - teamCache.revalidate({ - projectId: projectTeam.projectId, - }); - } - return ok(updatedTeam); } catch (error) { - if (error instanceof PrismaClientKnownRequestError) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { if ( error.code === PrismaErrorType.RecordDoesNotExist || error.code === PrismaErrorType.RelatedRecordDoesNotExist diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts index f7ae2215f6ef..9b4089a1d1b6 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts @@ -1,6 +1,5 @@ -import { teamCache } from "@/lib/cache/team"; -import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; -import { describe, expect, it, vi } from "vitest"; +import { Prisma } from "@prisma/client"; +import { describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; import { deleteTeam, getTeam, updateTeam } from "../teams"; @@ -25,7 +24,7 @@ const mockTeam = { describe("Teams Lib", () => { describe("getTeam", () => { - it("returns the team when found", async () => { + test("returns the team when found", async () => { (prisma.team.findUnique as any).mockResolvedValueOnce(mockTeam); const result = await getTeam("org456", "team123"); expect(result.ok).toBe(true); @@ -37,7 +36,7 @@ describe("Teams Lib", () => { }); }); - it("returns a not_found error when team is missing", async () => { + test("returns a not_found error when team is missing", async () => { (prisma.team.findUnique as any).mockResolvedValueOnce(null); const result = await getTeam("org456", "team123"); expect(result.ok).toBe(false); @@ -49,42 +48,33 @@ describe("Teams Lib", () => { } }); - it("returns an internal_server_error when prisma throws", async () => { + test("returns an internal_server_error when prisma throws", async () => { (prisma.team.findUnique as any).mockRejectedValueOnce(new Error("DB error")); const result = await getTeam("org456", "team123"); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.error.type).toBe("internal_server_error"); + expect((result.error as any).type).toBe("internal_server_error"); } }); }); describe("deleteTeam", () => { - it("deletes the team and revalidates cache", async () => { + test("deletes the team", async () => { (prisma.team.delete as any).mockResolvedValueOnce(mockTeam); - // Mock teamCache.revalidate - const revalidateMock = vi.spyOn(teamCache, "revalidate").mockImplementation(() => {}); const result = await deleteTeam("org456", "team123"); expect(prisma.team.delete).toHaveBeenCalledWith({ where: { id: "team123", organizationId: "org456" }, include: { projectTeams: { select: { projectId: true } } }, }); - expect(revalidateMock).toHaveBeenCalledWith({ - id: mockTeam.id, - organizationId: mockTeam.organizationId, - }); - for (const pt of mockTeam.projectTeams) { - expect(revalidateMock).toHaveBeenCalledWith({ projectId: pt.projectId }); - } expect(result.ok).toBe(true); if (result.ok) { expect(result.data).toEqual(mockTeam); } }); - it("returns not_found error on known prisma error", async () => { + test("returns not_found error on known prisma error", async () => { (prisma.team.delete as any).mockRejectedValueOnce( - new PrismaClientKnownRequestError("Not found", { + new Prisma.PrismaClientKnownRequestError("Not found", { code: PrismaErrorType.RecordDoesNotExist, clientVersion: "1.0.0", meta: {}, @@ -100,12 +90,12 @@ describe("Teams Lib", () => { } }); - it("returns internal_server_error on exception", async () => { + test("returns internal_server_error on exception", async () => { (prisma.team.delete as any).mockRejectedValueOnce(new Error("Delete failed")); const result = await deleteTeam("org456", "team123"); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.error.type).toBe("internal_server_error"); + expect((result.error as any).type).toBe("internal_server_error"); } }); }); @@ -114,31 +104,23 @@ describe("Teams Lib", () => { const updateInput = { name: "Updated Team" }; const updatedTeam = { ...mockTeam, ...updateInput }; - it("updates the team successfully and revalidates cache", async () => { + test("updates the team successfully", async () => { (prisma.team.update as any).mockResolvedValueOnce(updatedTeam); - const revalidateMock = vi.spyOn(teamCache, "revalidate").mockImplementation(() => {}); const result = await updateTeam("org456", "team123", updateInput); expect(prisma.team.update).toHaveBeenCalledWith({ where: { id: "team123", organizationId: "org456" }, data: updateInput, include: { projectTeams: { select: { projectId: true } } }, }); - expect(revalidateMock).toHaveBeenCalledWith({ - id: updatedTeam.id, - organizationId: updatedTeam.organizationId, - }); - for (const pt of updatedTeam.projectTeams) { - expect(revalidateMock).toHaveBeenCalledWith({ projectId: pt.projectId }); - } expect(result.ok).toBe(true); if (result.ok) { expect(result.data).toEqual(updatedTeam); } }); - it("returns not_found error when update fails due to missing team", async () => { + test("returns not_found error when update fails due to missing team", async () => { (prisma.team.update as any).mockRejectedValueOnce( - new PrismaClientKnownRequestError("Not found", { + new Prisma.PrismaClientKnownRequestError("Not found", { code: PrismaErrorType.RecordDoesNotExist, clientVersion: "1.0.0", meta: {}, @@ -154,12 +136,12 @@ describe("Teams Lib", () => { } }); - it("returns internal_server_error on generic exception", async () => { + test("returns internal_server_error on generic exception", async () => { (prisma.team.update as any).mockRejectedValueOnce(new Error("Update failed")); const result = await updateTeam("org456", "team123", updateInput); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.error.type).toBe("internal_server_error"); + expect((result.error as any).type).toBe("internal_server_error"); } }); }); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts index 2f12f6aec300..192dda1a8528 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts @@ -12,7 +12,10 @@ import { ZTeamUpdateSchema, } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams"; import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { z } from "zod"; +import { logger } from "@formbricks/logger"; import { OrganizationAccessType } from "@formbricks/types/api-key"; export const GET = async ( @@ -35,7 +38,7 @@ export const GET = async ( const team = await getTeam(params!.organizationId, params!.teamId); if (!team.ok) { - return handleApiError(request, team.error); + return handleApiError(request, team.error as ApiErrorResponseV2); } return responses.successResponse(team); @@ -52,22 +55,46 @@ export const DELETE = async ( params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }), }, externalParams: props.params, - handler: async ({ authentication, parsedInput: { params } }) => { - if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { - return handleApiError(request, { - type: "unauthorized", - details: [{ field: "organizationId", issue: "unauthorized" }], - }); + handler: async ({ authentication, parsedInput: { params }, auditLog }) => { + if (auditLog) { + auditLog.targetId = params.teamId; } - const team = await deleteTeam(params!.organizationId, params!.teamId); + if (!hasOrganizationIdAndAccess(params.organizationId, authentication, OrganizationAccessType.Write)) { + return handleApiError( + request, + { + type: "unauthorized", + details: [{ field: "organizationId", issue: "unauthorized" }], + }, + auditLog + ); + } + + let oldTeamData: any = UNKNOWN_DATA; + try { + const oldTeamResult = await getTeam(params.organizationId, params.teamId); + if (oldTeamResult.ok) { + oldTeamData = oldTeamResult.data; + } + } catch (error) { + logger.error(`Failed to fetch old team data for audit log: ${JSON.stringify(error)}`); + } + + const team = await deleteTeam(params.organizationId, params.teamId); if (!team.ok) { - return handleApiError(request, team.error); + return handleApiError(request, team.error, auditLog); + } + + if (auditLog) { + auditLog.oldObject = oldTeamData; } return responses.successResponse(team); }, + action: "deleted", + targetType: "team", }); export const PUT = ( @@ -81,20 +108,45 @@ export const PUT = ( params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }), body: ZTeamUpdateSchema, }, - handler: async ({ authentication, parsedInput: { body, params } }) => { + handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => { + if (auditLog) { + auditLog.targetId = params.teamId; + } + if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { - return handleApiError(request, { - type: "unauthorized", - details: [{ field: "organizationId", issue: "unauthorized" }], - }); + return handleApiError( + request, + { + type: "unauthorized", + details: [{ field: "organizationId", issue: "unauthorized" }], + }, + auditLog + ); + } + + let oldTeamData: any = UNKNOWN_DATA; + try { + const oldTeamResult = await getTeam(params.organizationId, params.teamId); + if (oldTeamResult.ok) { + oldTeamData = oldTeamResult.data; + } + } catch (error) { + logger.error(`Failed to fetch old team data for audit log: ${JSON.stringify(error)}`); } const team = await updateTeam(params!.organizationId, params!.teamId, body!); if (!team.ok) { - return handleApiError(request, team.error); + return handleApiError(request, team.error, auditLog); + } + + if (auditLog) { + auditLog.oldObject = oldTeamData; + auditLog.newObject = team.data; } return responses.successResponse(team); }, + action: "updated", + targetType: "team", }); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/openapi.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/openapi.ts index f92910e592aa..da641c289c1f 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/openapi.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/openapi.ts @@ -24,7 +24,7 @@ export const getTeamsEndpoint: ZodOpenApiOperationObject = { }), query: ZGetTeamsFilter.sourceType(), }, - tags: ["Organizations API > Teams"], + tags: ["Organizations API - Teams"], responses: { "200": { description: "Teams retrieved successfully.", @@ -46,7 +46,7 @@ export const createTeamEndpoint: ZodOpenApiOperationObject = { organizationId: ZOrganizationIdSchema, }), }, - tags: ["Organizations API > Teams"], + tags: ["Organizations API - Teams"], requestBody: { required: true, description: "The team to create", diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/teams.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/teams.ts index c6653cdf8478..5c0d50da28f9 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/teams.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/teams.ts @@ -1,5 +1,5 @@ import "server-only"; -import { teamCache } from "@/lib/cache/team"; +import { captureTelemetry } from "@/lib/telemetry"; import { getTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/utils"; import { TGetTeamsFilter, @@ -9,8 +9,6 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success"; import { Team } from "@prisma/client"; import { prisma } from "@formbricks/database"; -import { organizationCache } from "@formbricks/lib/organization/cache"; -import { captureTelemetry } from "@formbricks/lib/telemetry"; import { Result, err, ok } from "@formbricks/types/error-handlers"; export const createTeam = async ( @@ -29,14 +27,6 @@ export const createTeam = async ( }, }); - organizationCache.revalidate({ - id: organizationId, - }); - - teamCache.revalidate({ - organizationId: organizationId, - }); - return ok(team); } catch (error) { return err({ type: "internal_server_error", details: [{ field: "team", issue: error.message }] }); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/teams.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/teams.test.ts index b7da581704a2..9a27b6a51036 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/teams.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/teams.test.ts @@ -1,7 +1,6 @@ import { TGetTeamsFilter } from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { organizationCache } from "@formbricks/lib/organization/cache"; import { createTeam, getTeams } from "../teams"; // Define a mock team object @@ -27,12 +26,9 @@ vi.mock("@formbricks/database", () => ({ }, })); -// Mock organizationCache.revalidate -vi.spyOn(organizationCache, "revalidate").mockImplementation(() => {}); - describe("Teams Lib", () => { describe("createTeam", () => { - it("creates a team successfully and revalidates cache", async () => { + test("creates a team successfully and revalidates cache", async () => { (prisma.team.create as any).mockResolvedValueOnce(mockTeam); const teamInput = { name: "Test Team" }; @@ -44,12 +40,11 @@ describe("Teams Lib", () => { organizationId: organizationId, }, }); - expect(organizationCache.revalidate).toHaveBeenCalledWith({ id: organizationId }); expect(result.ok).toBe(true); if (result.ok) expect(result.data).toEqual(mockTeam); }); - it("returns internal error when prisma.team.create fails", async () => { + test("returns internal error when prisma.team.create fails", async () => { (prisma.team.create as any).mockRejectedValueOnce(new Error("Create error")); const teamInput = { name: "Test Team" }; const organizationId = "org456"; @@ -63,7 +58,7 @@ describe("Teams Lib", () => { describe("getTeams", () => { const filter = { limit: 10, skip: 0 }; - it("returns teams with meta on success", async () => { + test("returns teams with meta on success", async () => { const teamsArray = [mockTeam]; // Simulate prisma transaction return [teams, count] (prisma.$transaction as any).mockResolvedValueOnce([teamsArray, teamsArray.length]); @@ -80,7 +75,7 @@ describe("Teams Lib", () => { } }); - it("returns internal_server_error when prisma transaction fails", async () => { + test("returns internal_server_error when prisma transaction fails", async () => { (prisma.$transaction as any).mockRejectedValueOnce(new Error("Transaction error")); const organizationId = "org456"; const result = await getTeams(organizationId, filter as TGetTeamsFilter); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/utils.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/utils.test.ts index 4d77520d2d4f..126b43d5f8fe 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/utils.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/utils.test.ts @@ -1,6 +1,6 @@ import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils"; import { Prisma } from "@prisma/client"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { getTeamsQuery } from "../utils"; // Mock the common utils functions @@ -12,12 +12,12 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({ describe("getTeamsQuery", () => { const organizationId = "org123"; - it("returns base query when no params provided", () => { + test("returns base query when no params provided", () => { const result = getTeamsQuery(organizationId); expect(result.where).toEqual({ organizationId }); }); - it("returns unchanged query if pickCommonFilter returns null/undefined", () => { + test("returns unchanged query if pickCommonFilter returns null/undefined", () => { vi.mocked(pickCommonFilter).mockReturnValueOnce(null as any); const params: any = { someParam: "test" }; const result = getTeamsQuery(organizationId, params); @@ -26,7 +26,7 @@ describe("getTeamsQuery", () => { expect(result.where).toEqual({ organizationId }); }); - it("calls buildCommonFilterQuery and returns updated query when base filter exists", () => { + test("calls buildCommonFilterQuery and returns updated query when base filter exists", () => { const baseFilter = { key: "value" }; vi.mocked(pickCommonFilter).mockReturnValueOnce(baseFilter as any); // Simulate buildCommonFilterQuery to merge base query with baseFilter diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts index 44b61a41bf01..9bd39877ccd2 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts @@ -46,19 +46,30 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza params: z.object({ organizationId: ZOrganizationIdSchema }), }, externalParams: props.params, - handler: async ({ authentication, parsedInput: { body, params } }) => { + handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => { if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { - return handleApiError(request, { - type: "unauthorized", - details: [{ field: "organizationId", issue: "unauthorized" }], - }); + return handleApiError( + request, + { + type: "unauthorized", + details: [{ field: "organizationId", issue: "unauthorized" }], + }, + auditLog + ); } const createTeamResult = await createTeam(body!, authentication.organizationId); if (!createTeamResult.ok) { - return handleApiError(request, createTeamResult.error); + return handleApiError(request, createTeamResult.error, auditLog); + } + + if (auditLog) { + auditLog.targetId = createTeamResult.data.id; + auditLog.newObject = createTeamResult.data; } - return responses.successResponse({ data: createTeamResult.data }); + return responses.createdResponse({ data: createTeamResult.data }); }, + action: "created", + targetType: "team", }); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/openapi.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/openapi.ts index 1289dcf996d9..966f1e4440d9 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/openapi.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/openapi.ts @@ -20,7 +20,7 @@ export const getUsersEndpoint: ZodOpenApiOperationObject = { }), query: ZGetUsersFilter.sourceType(), }, - tags: ["Organizations API > Users"], + tags: ["Organizations API - Users"], responses: { "200": { description: "Users retrieved successfully.", @@ -42,7 +42,7 @@ export const createUserEndpoint: ZodOpenApiOperationObject = { organizationId: ZOrganizationIdSchema, }), }, - tags: ["Organizations API > Users"], + tags: ["Organizations API - Users"], requestBody: { required: true, description: "The user to create", @@ -73,7 +73,7 @@ export const updateUserEndpoint: ZodOpenApiOperationObject = { organizationId: ZOrganizationIdSchema, }), }, - tags: ["Organizations API > Users"], + tags: ["Organizations API - Users"], requestBody: { required: true, description: "The user to update", diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts index c94fc944ed03..186f132a8129 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts @@ -1,9 +1,6 @@ -import { teamCache } from "@/lib/cache/team"; import { TGetUsersFilter } from "@/modules/api/v2/organizations/[organizationId]/users/types/users"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { membershipCache } from "@formbricks/lib/membership/cache"; -import { userCache } from "@formbricks/lib/user/cache"; import { createUser, getUsers, updateUser } from "../users"; const mockUser = { @@ -39,13 +36,9 @@ vi.mock("@formbricks/database", () => ({ }, })); -vi.spyOn(membershipCache, "revalidate").mockImplementation(() => {}); -vi.spyOn(userCache, "revalidate").mockImplementation(() => {}); -vi.spyOn(teamCache, "revalidate").mockImplementation(() => {}); - describe("Users Lib", () => { describe("getUsers", () => { - it("returns users with meta on success", async () => { + test("returns users with meta on success", async () => { const usersArray = [mockUser]; (prisma.$transaction as any).mockResolvedValueOnce([usersArray, usersArray.length]); const result = await getUsers("org456", { limit: 10, skip: 0 } as TGetUsersFilter); @@ -68,7 +61,7 @@ describe("Users Lib", () => { } }); - it("returns internal_server_error if prisma fails", async () => { + test("returns internal_server_error if prisma fails", async () => { (prisma.$transaction as any).mockRejectedValueOnce(new Error("Transaction error")); const result = await getUsers("org456", { limit: 10, skip: 0 } as TGetUsersFilter); expect(result.ok).toBe(false); @@ -79,7 +72,7 @@ describe("Users Lib", () => { }); describe("createUser", () => { - it("creates user and revalidates caches", async () => { + test("creates user and revalidates caches", async () => { (prisma.user.create as any).mockResolvedValueOnce(mockUser); const result = await createUser( { name: "Test User", email: "test@example.com", role: "member" }, @@ -92,7 +85,7 @@ describe("Users Lib", () => { } }); - it("returns internal_server_error if creation fails", async () => { + test("returns internal_server_error if creation fails", async () => { (prisma.user.create as any).mockRejectedValueOnce(new Error("Create error")); const result = await createUser({ name: "fail", email: "fail@example.com", role: "manager" }, "org456"); expect(result.ok).toBe(false); @@ -103,7 +96,7 @@ describe("Users Lib", () => { }); describe("updateUser", () => { - it("updates user and revalidates caches", async () => { + test("updates user and revalidates caches", async () => { (prisma.user.findUnique as any).mockResolvedValueOnce(mockUser); (prisma.$transaction as any).mockResolvedValueOnce([{ ...mockUser, name: "Updated User" }]); const result = await updateUser({ email: mockUser.email, name: "Updated User" }, "org456"); @@ -114,7 +107,7 @@ describe("Users Lib", () => { } }); - it("returns not_found if user doesn't exist", async () => { + test("returns not_found if user doesn't exist", async () => { (prisma.user.findUnique as any).mockResolvedValueOnce(null); const result = await updateUser({ email: "unknown@example.com" }, "org456"); expect(result.ok).toBe(false); @@ -123,7 +116,7 @@ describe("Users Lib", () => { } }); - it("returns internal_server_error if update fails", async () => { + test("returns internal_server_error if update fails", async () => { (prisma.user.findUnique as any).mockResolvedValueOnce(mockUser); (prisma.$transaction as any).mockRejectedValueOnce(new Error("Update error")); const result = await updateUser({ email: mockUser.email }, "org456"); @@ -135,7 +128,7 @@ describe("Users Lib", () => { }); describe("createUser with teams", () => { - it("creates user with existing teams", async () => { + test("creates user with existing teams", async () => { (prisma.team.findMany as any).mockResolvedValueOnce([ { id: "team123", name: "MyTeam", projectTeams: [{ projectId: "proj789" }] }, ]); @@ -150,14 +143,12 @@ describe("Users Lib", () => { ); expect(prisma.user.create).toHaveBeenCalled(); - expect(teamCache.revalidate).toHaveBeenCalled(); - expect(membershipCache.revalidate).toHaveBeenCalled(); expect(result.ok).toBe(true); }); }); describe("updateUser with team changes", () => { - it("removes a team and adds new team", async () => { + test("removes a team and adds new team", async () => { (prisma.user.findUnique as any).mockResolvedValueOnce({ ...mockUser, teamUsers: [{ team: { id: "team123", name: "OldTeam", projectTeams: [{ projectId: "proj789" }] } }], @@ -182,9 +173,6 @@ describe("Users Lib", () => { ); expect(prisma.user.findUnique).toHaveBeenCalled(); - expect(teamCache.revalidate).toHaveBeenCalledTimes(3); - expect(membershipCache.revalidate).toHaveBeenCalled(); - expect(userCache.revalidate).toHaveBeenCalled(); expect(result.ok).toBe(true); if (result.ok) { expect(result.data.teams).toContain("NewTeam"); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/utils.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/utils.test.ts index dd3cb07a2c5e..df626d9b9c94 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/utils.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/utils.test.ts @@ -1,6 +1,6 @@ import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils"; import { TGetUsersFilter } from "@/modules/api/v2/organizations/[organizationId]/users/types/users"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { getUsersQuery } from "../utils"; vi.mock("@/modules/api/v2/management/lib/utils", () => ({ @@ -9,7 +9,7 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({ })); describe("getUsersQuery", () => { - it("returns default query if no params are provided", () => { + test("returns default query if no params are provided", () => { const result = getUsersQuery("org123"); expect(result).toEqual({ where: { @@ -22,7 +22,7 @@ describe("getUsersQuery", () => { }); }); - it("includes email filter if email param is provided", () => { + test("includes email filter if email param is provided", () => { const result = getUsersQuery("org123", { email: "test@example.com" } as TGetUsersFilter); expect(result.where?.email).toEqual({ contains: "test@example.com", @@ -30,12 +30,12 @@ describe("getUsersQuery", () => { }); }); - it("includes id filter if id param is provided", () => { + test("includes id filter if id param is provided", () => { const result = getUsersQuery("org123", { id: "user123" } as TGetUsersFilter); expect(result.where?.id).toBe("user123"); }); - it("applies baseFilter if pickCommonFilter returns something", () => { + test("applies baseFilter if pickCommonFilter returns something", () => { vi.mocked(pickCommonFilter).mockReturnValueOnce({ someField: "test" } as unknown as ReturnType< typeof pickCommonFilter >); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts index 85b7aac577cc..f421e032f3af 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts @@ -1,4 +1,4 @@ -import { teamCache } from "@/lib/cache/team"; +import { captureTelemetry } from "@/lib/telemetry"; import { getUsersQuery } from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils"; import { TGetUsersFilter, @@ -10,9 +10,6 @@ import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success"; import { OrganizationRole, Prisma, TeamUserRole } from "@prisma/client"; import { prisma } from "@formbricks/database"; import { TUser } from "@formbricks/database/zod/users"; -import { membershipCache } from "@formbricks/lib/membership/cache"; -import { captureTelemetry } from "@formbricks/lib/telemetry"; -import { userCache } from "@formbricks/lib/user/cache"; import { Result, err, ok } from "@formbricks/types/error-handlers"; export const getUsers = async ( @@ -131,25 +128,6 @@ export const createUser = async ( }, }); - existingTeams?.forEach((team) => { - teamCache.revalidate({ - id: team.id, - organizationId: organizationId, - }); - - for (const projectTeam of team.projectTeams) { - teamCache.revalidate({ - projectId: projectTeam.projectId, - }); - } - }); - - // revalidate membership cache - membershipCache.revalidate({ - organizationId: organizationId, - userId: user.id, - }); - const returnedUser = { id: user.id, createdAt: user.createdAt, @@ -298,47 +276,6 @@ export const updateUser = async ( // Retrieve the updated user result. Since the update was the last operation, it is the last item. const updatedUser = results[results.length - 1]; - // For each deletion, revalidate the corresponding team and its project caches. - for (const opResult of results.slice(0, deleteTeamOps.length)) { - const deletedTeamUser = opResult; - teamCache.revalidate({ - id: deletedTeamUser.team.id, - userId: existingUser.id, - organizationId, - }); - - deletedTeamUser.team.projectTeams.forEach((projectTeam) => { - teamCache.revalidate({ - projectId: projectTeam.projectId, - }); - }); - } - // For each creation, do the same. - for (const opResult of results.slice(deleteTeamOps.length, deleteTeamOps.length + createTeamOps.length)) { - const newTeamUser = opResult; - teamCache.revalidate({ - id: newTeamUser.team.id, - userId: existingUser.id, - organizationId, - }); - - newTeamUser.team.projectTeams.forEach((projectTeam) => { - teamCache.revalidate({ - projectId: projectTeam.projectId, - }); - }); - } - - // Revalidate membership and user caches for the updated user. - membershipCache.revalidate({ - organizationId, - userId: updatedUser.id, - }); - userCache.revalidate({ - id: updatedUser.id, - email: updatedUser.email, - }); - const returnedUser = { id: updatedUser.id, createdAt: updatedUser.createdAt, diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts index 7097d2d56dbe..e970c81b9af0 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts @@ -1,3 +1,4 @@ +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { responses } from "@/modules/api/v2/lib/response"; import { handleApiError } from "@/modules/api/v2/lib/utils"; @@ -13,9 +14,10 @@ import { ZUserInput, ZUserInputPatch, } from "@/modules/api/v2/organizations/[organizationId]/users/types/users"; +import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { NextRequest } from "next/server"; import { z } from "zod"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { logger } from "@formbricks/logger"; import { OrganizationAccessType } from "@formbricks/types/api-key"; export const GET = async (request: NextRequest, props: { params: Promise<{ organizationId: string }> }) => @@ -59,28 +61,45 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza params: z.object({ organizationId: ZOrganizationIdSchema }), }, externalParams: props.params, - handler: async ({ authentication, parsedInput: { body, params } }) => { + handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => { if (IS_FORMBRICKS_CLOUD) { - return handleApiError(request, { - type: "bad_request", - details: [{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }], - }); + return handleApiError( + request, + { + type: "bad_request", + details: [ + { field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }, + ], + }, + auditLog + ); } if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { - return handleApiError(request, { - type: "unauthorized", - details: [{ field: "organizationId", issue: "unauthorized" }], - }); + return handleApiError( + request, + { + type: "unauthorized", + details: [{ field: "organizationId", issue: "unauthorized" }], + }, + auditLog + ); } const createUserResult = await createUser(body!, authentication.organizationId); if (!createUserResult.ok) { - return handleApiError(request, createUserResult.error); + return handleApiError(request, createUserResult.error, auditLog); + } + + if (auditLog) { + auditLog.targetId = createUserResult.data.id; + auditLog.newObject = createUserResult.data; } - return responses.successResponse({ data: createUserResult.data }); + return responses.createdResponse({ data: createUserResult.data }); }, + action: "created", + targetType: "user", }); export const PATCH = async (request: Request, props: { params: Promise<{ organizationId: string }> }) => @@ -91,33 +110,75 @@ export const PATCH = async (request: Request, props: { params: Promise<{ organiz params: z.object({ organizationId: ZOrganizationIdSchema }), }, externalParams: props.params, - handler: async ({ authentication, parsedInput: { body, params } }) => { + handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => { if (IS_FORMBRICKS_CLOUD) { - return handleApiError(request, { - type: "bad_request", - details: [{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }], - }); + return handleApiError( + request, + { + type: "bad_request", + details: [ + { field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }, + ], + }, + auditLog + ); } if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { - return handleApiError(request, { - type: "unauthorized", - details: [{ field: "organizationId", issue: "unauthorized" }], - }); + return handleApiError( + request, + { + type: "unauthorized", + details: [{ field: "organizationId", issue: "unauthorized" }], + }, + auditLog + ); } if (!body?.email) { - return handleApiError(request, { - type: "bad_request", - details: [{ field: "email", issue: "Email is required" }], + return handleApiError( + request, + { + type: "bad_request", + details: [{ field: "email", issue: "Email is required" }], + }, + auditLog + ); + } + + let oldUserData: any = UNKNOWN_DATA; + try { + const oldUserResult = await getUsers(authentication.organizationId, { + email: body.email, + limit: 1, + skip: 0, + sortBy: "createdAt", + order: "desc", }); + if (oldUserResult.ok) { + oldUserData = oldUserResult.data.data[0]; + } + } catch (error) { + logger.error(`Failed to fetch old user data for audit log: ${JSON.stringify(error)}`); + } + + if (auditLog) { + auditLog.targetId = oldUserData !== UNKNOWN_DATA ? oldUserData?.id : UNKNOWN_DATA; } const updateUserResult = await updateUser(body, authentication.organizationId); if (!updateUserResult.ok) { - return handleApiError(request, updateUserResult.error); + return handleApiError(request, updateUserResult.error, auditLog); + } + + if (auditLog) { + auditLog.targetId = auditLog.targetId === UNKNOWN_DATA ? updateUserResult.data.id : auditLog.targetId; + auditLog.oldObject = oldUserData; + auditLog.newObject = updateUserResult.data; } return responses.successResponse({ data: updateUserResult.data }); }, + action: "updated", + targetType: "user", }); diff --git a/apps/web/modules/api/v2/roles/lib/utils.test.ts b/apps/web/modules/api/v2/roles/lib/utils.test.ts new file mode 100644 index 000000000000..3137be94c84d --- /dev/null +++ b/apps/web/modules/api/v2/roles/lib/utils.test.ts @@ -0,0 +1,29 @@ +import * as constants from "@/lib/constants"; +import { OrganizationRole } from "@prisma/client"; +import { describe, expect, test, vi } from "vitest"; +import { getRoles } from "./utils"; + +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, +})); + +describe("getRoles", () => { + test("should return all roles except billing when not in Formbricks Cloud", () => { + const result = getRoles(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data).toEqual(Object.values(OrganizationRole).filter((role) => role !== "billing")); + } + }); + + test("should return all roles including billing when in Formbricks Cloud", () => { + const originalValue = constants.IS_FORMBRICKS_CLOUD; + Object.defineProperty(constants, "IS_FORMBRICKS_CLOUD", { value: true }); + const result = getRoles(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data).toEqual(Object.values(OrganizationRole)); + } + Object.defineProperty(constants, "IS_FORMBRICKS_CLOUD", { value: originalValue }); + }); +}); diff --git a/apps/web/modules/api/v2/roles/lib/utils.ts b/apps/web/modules/api/v2/roles/lib/utils.ts index 48eff88d75d6..47db5d41f3e9 100644 --- a/apps/web/modules/api/v2/roles/lib/utils.ts +++ b/apps/web/modules/api/v2/roles/lib/utils.ts @@ -1,6 +1,6 @@ +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { OrganizationRole } from "@prisma/client"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { Result, err, ok } from "@formbricks/types/error-handlers"; export const getRoles = (): Result<{ data: string[] }, ApiErrorResponseV2> => { diff --git a/apps/web/modules/auth/actions.ts b/apps/web/modules/auth/actions.ts index 717e8ef250c1..707f00178151 100644 --- a/apps/web/modules/auth/actions.ts +++ b/apps/web/modules/auth/actions.ts @@ -1,9 +1,9 @@ "use server"; +import { createEmailToken } from "@/lib/jwt"; +import { getUserByEmail } from "@/lib/user/service"; import { actionClient } from "@/lib/utils/action-client"; import { z } from "zod"; -import { createEmailToken } from "@formbricks/lib/jwt"; -import { getUserByEmail } from "@formbricks/lib/user/service"; import { InvalidInputError } from "@formbricks/types/errors"; const ZCreateEmailTokenAction = z.object({ diff --git a/apps/web/modules/auth/actions/sign-out.test.ts b/apps/web/modules/auth/actions/sign-out.test.ts new file mode 100644 index 000000000000..f97953dd1c08 --- /dev/null +++ b/apps/web/modules/auth/actions/sign-out.test.ts @@ -0,0 +1,149 @@ +import { logSignOut } from "@/modules/auth/lib/utils"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; +import { logSignOutAction } from "./sign-out"; + +// Mock the dependencies +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +vi.mock("@/modules/auth/lib/utils", () => ({ + logSignOut: vi.fn(), +})); + +// Clear the existing mock from vitestSetup.ts +vi.unmock("@/modules/auth/actions/sign-out"); + +describe("logSignOutAction", () => { + const mockUserId = "user123"; + const mockUserEmail = "test@example.com"; + const mockContext = { + reason: "user_initiated" as const, + redirectUrl: "https://example.com", + organizationId: "org123", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("calls logSignOut with correct parameters", async () => { + await logSignOutAction(mockUserId, mockUserEmail, mockContext); + + expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, mockContext); + expect(logSignOut).toHaveBeenCalledTimes(1); + }); + + test("calls logSignOut with minimal parameters", async () => { + const minimalContext = {}; + + await logSignOutAction(mockUserId, mockUserEmail, minimalContext); + + expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, minimalContext); + expect(logSignOut).toHaveBeenCalledTimes(1); + }); + + test("calls logSignOut with context containing only reason", async () => { + const contextWithReason = { reason: "session_timeout" as const }; + + await logSignOutAction(mockUserId, mockUserEmail, contextWithReason); + + expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, contextWithReason); + expect(logSignOut).toHaveBeenCalledTimes(1); + }); + + test("calls logSignOut with context containing only redirectUrl", async () => { + const contextWithRedirectUrl = { redirectUrl: "https://redirect.com" }; + + await logSignOutAction(mockUserId, mockUserEmail, contextWithRedirectUrl); + + expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, contextWithRedirectUrl); + expect(logSignOut).toHaveBeenCalledTimes(1); + }); + + test("calls logSignOut with context containing only organizationId", async () => { + const contextWithOrgId = { organizationId: "org456" }; + + await logSignOutAction(mockUserId, mockUserEmail, contextWithOrgId); + + expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, contextWithOrgId); + expect(logSignOut).toHaveBeenCalledTimes(1); + }); + + test("handles all possible reason values", async () => { + const reasons = [ + "user_initiated", + "account_deletion", + "email_change", + "session_timeout", + "forced_logout", + ] as const; + + for (const reason of reasons) { + const context = { reason }; + await logSignOutAction(mockUserId, mockUserEmail, context); + + expect(logSignOut).toHaveBeenCalledWith(mockUserId, mockUserEmail, context); + } + + expect(logSignOut).toHaveBeenCalledTimes(reasons.length); + }); + + test("logs error and re-throws when logSignOut throws an Error", async () => { + const mockError = new Error("Failed to log sign out"); + vi.mocked(logSignOut).mockImplementation(() => { + throw mockError; + }); + + await expect(() => logSignOutAction(mockUserId, mockUserEmail, mockContext)).rejects.toThrow(mockError); + + expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", { + userId: mockUserId, + context: mockContext, + error: mockError.message, + }); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + test("logs error and re-throws when logSignOut throws a non-Error", async () => { + const mockError = "String error"; + vi.mocked(logSignOut).mockImplementation(() => { + throw mockError; + }); + + await expect(() => logSignOutAction(mockUserId, mockUserEmail, mockContext)).rejects.toThrow(mockError); + + expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", { + userId: mockUserId, + context: mockContext, + error: mockError, + }); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + test("logs error with empty context when logSignOut throws", async () => { + const mockError = new Error("Failed to log sign out"); + const emptyContext = {}; + vi.mocked(logSignOut).mockImplementation(() => { + throw mockError; + }); + + await expect(() => logSignOutAction(mockUserId, mockUserEmail, emptyContext)).rejects.toThrow(mockError); + + expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", { + userId: mockUserId, + context: emptyContext, + error: mockError.message, + }); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + test("does not log error when logSignOut succeeds", async () => { + await logSignOutAction(mockUserId, mockUserEmail, mockContext); + + expect(logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/auth/actions/sign-out.ts b/apps/web/modules/auth/actions/sign-out.ts new file mode 100644 index 000000000000..8506082428bc --- /dev/null +++ b/apps/web/modules/auth/actions/sign-out.ts @@ -0,0 +1,38 @@ +"use server"; + +import { logSignOut } from "@/modules/auth/lib/utils"; +import { logger } from "@formbricks/logger"; + +/** + * Logs a sign out event + * @param userId - The ID of the user who signed out + * @param userEmail - The email of the user who signed out + * @param context - The context of the sign out event + */ +export const logSignOutAction = async ( + userId: string, + userEmail: string, + context: { + reason?: + | "user_initiated" + | "account_deletion" + | "email_change" + | "session_timeout" + | "forced_logout" + | "password_reset"; + redirectUrl?: string; + organizationId?: string; + } +) => { + try { + logSignOut(userId, userEmail, context); + } catch (error) { + logger.error("Failed to log sign out event", { + userId, + context, + error: error instanceof Error ? error.message : String(error), + }); + // Re-throw to ensure callers are aware of the failure + throw error; + } +}; diff --git a/apps/web/modules/auth/components/back-to-login-button.test.tsx b/apps/web/modules/auth/components/back-to-login-button.test.tsx new file mode 100644 index 000000000000..672153107914 --- /dev/null +++ b/apps/web/modules/auth/components/back-to-login-button.test.tsx @@ -0,0 +1,35 @@ +import { getTranslate } from "@/tolgee/server"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { TFnType } from "@tolgee/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { BackToLoginButton } from "./back-to-login-button"; + +vi.mock("@/tolgee/server", () => ({ + getTranslate: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => {children}, +})); + +describe("BackToLoginButton", () => { + afterEach(() => { + cleanup(); + }); + + test("renders login button with correct link and translation", async () => { + const mockTranslate = vi.mocked(getTranslate); + const mockT: TFnType = (key) => { + if (key === "auth.signup.log_in") return "Back to Login"; + return key; + }; + mockTranslate.mockResolvedValue(mockT); + + render(await BackToLoginButton()); + + const link = screen.getByRole("link", { name: "Back to Login" }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "/auth/login"); + }); +}); diff --git a/apps/web/modules/auth/components/form-wrapper.test.tsx b/apps/web/modules/auth/components/form-wrapper.test.tsx new file mode 100644 index 000000000000..d1373819b21c --- /dev/null +++ b/apps/web/modules/auth/components/form-wrapper.test.tsx @@ -0,0 +1,55 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { FormWrapper } from "./form-wrapper"; + +vi.mock("@/modules/ui/components/logo", () => ({ + Logo: () =>
    Logo
    , +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + target, + rel, + }: { + children: React.ReactNode; + href: string; + target?: string; + rel?: string; + }) => ( + + {children} + + ), +})); + +describe("FormWrapper", () => { + afterEach(() => { + cleanup(); + }); + + test("renders logo and children content", () => { + render( + +
    Test Content
    +
    + ); + + // Check if logo is rendered + const logo = screen.getByTestId("mock-logo"); + expect(logo).toBeInTheDocument(); + + // Check if logo link has correct attributes + const logoLink = screen.getByTestId("mock-link"); + expect(logoLink).toHaveAttribute("href", "https://formbricks.com?utm_source=ce"); + expect(logoLink).toHaveAttribute("target", "_blank"); + expect(logoLink).toHaveAttribute("rel", "noopener noreferrer"); + + // Check if children content is rendered + const content = screen.getByTestId("test-content"); + expect(content).toBeInTheDocument(); + expect(content).toHaveTextContent("Test Content"); + }); +}); diff --git a/apps/web/modules/auth/components/form-wrapper.tsx b/apps/web/modules/auth/components/form-wrapper.tsx index 85c74459de39..0439d8f96d43 100644 --- a/apps/web/modules/auth/components/form-wrapper.tsx +++ b/apps/web/modules/auth/components/form-wrapper.tsx @@ -1,4 +1,5 @@ import { Logo } from "@/modules/ui/components/logo"; +import Link from "next/link"; interface FormWrapperProps { children: React.ReactNode; @@ -9,7 +10,9 @@ export const FormWrapper = ({ children }: FormWrapperProps) => {
    - + + +
    {children}
    diff --git a/apps/web/modules/auth/components/testimonial.test.tsx b/apps/web/modules/auth/components/testimonial.test.tsx new file mode 100644 index 000000000000..c6fae82825ee --- /dev/null +++ b/apps/web/modules/auth/components/testimonial.test.tsx @@ -0,0 +1,59 @@ +import { getTranslate } from "@/tolgee/server"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { TFnType } from "@tolgee/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { Testimonial } from "./testimonial"; + +vi.mock("@/tolgee/server", () => ({ + getTranslate: vi.fn(), +})); + +vi.mock("next/image", () => ({ + default: ({ src, alt }: { src: string; alt: string }) => ( + {alt} + ), +})); + +describe("Testimonial", () => { + afterEach(() => { + cleanup(); + }); + + test("renders testimonial content with translations", async () => { + const mockTranslate = vi.mocked(getTranslate); + const mockT: TFnType = (key) => { + const translations: Record = { + "auth.testimonial_title": "Testimonial Title", + "auth.testimonial_all_features_included": "All features included", + "auth.testimonial_free_and_open_source": "Free and open source", + "auth.testimonial_no_credit_card_required": "No credit card required", + "auth.testimonial_1": "Test testimonial quote", + }; + return translations[key] || key; + }; + mockTranslate.mockResolvedValue(mockT); + + render(await Testimonial()); + + // Check title + expect(screen.getByText("Testimonial Title")).toBeInTheDocument(); + + // Check feature points + expect(screen.getByText("All features included")).toBeInTheDocument(); + expect(screen.getByText("Free and open source")).toBeInTheDocument(); + expect(screen.getByText("No credit card required")).toBeInTheDocument(); + + // Check testimonial quote + expect(screen.getByText("Test testimonial quote")).toBeInTheDocument(); + + // Check testimonial author + expect(screen.getByText("Peer Richelsen, Co-Founder Cal.com")).toBeInTheDocument(); + + // Check images + const images = screen.getAllByTestId("mock-image"); + expect(images).toHaveLength(2); + expect(images[0]).toHaveAttribute("alt", "Cal.com Co-Founder Peer Richelsen"); + expect(images[1]).toHaveAttribute("alt", "Cal.com Logo"); + }); +}); diff --git a/apps/web/modules/auth/email-change-without-verification-success/page.test.tsx b/apps/web/modules/auth/email-change-without-verification-success/page.test.tsx new file mode 100644 index 000000000000..98772b5cc12c --- /dev/null +++ b/apps/web/modules/auth/email-change-without-verification-success/page.test.tsx @@ -0,0 +1,61 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { EmailChangeWithoutVerificationSuccessPage } from "./page"; + +// Mock the necessary dependencies +vi.mock("@/modules/auth/components/back-to-login-button", () => ({ + BackToLoginButton: () =>
    Back to Login
    , +})); + +vi.mock("@/modules/auth/components/form-wrapper", () => ({ + FormWrapper: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), +})); + +vi.mock("@/modules/auth/lib/authOptions", () => ({ + authOptions: {}, +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) })); + +describe("EmailChangeWithoutVerificationSuccessPage", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders success page with correct translations when user is not logged in", async () => { + vi.mocked(getServerSession).mockResolvedValue(null); + + const page = await EmailChangeWithoutVerificationSuccessPage(); + render(page); + + expect(screen.getByTestId("form-wrapper")).toBeInTheDocument(); + expect(screen.getByTestId("back-to-login")).toBeInTheDocument(); + expect(screen.getByText("auth.email-change.email_change_success")).toBeInTheDocument(); + expect(screen.getByText("auth.email-change.email_change_success_description")).toBeInTheDocument(); + }); + + test("redirects to home page when user is logged in", async () => { + vi.mocked(getServerSession).mockResolvedValue({ + user: { id: "123", email: "test@example.com" }, + expires: new Date().toISOString(), + }); + + await EmailChangeWithoutVerificationSuccessPage(); + + expect(redirect).toHaveBeenCalledWith("/"); + }); +}); diff --git a/apps/web/modules/auth/email-change-without-verification-success/page.tsx b/apps/web/modules/auth/email-change-without-verification-success/page.tsx new file mode 100644 index 000000000000..29a1720b10b3 --- /dev/null +++ b/apps/web/modules/auth/email-change-without-verification-success/page.tsx @@ -0,0 +1,29 @@ +import { BackToLoginButton } from "@/modules/auth/components/back-to-login-button"; +import { FormWrapper } from "@/modules/auth/components/form-wrapper"; +import { authOptions } from "@/modules/auth/lib/authOptions"; +import { getTranslate } from "@/tolgee/server"; +import { getServerSession } from "next-auth"; +import type { Session } from "next-auth"; +import { redirect } from "next/navigation"; + +export const EmailChangeWithoutVerificationSuccessPage = async () => { + const t = await getTranslate(); + const session: Session | null = await getServerSession(authOptions); + + if (session) { + redirect("/"); + } + + return ( +
    + +

    + {t("auth.email-change.email_change_success")} +

    +

    {t("auth.email-change.email_change_success_description")}

    +
    + +
    +
    + ); +}; diff --git a/apps/web/modules/auth/forgot-password/actions.test.ts b/apps/web/modules/auth/forgot-password/actions.test.ts new file mode 100644 index 000000000000..f335f1a43ed1 --- /dev/null +++ b/apps/web/modules/auth/forgot-password/actions.test.ts @@ -0,0 +1,230 @@ +import { getUserByEmail } from "@/modules/auth/lib/user"; +// Import mocked functions +import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { sendForgotPasswordEmail } from "@/modules/email"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { forgotPasswordAction } from "./actions"; + +// Mock dependencies +vi.mock("@/lib/constants", () => ({ + PASSWORD_RESET_DISABLED: false, +})); + +vi.mock("@/modules/core/rate-limit/helpers", () => ({ + applyIPRateLimit: vi.fn(), +})); + +vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({ + rateLimitConfigs: { + auth: { + forgotPassword: { interval: 3600, allowedPerInterval: 5, namespace: "auth:forgot" }, + }, + }, +})); + +vi.mock("@/modules/auth/lib/user", () => ({ + getUserByEmail: vi.fn(), +})); + +vi.mock("@/modules/email", () => ({ + sendForgotPasswordEmail: vi.fn(), +})); + +vi.mock("@/lib/utils/action-client", () => ({ + actionClient: { + schema: vi.fn().mockReturnThis(), + action: vi.fn((fn) => fn), + }, +})); + +describe("forgotPasswordAction", () => { + const validInput = { + email: "test@example.com", + }; + + const mockUser = { + id: "user123", + email: "test@example.com", + identityProvider: "email", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Rate Limiting", () => { + test("should apply rate limiting before processing forgot password request", async () => { + vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any); + + await forgotPasswordAction({ parsedInput: validInput } as any); + + expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.forgotPassword); + expect(applyIPRateLimit).toHaveBeenCalledBefore(getUserByEmail as any); + }); + + test("should throw rate limit error when limit exceeded", async () => { + vi.mocked(applyIPRateLimit).mockRejectedValue( + new Error("Maximum number of requests reached. Please try again later.") + ); + + await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow( + "Maximum number of requests reached. Please try again later." + ); + + expect(getUserByEmail).not.toHaveBeenCalled(); + expect(sendForgotPasswordEmail).not.toHaveBeenCalled(); + }); + + test("should use correct rate limit configuration", async () => { + vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any); + + await forgotPasswordAction({ parsedInput: validInput } as any); + + expect(applyIPRateLimit).toHaveBeenCalledWith({ + interval: 3600, + allowedPerInterval: 5, + namespace: "auth:forgot", + }); + }); + + test("should apply rate limiting even when user doesn't exist", async () => { + vi.mocked(getUserByEmail).mockResolvedValue(null); + + const result = await forgotPasswordAction({ parsedInput: validInput } as any); + + expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.forgotPassword); + expect(result).toEqual({ success: true }); + }); + }); + + describe("Password Reset Flow", () => { + test("should send password reset email when user exists with email identity provider", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any); + + const result = await forgotPasswordAction({ parsedInput: validInput } as any); + + expect(applyIPRateLimit).toHaveBeenCalled(); + expect(getUserByEmail).toHaveBeenCalledWith(validInput.email); + expect(sendForgotPasswordEmail).toHaveBeenCalledWith(mockUser); + expect(result).toEqual({ success: true }); + }); + + test("should not send email when user doesn't exist", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(null); + + const result = await forgotPasswordAction({ parsedInput: validInput } as any); + + expect(applyIPRateLimit).toHaveBeenCalled(); + expect(getUserByEmail).toHaveBeenCalledWith(validInput.email); + expect(sendForgotPasswordEmail).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + test("should not send email when user has non-email identity provider", async () => { + const ssoUser = { ...mockUser, identityProvider: "google" }; + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any); + + const result = await forgotPasswordAction({ parsedInput: validInput } as any); + + expect(applyIPRateLimit).toHaveBeenCalled(); + expect(getUserByEmail).toHaveBeenCalledWith(validInput.email); + expect(sendForgotPasswordEmail).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + }); + + describe("Password Reset Disabled", () => { + test("should check password reset is enabled in our implementation", async () => { + // This test verifies that password reset is enabled by default + // The actual PASSWORD_RESET_DISABLED check is part of the implementation + // and we've mocked it as false, so rate limiting should work normally + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any); + + const result = await forgotPasswordAction({ parsedInput: validInput } as any); + + expect(applyIPRateLimit).toHaveBeenCalled(); + expect(getUserByEmail).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + }); + + describe("Error Handling", () => { + test("should propagate rate limiting errors", async () => { + const rateLimitError = new Error("Maximum number of requests reached. Please try again later."); + vi.mocked(applyIPRateLimit).mockRejectedValue(rateLimitError); + + await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow( + "Maximum number of requests reached. Please try again later." + ); + }); + + test("should handle user lookup errors after rate limiting", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockRejectedValue(new Error("Database error")); + + await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow( + "Database error" + ); + + expect(applyIPRateLimit).toHaveBeenCalled(); + }); + + test("should handle email sending errors after rate limiting", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any); + vi.mocked(sendForgotPasswordEmail).mockRejectedValue(new Error("Email service error")); + + await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow( + "Email service error" + ); + + expect(applyIPRateLimit).toHaveBeenCalled(); + expect(getUserByEmail).toHaveBeenCalled(); + }); + }); + + describe("Security Considerations", () => { + test("should always return success even for non-existent users to prevent email enumeration", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(null); + + const result = await forgotPasswordAction({ parsedInput: validInput } as any); + + expect(result).toEqual({ success: true }); + }); + + test("should always return success even for SSO users to prevent identity provider enumeration", async () => { + const ssoUser = { ...mockUser, identityProvider: "github" }; + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any); + + const result = await forgotPasswordAction({ parsedInput: validInput } as any); + + expect(result).toEqual({ success: true }); + expect(sendForgotPasswordEmail).not.toHaveBeenCalled(); + }); + + test("should rate limit all requests regardless of user existence", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + + // Test with existing user + vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any); + await forgotPasswordAction({ parsedInput: validInput } as any); + + // Test with non-existing user + vi.mocked(getUserByEmail).mockResolvedValue(null); + await forgotPasswordAction({ parsedInput: { email: "nonexistent@example.com" } } as any); + + expect(applyIPRateLimit).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/apps/web/modules/auth/forgot-password/actions.ts b/apps/web/modules/auth/forgot-password/actions.ts index d8702274e11f..2b14a1560cd0 100644 --- a/apps/web/modules/auth/forgot-password/actions.ts +++ b/apps/web/modules/auth/forgot-password/actions.ts @@ -1,9 +1,13 @@ "use server"; +import { PASSWORD_RESET_DISABLED } from "@/lib/constants"; import { actionClient } from "@/lib/utils/action-client"; import { getUserByEmail } from "@/modules/auth/lib/user"; +import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { sendForgotPasswordEmail } from "@/modules/email"; import { z } from "zod"; +import { OperationNotAllowedError } from "@formbricks/types/errors"; import { ZUserEmail } from "@formbricks/types/user"; const ZForgotPasswordAction = z.object({ @@ -13,9 +17,17 @@ const ZForgotPasswordAction = z.object({ export const forgotPasswordAction = actionClient .schema(ZForgotPasswordAction) .action(async ({ parsedInput }) => { + await applyIPRateLimit(rateLimitConfigs.auth.forgotPassword); + + if (PASSWORD_RESET_DISABLED) { + throw new OperationNotAllowedError("Password reset is disabled"); + } + const user = await getUserByEmail(parsedInput.email); - if (user) { + + if (user && user.identityProvider === "email") { await sendForgotPasswordEmail(user); } + return { success: true }; }); diff --git a/apps/web/modules/auth/forgot-password/email-sent/page.test.tsx b/apps/web/modules/auth/forgot-password/email-sent/page.test.tsx new file mode 100644 index 000000000000..f41db2c5878f --- /dev/null +++ b/apps/web/modules/auth/forgot-password/email-sent/page.test.tsx @@ -0,0 +1,26 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { EmailSentPage } from "./page"; + +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); + +vi.mock("@/modules/auth/components/back-to-login-button", () => ({ + BackToLoginButton: () =>
    Back to Login
    , +})); + +describe("EmailSentPage", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the email sent page with correct translations", async () => { + render(await EmailSentPage()); + + expect(screen.getByText("auth.forgot-password.email-sent.heading")).toBeInTheDocument(); + expect(screen.getByText("auth.forgot-password.email-sent.text")).toBeInTheDocument(); + expect(screen.getByText("Back to Login")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/auth/forgot-password/page.test.tsx b/apps/web/modules/auth/forgot-password/page.test.tsx new file mode 100644 index 000000000000..e05ea8159671 --- /dev/null +++ b/apps/web/modules/auth/forgot-password/page.test.tsx @@ -0,0 +1,27 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ForgotPasswordPage } from "./page"; + +vi.mock("@/modules/auth/components/form-wrapper", () => ({ + FormWrapper: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), +})); + +vi.mock("@/modules/auth/forgot-password/components/forgot-password-form", () => ({ + ForgotPasswordForm: () =>
    Forgot Password Form
    , +})); + +describe("ForgotPasswordPage", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the forgot password page with form wrapper and form", () => { + render(); + + expect(screen.getByTestId("form-wrapper")).toBeInTheDocument(); + expect(screen.getByTestId("forgot-password-form")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/auth/forgot-password/reset/actions.ts b/apps/web/modules/auth/forgot-password/reset/actions.ts index afaf06aaf8bc..585dc70966b5 100644 --- a/apps/web/modules/auth/forgot-password/reset/actions.ts +++ b/apps/web/modules/auth/forgot-password/reset/actions.ts @@ -1,12 +1,13 @@ "use server"; +import { hashPassword } from "@/lib/auth"; +import { verifyToken } from "@/lib/jwt"; import { actionClient } from "@/lib/utils/action-client"; -import { updateUser } from "@/modules/auth/lib/user"; -import { getUser } from "@/modules/auth/lib/user"; +import { ActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { getUser, updateUser } from "@/modules/auth/lib/user"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { sendPasswordResetNotifyEmail } from "@/modules/email"; import { z } from "zod"; -import { hashPassword } from "@formbricks/lib/auth"; -import { verifyToken } from "@formbricks/lib/jwt"; import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ZUserPassword } from "@formbricks/types/user"; @@ -15,16 +16,25 @@ const ZResetPasswordAction = z.object({ password: ZUserPassword, }); -export const resetPasswordAction = actionClient - .schema(ZResetPasswordAction) - .action(async ({ parsedInput }) => { - const hashedPassword = await hashPassword(parsedInput.password); - const { id } = await verifyToken(parsedInput.token); - const user = await getUser(id); - if (!user) { - throw new ResourceNotFoundError("user", id); +export const resetPasswordAction = actionClient.schema(ZResetPasswordAction).action( + withAuditLogging( + "updated", + "user", + async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record }) => { + const hashedPassword = await hashPassword(parsedInput.password); + const { id } = await verifyToken(parsedInput.token); + const oldObject = await getUser(id); + if (!oldObject) { + throw new ResourceNotFoundError("user", id); + } + const updatedUser = await updateUser(id, { password: hashedPassword }); + + ctx.auditLoggingCtx.userId = id; + ctx.auditLoggingCtx.oldObject = oldObject; + ctx.auditLoggingCtx.newObject = updatedUser; + + await sendPasswordResetNotifyEmail(updatedUser); + return { success: true }; } - const updatedUser = await updateUser(id, { password: hashedPassword }); - await sendPasswordResetNotifyEmail(updatedUser); - return { success: true }; - }); + ) +); diff --git a/apps/web/modules/auth/forgot-password/reset/components/reset-password-form.test.tsx b/apps/web/modules/auth/forgot-password/reset/components/reset-password-form.test.tsx new file mode 100644 index 000000000000..7feb91e40f76 --- /dev/null +++ b/apps/web/modules/auth/forgot-password/reset/components/reset-password-form.test.tsx @@ -0,0 +1,132 @@ +import { resetPasswordAction } from "@/modules/auth/forgot-password/reset/actions"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRouter, useSearchParams } from "next/navigation"; +import { toast } from "react-hot-toast"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ResetPasswordForm } from "./reset-password-form"; + +vi.mock("next/navigation", () => ({ + useRouter: vi.fn(), + useSearchParams: vi.fn(), +})); + +vi.mock("@/modules/auth/forgot-password/reset/actions", () => ({ + resetPasswordAction: vi.fn(), +})); + +vi.mock("react-hot-toast", () => ({ + toast: { + error: vi.fn(), + }, +})); + +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +describe("ResetPasswordForm", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockRouter = { + push: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + }; + + const mockSearchParams = { + get: vi.fn(), + append: vi.fn(), + delete: vi.fn(), + set: vi.fn(), + sort: vi.fn(), + toString: vi.fn(), + forEach: vi.fn(), + entries: vi.fn(), + keys: vi.fn(), + values: vi.fn(), + has: vi.fn(), + }; + + beforeEach(() => { + vi.mocked(useRouter).mockReturnValue(mockRouter as any); + vi.mocked(useSearchParams).mockReturnValue(mockSearchParams as any); + vi.mocked(mockSearchParams.get).mockReturnValue("test-token"); + }); + + test("renders the form with password fields", () => { + render(); + + expect(screen.getByLabelText("auth.forgot-password.reset.new_password")).toBeInTheDocument(); + expect(screen.getByLabelText("auth.forgot-password.reset.confirm_password")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "auth.forgot-password.reset_password" })).toBeInTheDocument(); + }); + + test("shows error when passwords do not match", async () => { + render(); + + const passwordInput = screen.getByLabelText("auth.forgot-password.reset.new_password"); + const confirmPasswordInput = screen.getByLabelText("auth.forgot-password.reset.confirm_password"); + + await userEvent.type(passwordInput, "Password123!"); + await userEvent.type(confirmPasswordInput, "Different123!"); + + const submitButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("auth.forgot-password.reset.passwords_do_not_match"); + }); + }); + + test("successfully resets password and redirects", async () => { + vi.mocked(resetPasswordAction).mockResolvedValueOnce({ data: { success: true } }); + + render(); + + const passwordInput = screen.getByLabelText("auth.forgot-password.reset.new_password"); + const confirmPasswordInput = screen.getByLabelText("auth.forgot-password.reset.confirm_password"); + + await userEvent.type(passwordInput, "Password123!"); + await userEvent.type(confirmPasswordInput, "Password123!"); + + const submitButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(resetPasswordAction).toHaveBeenCalledWith({ + token: "test-token", + password: "Password123!", + }); + expect(mockRouter.push).toHaveBeenCalledWith("/auth/forgot-password/reset/success"); + }); + }); + + test("shows error when no token is provided", async () => { + vi.mocked(mockSearchParams.get).mockReturnValueOnce(null); + + render(); + + const passwordInput = screen.getByLabelText("auth.forgot-password.reset.new_password"); + const confirmPasswordInput = screen.getByLabelText("auth.forgot-password.reset.confirm_password"); + + await userEvent.type(passwordInput, "Password123!"); + await userEvent.type(confirmPasswordInput, "Password123!"); + + const submitButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("auth.forgot-password.reset.no_token_provided"); + }); + }); +}); diff --git a/apps/web/modules/auth/forgot-password/reset/success/page.test.tsx b/apps/web/modules/auth/forgot-password/reset/success/page.test.tsx new file mode 100644 index 000000000000..31c9374d934d --- /dev/null +++ b/apps/web/modules/auth/forgot-password/reset/success/page.test.tsx @@ -0,0 +1,30 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ResetPasswordSuccessPage } from "./page"; + +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, +})); + +vi.mock("@/modules/auth/components/back-to-login-button", () => ({ + BackToLoginButton: () => , +})); + +vi.mock("@/modules/auth/components/form-wrapper", () => ({ + FormWrapper: ({ children }: { children: React.ReactNode }) =>
    {children}
    , +})); + +describe("ResetPasswordSuccessPage", () => { + afterEach(() => { + cleanup(); + }); + + test("renders success page with correct translations", async () => { + render(await ResetPasswordSuccessPage()); + + expect(screen.getByText("auth.forgot-password.reset.success.heading")).toBeInTheDocument(); + expect(screen.getByText("auth.forgot-password.reset.success.text")).toBeInTheDocument(); + expect(screen.getByText("Back to Login")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/auth/hooks/use-sign-out.test.tsx b/apps/web/modules/auth/hooks/use-sign-out.test.tsx new file mode 100644 index 000000000000..f3ffe20b00ff --- /dev/null +++ b/apps/web/modules/auth/hooks/use-sign-out.test.tsx @@ -0,0 +1,251 @@ +import { logSignOutAction } from "@/modules/auth/actions/sign-out"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, renderHook } from "@testing-library/react"; +import { signOut } from "next-auth/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; + +// Import the actual hook (unmock it for testing) +vi.unmock("@/modules/auth/hooks/use-sign-out"); +const { useSignOut } = await import("./use-sign-out"); + +// Mock dependencies +vi.mock("@/modules/auth/actions/sign-out", () => ({ + logSignOutAction: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next-auth/react", () => ({ + signOut: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +describe("useSignOut", () => { + const mockSessionUser = { + id: "user-123", + email: "test@example.com", + }; + + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should return signOut function", () => { + const { result } = renderHook(() => useSignOut()); + + expect(result.current.signOut).toBeDefined(); + expect(typeof result.current.signOut).toBe("function"); + }); + + test("should sign out without audit logging when no session user", async () => { + const { result } = renderHook(() => useSignOut()); + + await result.current.signOut(); + + expect(logSignOutAction).not.toHaveBeenCalled(); + expect(signOut).toHaveBeenCalledWith({ + redirect: undefined, + callbackUrl: undefined, + }); + }); + + test("should sign out with audit logging when session user exists", async () => { + const { result } = renderHook(() => useSignOut(mockSessionUser)); + + await result.current.signOut({ + reason: "user_initiated", + redirectUrl: "/dashboard", + organizationId: "org-123", + }); + + expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", { + reason: "user_initiated", + redirectUrl: "/dashboard", + organizationId: "org-123", + }); + + expect(signOut).toHaveBeenCalledWith({ + redirect: undefined, + callbackUrl: undefined, + }); + }); + + test("should handle null session user", async () => { + const { result } = renderHook(() => useSignOut(null)); + + await result.current.signOut(); + + expect(logSignOutAction).not.toHaveBeenCalled(); + expect(signOut).toHaveBeenCalledWith({ + redirect: undefined, + callbackUrl: undefined, + }); + }); + + test("should use default reason when not provided", async () => { + const { result } = renderHook(() => useSignOut(mockSessionUser)); + + await result.current.signOut(); + + expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", { + reason: "user_initiated", + redirectUrl: undefined, + organizationId: undefined, + }); + }); + + test("should use callbackUrl as redirectUrl when redirectUrl not provided", async () => { + const { result } = renderHook(() => useSignOut(mockSessionUser)); + + await result.current.signOut({ + callbackUrl: "/auth/login", + organizationId: "org-456", + }); + + expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", { + reason: "user_initiated", + redirectUrl: "/auth/login", + organizationId: "org-456", + }); + + expect(signOut).toHaveBeenCalledWith({ + redirect: undefined, + callbackUrl: "/auth/login", + }); + }); + + test("should pass through NextAuth signOut options", async () => { + const { result } = renderHook(() => useSignOut(mockSessionUser)); + + await result.current.signOut({ + redirect: false, + callbackUrl: "/custom-redirect", + }); + + expect(signOut).toHaveBeenCalledWith({ + redirect: false, + callbackUrl: "/custom-redirect", + }); + }); + + test("should handle different sign out reasons", async () => { + const { result } = renderHook(() => useSignOut(mockSessionUser)); + + const reasons = ["account_deletion", "email_change", "session_timeout", "forced_logout"] as const; + + for (const reason of reasons) { + vi.clearAllMocks(); + + await result.current.signOut({ reason }); + + expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", { + reason, + redirectUrl: undefined, + organizationId: undefined, + }); + } + }); + + test("should handle session user without email", async () => { + const userWithoutEmail = { id: "user-456" }; + const { result } = renderHook(() => useSignOut(userWithoutEmail)); + + await result.current.signOut(); + + expect(logSignOutAction).toHaveBeenCalledWith("user-456", "", { + reason: "user_initiated", + redirectUrl: undefined, + organizationId: undefined, + }); + }); + + test("should not block sign out when audit logging fails", async () => { + vi.mocked(logSignOutAction).mockRejectedValueOnce(new Error("Audit logging failed")); + + const { result } = renderHook(() => useSignOut(mockSessionUser)); + + await result.current.signOut(); + + expect(logger.error).toHaveBeenCalledWith("Failed to log signOut event:", expect.any(Error)); + + expect(signOut).toHaveBeenCalledWith({ + redirect: undefined, + callbackUrl: undefined, + }); + }); + + test("should return NextAuth signOut result", async () => { + const mockSignOutResult = { url: "https://example.com/signed-out" }; + vi.mocked(signOut).mockResolvedValueOnce(mockSignOutResult); + + const { result } = renderHook(() => useSignOut(mockSessionUser)); + + const signOutResult = await result.current.signOut(); + + expect(signOutResult).toBe(mockSignOutResult); + }); + + test("should handle audit logging error and still return NextAuth result", async () => { + const mockSignOutResult = { url: "https://example.com/signed-out" }; + vi.mocked(logSignOutAction).mockRejectedValueOnce(new Error("Network error")); + vi.mocked(signOut).mockResolvedValueOnce(mockSignOutResult); + + const { result } = renderHook(() => useSignOut(mockSessionUser)); + + const signOutResult = await result.current.signOut(); + + expect(logger.error).toHaveBeenCalled(); + expect(signOutResult).toBe(mockSignOutResult); + }); + + test("should handle complex sign out scenario", async () => { + const { result } = renderHook(() => useSignOut(mockSessionUser)); + + await result.current.signOut({ + reason: "email_change", + redirectUrl: "/profile/email-changed", + organizationId: "org-complex-123", + redirect: true, + callbackUrl: "/dashboard", + }); + + expect(logSignOutAction).toHaveBeenCalledWith("user-123", "test@example.com", { + reason: "email_change", + redirectUrl: "/profile/email-changed", // redirectUrl takes precedence over callbackUrl + organizationId: "org-complex-123", + }); + + expect(signOut).toHaveBeenCalledWith({ + redirect: true, + callbackUrl: "/dashboard", + }); + }); + + test("should wait for audit logging before calling NextAuth signOut", async () => { + let auditLogResolved = false; + vi.mocked(logSignOutAction).mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + auditLogResolved = true; + }); + + const { result } = renderHook(() => useSignOut(mockSessionUser)); + + const signOutPromise = result.current.signOut(); + + // NextAuth signOut should not be called immediately + expect(signOut).not.toHaveBeenCalled(); + + await signOutPromise; + + expect(auditLogResolved).toBe(true); + expect(signOut).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/auth/hooks/use-sign-out.ts b/apps/web/modules/auth/hooks/use-sign-out.ts new file mode 100644 index 000000000000..e8cffa54f568 --- /dev/null +++ b/apps/web/modules/auth/hooks/use-sign-out.ts @@ -0,0 +1,59 @@ +import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; +import { logSignOutAction } from "@/modules/auth/actions/sign-out"; +import { signOut } from "next-auth/react"; +import { logger } from "@formbricks/logger"; + +interface UseSignOutOptions { + reason?: + | "user_initiated" + | "account_deletion" + | "email_change" + | "session_timeout" + | "forced_logout" + | "password_reset"; + redirectUrl?: string; + organizationId?: string; + redirect?: boolean; + callbackUrl?: string; + clearEnvironmentId?: boolean; +} + +interface SessionUser { + id: string; + email?: string; +} + +/** + * Custom hook to handle sign out with audit logging + * @param sessionUser - The current user session data (optional) + * @returns {Object} - An object containing the signOutWithAudit function + */ +export const useSignOut = (sessionUser?: SessionUser | null) => { + const signOutWithAudit = async (options?: UseSignOutOptions) => { + // Log audit event before signing out (server action) + if (sessionUser?.id) { + try { + await logSignOutAction(sessionUser.id, sessionUser.email ?? "", { + reason: options?.reason || "user_initiated", // NOSONAR // We want to check for empty strings + redirectUrl: options?.redirectUrl || options?.callbackUrl, // NOSONAR // We want to check for empty strings + organizationId: options?.organizationId, + }); + } catch (error) { + // Don't block signOut if audit logging fails + logger.error("Failed to log signOut event:", error); + } + } + + if (options?.clearEnvironmentId) { + localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS); + } + + // Call NextAuth signOut + return await signOut({ + redirect: options?.redirect, + callbackUrl: options?.callbackUrl, + }); + }; + + return { signOut: signOutWithAudit }; +}; diff --git a/apps/web/modules/auth/invite/components/content-layout.test.tsx b/apps/web/modules/auth/invite/components/content-layout.test.tsx new file mode 100644 index 000000000000..f4b44302b34e --- /dev/null +++ b/apps/web/modules/auth/invite/components/content-layout.test.tsx @@ -0,0 +1,27 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { ContentLayout } from "./content-layout"; + +describe("ContentLayout", () => { + afterEach(() => { + cleanup(); + }); + + test("renders headline and description", () => { + render(); + + expect(screen.getByText("Test Headline")).toBeInTheDocument(); + expect(screen.getByText("Test Description")).toBeInTheDocument(); + }); + + test("renders children when provided", () => { + render( + +
    Test Child
    +
    + ); + + expect(screen.getByText("Test Child")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/auth/invite/lib/invite.test.ts b/apps/web/modules/auth/invite/lib/invite.test.ts new file mode 100644 index 000000000000..593e7e7543cc --- /dev/null +++ b/apps/web/modules/auth/invite/lib/invite.test.ts @@ -0,0 +1,117 @@ +import { type InviteWithCreator } from "@/modules/auth/invite/types/invites"; +import { Prisma } from "@prisma/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { deleteInvite, getInvite } from "./invite"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + invite: { + delete: vi.fn(), + findUnique: vi.fn(), + }, + }, +})); + +describe("invite", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("deleteInvite", () => { + test("should delete an invite and return true", async () => { + const mockInvite = { + id: "test-id", + organizationId: "org-id", + }; + + vi.mocked(prisma.invite.delete).mockResolvedValue(mockInvite as any); + + const result = await deleteInvite("test-id"); + + expect(result).toBe(true); + expect(prisma.invite.delete).toHaveBeenCalledWith({ + where: { id: "test-id" }, + select: { + id: true, + organizationId: true, + }, + }); + }); + + test("should throw ResourceNotFoundError when invite is not found", async () => { + vi.mocked(prisma.invite.delete).mockResolvedValue(null as any); + + await expect(deleteInvite("test-id")).rejects.toThrow(ResourceNotFoundError); + }); + + test("should throw DatabaseError when Prisma throws an error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", { + code: "P2002", + clientVersion: "1.0.0", + }); + + vi.mocked(prisma.invite.delete).mockRejectedValue(prismaError); + + await expect(deleteInvite("test-id")).rejects.toThrow(DatabaseError); + }); + }); + + describe("getInvite", () => { + test("should return invite with creator details", async () => { + const mockInvite: InviteWithCreator = { + id: "test-id", + expiresAt: new Date(), + organizationId: "org-id", + role: "member", + teamIds: ["team-1"], + creator: { + name: "Test User", + email: "test@example.com", + }, + }; + + vi.mocked(prisma.invite.findUnique).mockResolvedValue(mockInvite); + + const result = await getInvite("test-id"); + + expect(result).toEqual(mockInvite); + expect(prisma.invite.findUnique).toHaveBeenCalledWith({ + where: { id: "test-id" }, + select: { + id: true, + expiresAt: true, + organizationId: true, + role: true, + teamIds: true, + creator: { + select: { + name: true, + email: true, + }, + }, + }, + }); + }); + + test("should return null when invite is not found", async () => { + vi.mocked(prisma.invite.findUnique).mockResolvedValue(null); + + const result = await getInvite("test-id"); + + expect(result).toBeNull(); + }); + + test("should throw DatabaseError when Prisma throws an error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", { + code: "P2002", + clientVersion: "1.0.0", + }); + + vi.mocked(prisma.invite.findUnique).mockRejectedValue(prismaError); + + await expect(getInvite("test-id")).rejects.toThrow(DatabaseError); + }); + }); +}); diff --git a/apps/web/modules/auth/invite/lib/invite.ts b/apps/web/modules/auth/invite/lib/invite.ts index ae007c20810f..cc5b98b00249 100644 --- a/apps/web/modules/auth/invite/lib/invite.ts +++ b/apps/web/modules/auth/invite/lib/invite.ts @@ -1,9 +1,7 @@ -import { inviteCache } from "@/lib/cache/invite"; import { type InviteWithCreator } from "@/modules/auth/invite/types/invites"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; export const deleteInvite = async (inviteId: string): Promise => { @@ -22,11 +20,6 @@ export const deleteInvite = async (inviteId: string): Promise => { throw new ResourceNotFoundError("Invite", inviteId); } - inviteCache.revalidate({ - id: invite.id, - organizationId: invite.organizationId, - }); - return true; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -37,42 +30,33 @@ export const deleteInvite = async (inviteId: string): Promise => { } }; -export const getInvite = reactCache( - async (inviteId: string): Promise => - cache( - async () => { - try { - const invite = await prisma.invite.findUnique({ - where: { - id: inviteId, - }, - select: { - id: true, - expiresAt: true, - organizationId: true, - role: true, - teamIds: true, - creator: { - select: { - name: true, - email: true, - }, - }, - }, - }); +export const getInvite = reactCache(async (inviteId: string): Promise => { + try { + const invite = await prisma.invite.findUnique({ + where: { + id: inviteId, + }, + select: { + id: true, + expiresAt: true, + organizationId: true, + role: true, + teamIds: true, + creator: { + select: { + name: true, + email: true, + }, + }, + }, + }); - return invite; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } + return invite; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } - throw error; - } - }, - [`invite-getInvite-${inviteId}`], - { - tags: [inviteCache.tag.byId(inviteId)], - } - )() -); + throw error; + } +}); diff --git a/apps/web/modules/auth/invite/lib/team.test.ts b/apps/web/modules/auth/invite/lib/team.test.ts new file mode 100644 index 000000000000..1343a81b1964 --- /dev/null +++ b/apps/web/modules/auth/invite/lib/team.test.ts @@ -0,0 +1,53 @@ +import { OrganizationRole, Prisma } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { DatabaseError } from "@formbricks/types/errors"; +import { createTeamMembership } from "./team"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + team: { + findUnique: vi.fn(), + }, + teamUser: { + create: vi.fn(), + }, + }, +})); + +describe("createTeamMembership", () => { + const mockInvite = { + teamIds: ["team1", "team2"], + role: "owner" as OrganizationRole, + organizationId: "org1", + }; + const mockUserId = "user1"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("creates team memberships and revalidates caches", async () => { + const mockTeam = { + projectTeams: [{ projectId: "project1" }], + }; + + vi.mocked(prisma.team.findUnique).mockResolvedValue(mockTeam as any); + vi.mocked(prisma.teamUser.create).mockResolvedValue({} as any); + + await createTeamMembership(mockInvite, mockUserId); + + expect(prisma.team.findUnique).toHaveBeenCalledTimes(2); + expect(prisma.teamUser.create).toHaveBeenCalledTimes(2); + }); + + test("handles database errors", async () => { + const dbError = new Prisma.PrismaClientKnownRequestError("Database error", { + code: "P2002", + clientVersion: "5.0.0", + }); + vi.mocked(prisma.team.findUnique).mockRejectedValue(dbError); + + await expect(createTeamMembership(mockInvite, mockUserId)).rejects.toThrow(DatabaseError); + }); +}); diff --git a/apps/web/modules/auth/invite/lib/team.ts b/apps/web/modules/auth/invite/lib/team.ts index 00ddc6dab607..e6da07f798ee 100644 --- a/apps/web/modules/auth/invite/lib/team.ts +++ b/apps/web/modules/auth/invite/lib/team.ts @@ -1,10 +1,8 @@ import "server-only"; -import { teamCache } from "@/lib/cache/team"; +import { getAccessFlags } from "@/lib/membership/utils"; import { CreateMembershipInvite } from "@/modules/auth/invite/types/invites"; import { Prisma } from "@prisma/client"; import { prisma } from "@formbricks/database"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; -import { projectCache } from "@formbricks/lib/project/cache"; import { DatabaseError } from "@formbricks/types/errors"; export const createTeamMembership = async (invite: CreateMembershipInvite, userId: string): Promise => { @@ -44,17 +42,6 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI validProjectIds.push(...team.projectTeams.map((pt) => pt.projectId)); } } - - for (const projectId of validProjectIds) { - teamCache.revalidate({ id: projectId }); - } - - for (const teamId of validTeamIds) { - teamCache.revalidate({ id: teamId }); - } - - teamCache.revalidate({ userId, organizationId: invite.organizationId }); - projectCache.revalidate({ userId, organizationId: invite.organizationId }); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError(error.message); diff --git a/apps/web/modules/auth/invite/page.test.tsx b/apps/web/modules/auth/invite/page.test.tsx new file mode 100644 index 000000000000..1e3b049ef99a --- /dev/null +++ b/apps/web/modules/auth/invite/page.test.tsx @@ -0,0 +1,99 @@ +import { verifyInviteToken } from "@/lib/jwt"; +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/preact"; +import { getServerSession } from "next-auth"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { getInvite } from "./lib/invite"; +import { InvitePage } from "./page"; + +// Mock Next.js headers to avoid `headers()` request scope error +vi.mock("next/headers", () => ({ + headers: () => ({ + get: () => "en", + }), +})); + +// Include AVAILABLE_LOCALES for locale matching +vi.mock("@/lib/constants", () => ({ + AVAILABLE_LOCALES: ["en"], + WEBAPP_URL: "http://localhost:3000", + ENCRYPTION_KEY: "test-encryption-key-32-chars-long!!", + IS_FORMBRICKS_CLOUD: false, + IS_PRODUCTION: false, + ENTERPRISE_LICENSE_KEY: undefined, + FB_LOGO_URL: "https://formbricks.com/logo.png", + SMTP_HOST: "smtp.example.com", + SMTP_PORT: "587", + SESSION_MAX_AGE: 1000, + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: true, +})); + +vi.mock("@/lib/env", () => ({ + env: { + PUBLIC_URL: "https://public-domain.com", + }, +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("@/lib/user/service", () => ({ + getUser: vi.fn(), +})); + +vi.mock("./lib/invite", () => ({ + getInvite: vi.fn(), +})); + +vi.mock("@/lib/jwt", () => ({ + verifyInviteToken: vi.fn(), +})); + +vi.mock("@tolgee/react", async () => { + const actual = await vi.importActual("@tolgee/react"); + return { + ...actual, + useTranslate: () => ({ + t: (key: string) => key, + }), + T: ({ keyName }: { keyName: string }) => keyName, + }; +}); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/modules/ee/lib/ee", () => ({ + ee: { + sso: { + getSSOConfig: vi.fn().mockResolvedValue(null), + }, + }, +})); + +describe("InvitePage", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("should show invite not found when invite doesn't exist", async () => { + vi.mocked(getServerSession).mockResolvedValue(null); + vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "123", email: "test@example.com" }); + vi.mocked(getInvite).mockResolvedValue(null); + + const result = await InvitePage({ searchParams: Promise.resolve({ token: "test-token" }) }); + + expect(result.props.headline).toContain("auth.invite.invite_not_found"); + expect(result.props.description).toContain("auth.invite.invite_not_found_description"); + }); +}); diff --git a/apps/web/modules/auth/invite/page.tsx b/apps/web/modules/auth/invite/page.tsx index b91402f5afd3..91d6d5b2822d 100644 --- a/apps/web/modules/auth/invite/page.tsx +++ b/apps/web/modules/auth/invite/page.tsx @@ -1,3 +1,7 @@ +import { WEBAPP_URL } from "@/lib/constants"; +import { verifyInviteToken } from "@/lib/jwt"; +import { createMembership } from "@/lib/membership/service"; +import { getUser, updateUser } from "@/lib/user/service"; import { deleteInvite, getInvite } from "@/modules/auth/invite/lib/invite"; import { createTeamMembership } from "@/modules/auth/invite/lib/team"; import { authOptions } from "@/modules/auth/lib/authOptions"; @@ -7,10 +11,6 @@ import { getTranslate } from "@/tolgee/server"; import { getServerSession } from "next-auth"; import Link from "next/link"; import { after } from "next/server"; -import { WEBAPP_URL } from "@formbricks/lib/constants"; -import { verifyInviteToken } from "@formbricks/lib/jwt"; -import { createMembership } from "@formbricks/lib/membership/service"; -import { getUser, updateUser } from "@formbricks/lib/user/service"; import { logger } from "@formbricks/logger"; import { ContentLayout } from "./components/content-layout"; @@ -107,7 +107,6 @@ export const InvitePage = async (props: InvitePageProps) => { notificationSettings: { ...user.notificationSettings, alert: user.notificationSettings.alert ?? {}, - weeklySummary: user.notificationSettings.weeklySummary ?? {}, unsubscribedOrganizationIds: Array.from( new Set([ ...(user.notificationSettings?.unsubscribedOrganizationIds || []), diff --git a/apps/web/modules/auth/layout.tsx b/apps/web/modules/auth/layout.tsx index adefb87862ca..85221abc1691 100644 --- a/apps/web/modules/auth/layout.tsx +++ b/apps/web/modules/auth/layout.tsx @@ -1,9 +1,9 @@ +import { getIsFreshInstance } from "@/lib/instance/service"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; import { Toaster } from "react-hot-toast"; -import { getIsFreshInstance } from "@formbricks/lib/instance/service"; export const AuthLayout = async ({ children }: { children: React.ReactNode }) => { const [session, isFreshInstance, isMultiOrgEnabled] = await Promise.all([ diff --git a/apps/web/modules/auth/lib/authOptions.test.ts b/apps/web/modules/auth/lib/authOptions.test.ts index 283dc228cebe..0f53d6f249b3 100644 --- a/apps/web/modules/auth/lib/authOptions.test.ts +++ b/apps/web/modules/auth/lib/authOptions.test.ts @@ -1,13 +1,66 @@ +import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants"; +import { createToken } from "@/lib/jwt"; +// Import mocked rate limiting functions +import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { randomBytes } from "crypto"; import { Provider } from "next-auth/providers/index"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { EMAIL_VERIFICATION_DISABLED } from "@formbricks/lib/constants"; -import { createToken } from "@formbricks/lib/jwt"; import { authOptions } from "./authOptions"; import { mockUser } from "./mock-data"; import { hashPassword } from "./utils"; +// Mock rate limiting dependencies +vi.mock("@/modules/core/rate-limit/helpers", () => ({ + applyIPRateLimit: vi.fn(), +})); + +vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({ + rateLimitConfigs: { + auth: { + login: { interval: 900, allowedPerInterval: 30, namespace: "auth:login" }, + verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" }, + }, + }, +})); + +// Mock constants that this test needs +vi.mock("@/lib/constants", () => ({ + EMAIL_VERIFICATION_DISABLED: false, + SESSION_MAX_AGE: 86400, + NEXTAUTH_SECRET: "test-secret", + WEBAPP_URL: "http://localhost:3000", + ENCRYPTION_KEY: "test-encryption-key-32-chars-long", + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: false, + AUDIT_LOG_GET_USER_IP: false, + ENTERPRISE_LICENSE_KEY: undefined, + SENTRY_DSN: undefined, + BREVO_API_KEY: undefined, + RATE_LIMITING_DISABLED: false, +})); + +// Mock next/headers +vi.mock("next/headers", () => ({ + headers: () => ({ + get: () => null, + has: () => false, + keys: () => [], + values: () => [], + entries: () => [], + forEach: () => {}, + }), + cookies: () => ({ + get: (name: string) => { + if (name === "next-auth.callback-url") { + return { value: "/" }; + } + return null; + }, + }), +})); + const mockUserId = "cm5yzxcp900000cl78fzocjal"; const mockPassword = randomBytes(12).toString("hex"); const mockHashedPassword = await hashPassword(mockPassword); @@ -34,19 +87,21 @@ function getProviderById(id: string): Provider { describe("authOptions", () => { afterEach(() => { + vi.clearAllMocks(); vi.restoreAllMocks(); }); describe("CredentialsProvider (credentials) - email/password login", () => { const credentialsProvider = getProviderById("credentials"); - it("should throw error if credentials are not provided", async () => { + test("should throw error if credentials are not provided", async () => { await expect(credentialsProvider.options.authorize(undefined, {})).rejects.toThrow( "Invalid credentials" ); }); - it("should throw error if user not found", async () => { + test("should throw error if user not found", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes vi.spyOn(prisma.user, "findUnique").mockResolvedValue(null); const credentials = { email: mockUser.email, password: mockPassword }; @@ -56,12 +111,13 @@ describe("authOptions", () => { ); }); - it("should throw error if user has no password stored", async () => { + test("should throw error if user has no password stored", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, email: mockUser.email, password: null, - }); + } as any); const credentials = { email: mockUser.email, password: mockPassword }; @@ -70,12 +126,13 @@ describe("authOptions", () => { ); }); - it("should throw error if password verification fails", async () => { + test("should throw error if password verification fails", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUserId, email: mockUser.email, password: mockHashedPassword, - }); + } as any); const credentials = { email: mockUser.email, password: "wrongPassword" }; @@ -84,7 +141,8 @@ describe("authOptions", () => { ); }); - it("should successfully login when credentials are valid", async () => { + test("should successfully login when credentials are valid", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes const fakeUser = { id: mockUserId, email: mockUser.email, @@ -94,7 +152,7 @@ describe("authOptions", () => { twoFactorEnabled: false, }; - vi.spyOn(prisma.user, "findUnique").mockResolvedValue(fakeUser); + vi.spyOn(prisma.user, "findUnique").mockResolvedValue(fakeUser as any); const credentials = { email: mockUser.email, password: mockPassword }; @@ -107,8 +165,64 @@ describe("authOptions", () => { }); }); + describe("Rate Limiting", () => { + test("should apply rate limiting before credential validation", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ + id: mockUserId, + email: mockUser.email, + password: mockHashedPassword, + emailVerified: new Date(), + twoFactorEnabled: false, + } as any); + + const credentials = { email: mockUser.email, password: mockPassword }; + + await credentialsProvider.options.authorize(credentials, {}); + + expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.login); + expect(applyIPRateLimit).toHaveBeenCalledBefore(prisma.user.findUnique as any); + }); + + test("should block login when rate limit exceeded", async () => { + vi.mocked(applyIPRateLimit).mockRejectedValue( + new Error("Maximum number of requests reached. Please try again later.") + ); + + const credentials = { email: mockUser.email, password: mockPassword }; + + await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow( + "Maximum number of requests reached. Please try again later." + ); + + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + test("should use correct rate limit configuration", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ + id: mockUserId, + email: mockUser.email, + password: mockHashedPassword, + emailVerified: new Date(), + twoFactorEnabled: false, + } as any); + + const credentials = { email: mockUser.email, password: mockPassword }; + + await credentialsProvider.options.authorize(credentials, {}); + + expect(applyIPRateLimit).toHaveBeenCalledWith({ + interval: 900, + allowedPerInterval: 30, + namespace: "auth:login", + }); + }); + }); + describe("Two-Factor Backup Code login", () => { - it("should throw error if backup codes are missing", async () => { + test("should throw error if backup codes are missing", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes const mockUser = { id: mockUserId, email: "2fa@example.com", @@ -116,7 +230,7 @@ describe("authOptions", () => { twoFactorEnabled: true, backupCodes: null, }; - vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser); + vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any); const credentials = { email: mockUser.email, password: mockPassword, backupCode: "123456" }; @@ -130,13 +244,14 @@ describe("authOptions", () => { describe("CredentialsProvider (token) - Token-based email verification", () => { const tokenProvider = getProviderById("token"); - it("should throw error if token is not provided", async () => { + test("should throw error if token is not provided", async () => { await expect(tokenProvider.options.authorize({}, {})).rejects.toThrow( "Either a user does not match the provided token or the token is invalid" ); }); - it("should throw error if token is invalid or user not found", async () => { + test("should throw error if token is invalid or user not found", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes const credentials = { token: "badtoken" }; await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow( @@ -144,8 +259,9 @@ describe("authOptions", () => { ); }); - it("should throw error if email is already verified", async () => { - vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser); + test("should throw error if email is already verified", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes + vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any); const credentials = { token: createToken(mockUser.id, mockUser.email) }; @@ -154,8 +270,9 @@ describe("authOptions", () => { ); }); - it("should update user and verify email when token is valid", async () => { - vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, emailVerified: null }); + test("should update user and verify email when token is valid", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes + vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, emailVerified: null } as any); vi.spyOn(prisma.user, "update").mockResolvedValue({ ...mockUser, password: mockHashedPassword, @@ -163,7 +280,7 @@ describe("authOptions", () => { twoFactorSecret: null, identityProviderAccountId: null, groupId: null, - }); + } as any); const credentials = { token: createToken(mockUserId, mockUser.email) }; @@ -171,17 +288,81 @@ describe("authOptions", () => { expect(result.email).toBe(mockUser.email); expect(result.emailVerified).toBeInstanceOf(Date); }); + + describe("Rate Limiting", () => { + test("should apply rate limiting before token verification", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ + id: mockUser.id, + emailVerified: null, + } as any); + vi.spyOn(prisma.user, "update").mockResolvedValue({ + ...mockUser, + password: mockHashedPassword, + backupCodes: null, + twoFactorSecret: null, + identityProviderAccountId: null, + groupId: null, + } as any); + + const credentials = { token: createToken(mockUserId, mockUser.email) }; + + await tokenProvider.options.authorize(credentials, {}); + + expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail); + }); + + test("should block verification when rate limit exceeded", async () => { + vi.mocked(applyIPRateLimit).mockRejectedValue( + new Error("Maximum number of requests reached. Please try again later.") + ); + + const credentials = { token: createToken(mockUserId, mockUser.email) }; + + await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow( + "Maximum number of requests reached. Please try again later." + ); + + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + test("should use correct rate limit configuration", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ + id: mockUser.id, + emailVerified: null, + } as any); + vi.spyOn(prisma.user, "update").mockResolvedValue({ + ...mockUser, + password: mockHashedPassword, + backupCodes: null, + twoFactorSecret: null, + identityProviderAccountId: null, + groupId: null, + } as any); + + const credentials = { token: createToken(mockUserId, mockUser.email) }; + + await tokenProvider.options.authorize(credentials, {}); + + expect(applyIPRateLimit).toHaveBeenCalledWith({ + interval: 3600, + allowedPerInterval: 10, + namespace: "auth:verify", + }); + }); + }); }); describe("Callbacks", () => { describe("jwt callback", () => { - it("should add profile information to token if user is found", async () => { + test("should add profile information to token if user is found", async () => { vi.spyOn(prisma.user, "findFirst").mockResolvedValue({ id: mockUser.id, locale: mockUser.locale, email: mockUser.email, emailVerified: mockUser.emailVerified, - }); + } as any); const token = { email: mockUser.email }; if (!authOptions.callbacks?.jwt) { @@ -194,7 +375,7 @@ describe("authOptions", () => { }); }); - it("should return token unchanged if no existing user is found", async () => { + test("should return token unchanged if no existing user is found", async () => { vi.spyOn(prisma.user, "findFirst").mockResolvedValue(null); const token = { email: "nonexistent@example.com" }; @@ -207,7 +388,7 @@ describe("authOptions", () => { }); describe("session callback", () => { - it("should add user profile to session", async () => { + test("should add user profile to session", async () => { const token = { id: "user6", profile: { id: "user6", email: "user6@example.com" }, @@ -223,7 +404,7 @@ describe("authOptions", () => { }); describe("signIn callback", () => { - it("should throw error if email is not verified and email verification is enabled", async () => { + test("should throw error if email is not verified and email verification is enabled", async () => { const user = { ...mockUser, emailVerified: null }; const account = { provider: "credentials" } as any; // EMAIL_VERIFICATION_DISABLED is imported from constants. @@ -239,7 +420,8 @@ describe("authOptions", () => { describe("Two-Factor Authentication (TOTP)", () => { const credentialsProvider = getProviderById("credentials"); - it("should throw error if TOTP code is missing when 2FA is enabled", async () => { + test("should throw error if TOTP code is missing when 2FA is enabled", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes const mockUser = { id: mockUserId, email: "2fa@example.com", @@ -247,7 +429,7 @@ describe("authOptions", () => { twoFactorEnabled: true, twoFactorSecret: "encrypted_secret", }; - vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser); + vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any); const credentials = { email: mockUser.email, password: mockPassword }; @@ -256,7 +438,8 @@ describe("authOptions", () => { ); }); - it("should throw error if two factor secret is missing", async () => { + test("should throw error if two factor secret is missing", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes const mockUser = { id: mockUserId, email: "2fa@example.com", @@ -264,7 +447,7 @@ describe("authOptions", () => { twoFactorEnabled: true, twoFactorSecret: null, }; - vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser); + vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any); const credentials = { email: mockUser.email, diff --git a/apps/web/modules/auth/lib/authOptions.ts b/apps/web/modules/auth/lib/authOptions.ts index 8711e00c8ef4..eeb659dfd0dd 100644 --- a/apps/web/modules/auth/lib/authOptions.ts +++ b/apps/web/modules/auth/lib/authOptions.ts @@ -1,18 +1,30 @@ +import { + EMAIL_VERIFICATION_DISABLED, + ENCRYPTION_KEY, + ENTERPRISE_LICENSE_KEY, + SESSION_MAX_AGE, +} from "@/lib/constants"; +import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto"; +import { verifyToken } from "@/lib/jwt"; import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user"; -import { verifyPassword } from "@/modules/auth/lib/utils"; +import { + logAuthAttempt, + logAuthEvent, + logAuthSuccess, + logEmailVerificationAttempt, + logTwoFactorAttempt, + shouldLogAuthFailure, + verifyPassword, +} from "@/modules/auth/lib/utils"; +import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { getSSOProviders } from "@/modules/ee/sso/lib/providers"; import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers"; import type { Account, NextAuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import { cookies } from "next/headers"; import { prisma } from "@formbricks/database"; -import { - EMAIL_VERIFICATION_DISABLED, - ENCRYPTION_KEY, - ENTERPRISE_LICENSE_KEY, -} from "@formbricks/lib/constants"; -import { symmetricDecrypt, symmetricEncrypt } from "@formbricks/lib/crypto"; -import { verifyToken } from "@formbricks/lib/jwt"; import { logger } from "@formbricks/logger"; import { TUser } from "@formbricks/types/user"; import { createBrevoCustomer } from "./brevo"; @@ -42,9 +54,18 @@ export const authOptions: NextAuthOptions = { backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" }, }, async authorize(credentials, _req) { + await applyIPRateLimit(rateLimitConfigs.auth.login); + + // Use email for rate limiting when available, fall back to "unknown_user" for credential validation + const identifier = credentials?.email || "unknown_user"; // NOSONAR // We want to check for empty strings + if (!credentials) { + if (await shouldLogAuthFailure("no_credentials")) { + logAuthAttempt("no_credentials_provided", "credentials", "credentials_validation"); + } throw new Error("Invalid credentials"); } + let user; try { user = await prisma.user.findUnique({ @@ -54,37 +75,70 @@ export const authOptions: NextAuthOptions = { }); } catch (e) { logger.error(e, "Error in CredentialsProvider authorize"); + logAuthAttempt("database_error", "credentials", "user_lookup", UNKNOWN_DATA, credentials?.email); throw Error("Internal server error. Please try again later"); } + if (!user) { + if (await shouldLogAuthFailure(identifier)) { + logAuthAttempt("user_not_found", "credentials", "user_lookup", UNKNOWN_DATA, credentials?.email); + } throw new Error("Invalid credentials"); } + if (!user.password) { + logAuthAttempt("no_password_set", "credentials", "password_validation", user.id, user.email); throw new Error("User has no password stored"); } + if (user.isActive === false) { + logAuthAttempt("account_inactive", "credentials", "account_status", user.id, user.email); throw new Error("Your account is currently inactive. Please contact the organization admin."); } const isValid = await verifyPassword(credentials.password, user.password); if (!isValid) { + if (await shouldLogAuthFailure(user.email)) { + logAuthAttempt("invalid_password", "credentials", "password_validation", user.id, user.email); + } throw new Error("Invalid credentials"); } + logAuthSuccess("passwordVerified", "credentials", "password_validation", user.id, user.email, { + requires2FA: user.twoFactorEnabled, + }); + if (user.twoFactorEnabled && credentials.backupCode) { if (!ENCRYPTION_KEY) { logger.error("Missing encryption key; cannot proceed with backup code login."); + logTwoFactorAttempt(false, "backup_code", user.id, user.email, "encryption_key_missing"); throw new Error("Internal Server Error"); } - if (!user.backupCodes) throw new Error("No backup codes found"); + if (!user.backupCodes) { + logTwoFactorAttempt(false, "backup_code", user.id, user.email, "no_backup_codes"); + throw new Error("No backup codes found"); + } + + let backupCodes; - const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, ENCRYPTION_KEY)); + try { + backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, ENCRYPTION_KEY)); + } catch (e) { + logger.error(e, "Error in CredentialsProvider authorize"); + logTwoFactorAttempt(false, "backup_code", user.id, user.email, "invalid_backup_codes"); + throw new Error("Invalid backup codes"); + } // check if user-supplied code matches one const index = backupCodes.indexOf(credentials.backupCode.replaceAll("-", "")); - if (index === -1) throw new Error("Invalid backup code"); + if (index === -1) { + if (await shouldLogAuthFailure(user.email)) { + logTwoFactorAttempt(false, "backup_code", user.id, user.email, "invalid_backup_code"); + } + throw new Error("Invalid backup code"); + } // delete verified backup code and re-encrypt remaining backupCodes[index] = null; @@ -96,30 +150,58 @@ export const authOptions: NextAuthOptions = { backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), ENCRYPTION_KEY), }, }); + + logTwoFactorAttempt(true, "backup_code", user.id, user.email, undefined, { + backupCodeConsumed: true, + }); } else if (user.twoFactorEnabled) { if (!credentials.totpCode) { + logAuthEvent("twoFactorRequired", "success", user.id, user.email, { + provider: "credentials", + authMethod: "password_validation", + requiresTOTP: true, + }); throw new Error("second factor required"); } if (!user.twoFactorSecret) { + logTwoFactorAttempt(false, "totp", user.id, user.email, "no_2fa_secret"); throw new Error("Internal Server Error"); } if (!ENCRYPTION_KEY) { + logTwoFactorAttempt(false, "totp", user.id, user.email, "encryption_key_missing"); throw new Error("Internal Server Error"); } const secret = symmetricDecrypt(user.twoFactorSecret, ENCRYPTION_KEY); if (secret.length !== 32) { + logTwoFactorAttempt(false, "totp", user.id, user.email, "invalid_2fa_secret"); throw new Error("Invalid two factor secret"); } const isValidToken = (await import("./totp")).totpAuthenticatorCheck(credentials.totpCode, secret); if (!isValidToken) { + if (await shouldLogAuthFailure(user.email)) { + logTwoFactorAttempt(false, "totp", user.id, user.email, "invalid_totp_code"); + } throw new Error("Invalid two factor code"); } + + logTwoFactorAttempt(true, "totp", user.id, user.email); } + let authMethod; + if (!user.twoFactorEnabled) { + authMethod = "password_only"; + } else if (credentials.backupCode) { + authMethod = "password_and_backup_code"; + } else { + authMethod = "password_and_totp"; + } + + logAuthSuccess("authenticationSucceeded", "credentials", authMethod, user.id, user.email); + return { id: user.id, email: user.email, @@ -143,11 +225,21 @@ export const authOptions: NextAuthOptions = { }, }, async authorize(credentials, _req) { + await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail); + + // For token verification, we can't rate limit effectively by token (single-use) + // So we use a generic identifier for token abuse attempts + const identifier = "email_verification_attempts"; + let user; try { if (!credentials?.token) { + if (await shouldLogAuthFailure(identifier)) { + logEmailVerificationAttempt(false, "token_not_provided"); + } throw new Error("Token not found"); } + const { id } = await verifyToken(credentials?.token); user = await prisma.user.findUnique({ where: { @@ -155,23 +247,39 @@ export const authOptions: NextAuthOptions = { }, }); } catch (e) { + logger.error(e, "Error in CredentialsProvider authorize"); + + if (await shouldLogAuthFailure(identifier)) { + logEmailVerificationAttempt(false, "invalid_token", UNKNOWN_DATA, undefined, { + tokenProvided: !!credentials?.token, + }); + } throw new Error("Either a user does not match the provided token or the token is invalid"); } if (!user) { + if (await shouldLogAuthFailure(identifier)) { + logEmailVerificationAttempt(false, "user_not_found_for_token"); + } throw new Error("Either a user does not match the provided token or the token is invalid"); } if (user.emailVerified) { + logEmailVerificationAttempt(false, "email_already_verified", user.id, user.email); throw new Error("Email already verified"); } if (user.isActive === false) { + logEmailVerificationAttempt(false, "account_inactive", user.id, user.email); throw new Error("Your account is currently inactive. Please contact the organization admin."); } user = await updateUser(user.id, { emailVerified: new Date() }); + logEmailVerificationAttempt(true, undefined, user.id, user.email, { + emailVerifiedAt: user.emailVerified, + }); + // send new user to brevo after email verification createBrevoCustomer({ id: user.id, email: user.email }); @@ -182,7 +290,7 @@ export const authOptions: NextAuthOptions = { ...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []), ], session: { - maxAge: 3600, + maxAge: SESSION_MAX_AGE, }, callbacks: { async jwt({ token }) { @@ -211,11 +319,16 @@ export const authOptions: NextAuthOptions = { async signIn({ user, account }: { user: TUser; account: Account }) { const cookieStore = await cookies(); - const callbackUrl = cookieStore.get("next-auth.callback-url")?.value || ""; + // get callback url from the cookie store, + const callbackUrl = + cookieStore.get("__Secure-next-auth.callback-url")?.value || + cookieStore.get("next-auth.callback-url")?.value || + ""; if (account?.provider === "credentials" || account?.provider === "token") { // check if user's email is verified or not if (!user.emailVerified && !EMAIL_VERIFICATION_DISABLED) { + logger.error("Email Verification is Pending"); throw new Error("Email Verification is Pending"); } await updateUserLastLoginAt(user.email); @@ -223,6 +336,7 @@ export const authOptions: NextAuthOptions = { } if (ENTERPRISE_LICENSE_KEY) { const result = await handleSsoCallback({ user, account, callbackUrl }); + if (result) { await updateUserLastLoginAt(user.email); } diff --git a/apps/web/modules/auth/lib/brevo.test.ts b/apps/web/modules/auth/lib/brevo.test.ts index 16cff4885a89..49bc1bec5cce 100644 --- a/apps/web/modules/auth/lib/brevo.test.ts +++ b/apps/web/modules/auth/lib/brevo.test.ts @@ -1,15 +1,14 @@ -import { Response } from "node-fetch"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { validateInputs } from "@formbricks/lib/utils/validate"; +import { validateInputs } from "@/lib/utils/validate"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { logger } from "@formbricks/logger"; -import { createBrevoCustomer } from "./brevo"; +import { createBrevoCustomer, deleteBrevoCustomerByEmail, updateBrevoCustomer } from "./brevo"; -vi.mock("@formbricks/lib/constants", () => ({ +vi.mock("@/lib/constants", () => ({ BREVO_API_KEY: "mock_api_key", BREVO_LIST_ID: "123", })); -vi.mock("@formbricks/lib/utils/validate", () => ({ +vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn(), })); @@ -20,8 +19,8 @@ describe("createBrevoCustomer", () => { vi.clearAllMocks(); }); - it("should return early if BREVO_API_KEY is not defined", async () => { - vi.doMock("@formbricks/lib/constants", () => ({ + test("should return early if BREVO_API_KEY is not defined", async () => { + vi.doMock("@/lib/constants", () => ({ BREVO_API_KEY: undefined, BREVO_LIST_ID: "123", })); @@ -35,25 +34,154 @@ describe("createBrevoCustomer", () => { expect(validateInputs).not.toHaveBeenCalled(); }); - it("should log an error if fetch fails", async () => { + 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 createBrevoCustomer({ id: "123", email: "test@example.com" }); + expect(validateInputs).toHaveBeenCalled(); expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error sending user to Brevo"); }); - it("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(); + }); +}); + +describe("deleteBrevoCustomerByEmail", () => { + 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 { deleteBrevoCustomerByEmail } = await import("./brevo"); // Re-import to get the mocked version + + const result = await deleteBrevoCustomerByEmail({ 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 deleteBrevoCustomerByEmail({ email: "test@example.com" }); + + expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error deleting user from 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 deleteBrevoCustomerByEmail({ email: "test@example.com" }); + + expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error deleting user from Brevo"); + }); + + test("should successfully delete a Brevo customer", async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(new global.Response(null, { status: 204 })); + + await deleteBrevoCustomerByEmail({ email: "test@example.com" }); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.brevo.com/v3/contacts/test%40example.com?identifierType=email_id", + expect.objectContaining({ + method: "DELETE", + headers: { + Accept: "application/json", + "api-key": "mock_api_key", + }, + }) + ); + }); +}); diff --git a/apps/web/modules/auth/lib/brevo.ts b/apps/web/modules/auth/lib/brevo.ts index 6fd9e4a06c06..38f883d58030 100644 --- a/apps/web/modules/auth/lib/brevo.ts +++ b/apps/web/modules/auth/lib/brevo.ts @@ -1,9 +1,29 @@ -import { BREVO_API_KEY, BREVO_LIST_ID } from "@formbricks/lib/constants"; -import { validateInputs } from "@formbricks/lib/utils/validate"; +import { BREVO_API_KEY, BREVO_LIST_ID } from "@/lib/constants"; +import { validateInputs } from "@/lib/utils/validate"; 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; + emailBlacklisted?: boolean; + smsBlacklisted?: boolean; + listIds?: number[]; + updateEnabled?: boolean; + smtpBlacklistSender?: string[]; +}; + +type BrevoUpdateContact = { + attributes?: Record; + 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,61 @@ 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"); + } +}; + +export const deleteBrevoCustomerByEmail = async ({ email }: { email: TUserEmail }) => { + if (!BREVO_API_KEY) { + return; + } + + const encodedEmail = encodeURIComponent(email.toLowerCase()); + + try { + const res = await fetch(`https://api.brevo.com/v3/contacts/${encodedEmail}?identifierType=email_id`, { + method: "DELETE", + headers: { + Accept: "application/json", + "api-key": BREVO_API_KEY, + }, + }); + + if (res.status !== 204) { + const errorText = await res.text(); + logger.error({ errorText }, "Error deleting user from Brevo"); + } + } catch (error) { + logger.error(error, "Error deleting user from Brevo"); + } +}; diff --git a/apps/web/modules/auth/lib/mock-data.ts b/apps/web/modules/auth/lib/mock-data.ts index 3dfd70c01149..07227762c371 100644 --- a/apps/web/modules/auth/lib/mock-data.ts +++ b/apps/web/modules/auth/lib/mock-data.ts @@ -13,7 +13,7 @@ export const mockUser: TUser = { objective: "improve_user_retention", notificationSettings: { alert: {}, - weeklySummary: {}, + unsubscribedOrganizationIds: [], }, role: "other", diff --git a/apps/web/modules/auth/lib/totp.test.ts b/apps/web/modules/auth/lib/totp.test.ts index 92052f4c7e80..fe4167534e28 100644 --- a/apps/web/modules/auth/lib/totp.test.ts +++ b/apps/web/modules/auth/lib/totp.test.ts @@ -2,7 +2,7 @@ import { Authenticator } from "@otplib/core"; import type { AuthenticatorOptions } from "@otplib/core/authenticator"; import { createDigest, createRandomBytes } from "@otplib/plugin-crypto"; import { keyDecoder, keyEncoder } from "@otplib/plugin-thirty-two"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { totpAuthenticatorCheck } from "./totp"; vi.mock("@otplib/core"); @@ -14,7 +14,7 @@ describe("totpAuthenticatorCheck", () => { const secret = "JBSWY3DPEHPK3PXP"; const opts: Partial = { window: [1, 0] }; - it("should check a TOTP token with a base32-encoded secret", () => { + test("should check a TOTP token with a base32-encoded secret", () => { const checkMock = vi.fn().mockReturnValue(true); (Authenticator as unknown as vi.Mock).mockImplementation(() => ({ check: checkMock, @@ -33,7 +33,7 @@ describe("totpAuthenticatorCheck", () => { expect(result).toBe(true); }); - it("should use default window if none is provided", () => { + test("should use default window if none is provided", () => { const checkMock = vi.fn().mockReturnValue(true); (Authenticator as unknown as vi.Mock).mockImplementation(() => ({ check: checkMock, @@ -52,7 +52,7 @@ describe("totpAuthenticatorCheck", () => { expect(result).toBe(true); }); - it("should throw an error for invalid token format", () => { + test("should throw an error for invalid token format", () => { (Authenticator as unknown as vi.Mock).mockImplementation(() => ({ check: () => { throw new Error("Invalid token format"); @@ -64,7 +64,7 @@ describe("totpAuthenticatorCheck", () => { }).toThrow("Invalid token format"); }); - it("should throw an error for invalid secret format", () => { + test("should throw an error for invalid secret format", () => { (Authenticator as unknown as vi.Mock).mockImplementation(() => ({ check: () => { throw new Error("Invalid secret format"); @@ -76,7 +76,7 @@ describe("totpAuthenticatorCheck", () => { }).toThrow("Invalid secret format"); }); - it("should return false if token verification fails", () => { + test("should return false if token verification fails", () => { const checkMock = vi.fn().mockReturnValue(false); (Authenticator as unknown as vi.Mock).mockImplementation(() => ({ check: checkMock, diff --git a/apps/web/modules/auth/lib/user.test.ts b/apps/web/modules/auth/lib/user.test.ts index 93cd4951e86f..b1460f22e89b 100644 --- a/apps/web/modules/auth/lib/user.test.ts +++ b/apps/web/modules/auth/lib/user.test.ts @@ -1,8 +1,7 @@ import { Prisma } from "@prisma/client"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; -import { userCache } from "@formbricks/lib/user/cache"; import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { mockUser } from "./mock-data"; import { createUser, getUser, getUserByEmail, updateUser, updateUserLastLoginAt } from "./user"; @@ -27,23 +26,13 @@ vi.mock("@formbricks/database", () => ({ }, })); -vi.mock("@formbricks/lib/user/cache", () => ({ - userCache: { - revalidate: vi.fn(), - tag: { - byEmail: vi.fn(), - byId: vi.fn(), - }, - }, -})); - describe("User Management", () => { beforeEach(() => { vi.clearAllMocks(); }); describe("createUser", () => { - it("creates a user successfully", async () => { + test("creates a user successfully", async () => { vi.mocked(prisma.user.create).mockResolvedValueOnce(mockPrismaUser); const result = await createUser({ @@ -53,10 +42,9 @@ describe("User Management", () => { }); expect(result).toEqual(mockPrismaUser); - expect(userCache.revalidate).toHaveBeenCalled(); }); - it("throws InvalidInputError when email already exists", async () => { + test("throws InvalidInputError when email already exists", async () => { const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { code: PrismaErrorType.UniqueConstraintViolation, clientVersion: "0.0.1", @@ -76,16 +64,15 @@ describe("User Management", () => { describe("updateUser", () => { const mockUpdateData = { name: "Updated Name" }; - it("updates a user successfully", async () => { + test("updates a user successfully", async () => { vi.mocked(prisma.user.update).mockResolvedValueOnce({ ...mockPrismaUser, name: mockUpdateData.name }); const result = await updateUser(mockUser.id, mockUpdateData); expect(result).toEqual({ ...mockPrismaUser, name: mockUpdateData.name }); - expect(userCache.revalidate).toHaveBeenCalled(); }); - it("throws ResourceNotFoundError when user doesn't exist", async () => { + test("throws ResourceNotFoundError when user doesn't exist", async () => { const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { code: PrismaErrorType.RecordDoesNotExist, clientVersion: "0.0.1", @@ -99,16 +86,15 @@ describe("User Management", () => { describe("updateUserLastLoginAt", () => { const mockUpdateData = { name: "Updated Name" }; - it("updates a user successfully", async () => { + test("updates a user successfully", async () => { vi.mocked(prisma.user.update).mockResolvedValueOnce({ ...mockPrismaUser, name: mockUpdateData.name }); const result = await updateUserLastLoginAt(mockUser.email); expect(result).toEqual(void 0); - expect(userCache.revalidate).toHaveBeenCalled(); }); - it("throws ResourceNotFoundError when user doesn't exist", async () => { + test("throws ResourceNotFoundError when user doesn't exist", async () => { const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { code: PrismaErrorType.RecordDoesNotExist, clientVersion: "0.0.1", @@ -122,7 +108,7 @@ describe("User Management", () => { describe("getUserByEmail", () => { const mockEmail = "test@example.com"; - it("retrieves a user by email successfully", async () => { + test("retrieves a user by email successfully", async () => { const mockUser = { id: "user123", email: mockEmail, @@ -136,7 +122,7 @@ describe("User Management", () => { expect(result).toEqual(mockUser); }); - it("throws DatabaseError on prisma error", async () => { + test("throws DatabaseError on prisma error", async () => { vi.mocked(prisma.user.findFirst).mockRejectedValueOnce(new Error("Database error")); await expect(getUserByEmail(mockEmail)).rejects.toThrow(); @@ -146,7 +132,7 @@ describe("User Management", () => { describe("getUser", () => { const mockUserId = "cm5xj580r00000cmgdj9ohups"; - it("retrieves a user by id successfully", async () => { + test("retrieves a user by id successfully", async () => { const mockUser = { id: mockUserId, }; @@ -157,7 +143,7 @@ describe("User Management", () => { expect(result).toEqual(mockUser); }); - it("returns null when user doesn't exist", async () => { + test("returns null when user doesn't exist", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(null); const result = await getUser(mockUserId); @@ -165,7 +151,7 @@ describe("User Management", () => { expect(result).toBeNull(); }); - it("throws DatabaseError on prisma error", async () => { + test("throws DatabaseError on prisma error", async () => { vi.mocked(prisma.user.findUnique).mockRejectedValueOnce(new Error("Database error")); await expect(getUser(mockUserId)).rejects.toThrow(); diff --git a/apps/web/modules/auth/lib/user.ts b/apps/web/modules/auth/lib/user.ts index 3a19a0f7b364..1feacd24e28f 100644 --- a/apps/web/modules/auth/lib/user.ts +++ b/apps/web/modules/auth/lib/user.ts @@ -1,10 +1,9 @@ +import { isValidImageFile } from "@/lib/fileValidation"; +import { validateInputs } from "@/lib/utils/validate"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; -import { cache } from "@formbricks/lib/cache"; -import { userCache } from "@formbricks/lib/user/cache"; -import { validateInputs } from "@formbricks/lib/utils/validate"; import { ZId } from "@formbricks/types/common"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TUserCreateInput, TUserUpdateInput, ZUserEmail, ZUserUpdateInput } from "@formbricks/types/user"; @@ -12,6 +11,10 @@ import { TUserCreateInput, TUserUpdateInput, ZUserEmail, ZUserUpdateInput } from export const updateUser = async (id: string, data: TUserUpdateInput) => { validateInputs([id, ZId], [data, ZUserUpdateInput.partial()]); + if (data.imageUrl && !isValidImageFile(data.imageUrl)) { + throw new InvalidInputError("Invalid image file"); + } + try { const updatedUser = await prisma.user.update({ where: { @@ -26,11 +29,6 @@ export const updateUser = async (id: string, data: TUserUpdateInput) => { }, }); - userCache.revalidate({ - email: updatedUser.email, - id: updatedUser.id, - }); - return updatedUser; } catch (error) { if ( @@ -47,7 +45,7 @@ export const updateUserLastLoginAt = async (email: string) => { validateInputs([email, ZUserEmail]); try { - const updatedUser = await prisma.user.update({ + await prisma.user.update({ where: { email, }, @@ -55,11 +53,6 @@ export const updateUserLastLoginAt = async (email: string) => { lastLoginAt: new Date(), }, }); - - userCache.revalidate({ - email: updatedUser.email, - id: updatedUser.id, - }); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && @@ -71,74 +64,59 @@ export const updateUserLastLoginAt = async (email: string) => { } }; -export const getUserByEmail = reactCache(async (email: string) => - cache( - async () => { - validateInputs([email, ZUserEmail]); - - try { - const user = await prisma.user.findFirst({ - where: { - email, - }, - select: { - id: true, - locale: true, - email: true, - emailVerified: true, - isActive: true, - }, - }); - - return user; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } - }, - [`getUserByEmail-${email}`], - { - tags: [userCache.tag.byEmail(email)], +export const getUserByEmail = reactCache(async (email: string) => { + validateInputs([email, ZUserEmail]); + + try { + const user = await prisma.user.findFirst({ + where: { + email, + }, + select: { + id: true, + locale: true, + email: true, + emailVerified: true, + isActive: true, + identityProvider: true, + }, + }); + + return user; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )() -); - -export const getUser = reactCache(async (id: string) => - cache( - async () => { - validateInputs([id, ZId]); - - try { - const user = await prisma.user.findUnique({ - where: { - id, - }, - select: { - id: true, - }, - }); - - if (!user) { - return null; - } - return user; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } - }, - [`getUser-${id}`], - { - tags: [userCache.tag.byId(id)], + + throw error; + } +}); + +export const getUser = reactCache(async (id: string) => { + validateInputs([id, ZId]); + + try { + const user = await prisma.user.findUnique({ + where: { + id, + }, + select: { + id: true, + }, + }); + + if (!user) { + return null; + } + return user; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); } - )() -); + + throw error; + } +}); export const createUser = async (data: TUserCreateInput) => { validateInputs([data, ZUserUpdateInput]); @@ -154,12 +132,6 @@ export const createUser = async (data: TUserCreateInput) => { }, }); - userCache.revalidate({ - email: user.email, - id: user.id, - count: true, - }); - return user; } catch (error) { if ( diff --git a/apps/web/modules/auth/lib/utils.test.ts b/apps/web/modules/auth/lib/utils.test.ts index 50774174ea7e..096a194d6d9b 100644 --- a/apps/web/modules/auth/lib/utils.test.ts +++ b/apps/web/modules/auth/lib/utils.test.ts @@ -1,38 +1,660 @@ -import { describe, expect, it } from "vitest"; -import { hashPassword, verifyPassword } from "./utils"; +import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler"; +import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; +import * as Sentry from "@sentry/nextjs"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + createAuditIdentifier, + hashPassword, + logAuthAttempt, + logAuthEvent, + logAuthSuccess, + logEmailVerificationAttempt, + logSignOut, + logTwoFactorAttempt, + shouldLogAuthFailure, + verifyPassword, +} from "./utils"; -describe("Password Utils", () => { - const password = "password"; - const hashedPassword = "$2a$12$LZsLq.9nkZlU0YDPx2aLNelnwD/nyavqbewLN.5.Q5h/UxRD8Ymcy"; +// Mock the audit event handler +vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ + queueAuditEventBackground: vi.fn(), +})); - describe("hashPassword", () => { - it("should hash a password", async () => { - const hashedPassword = await hashPassword(password); +// Mock crypto for consistent hash testing +vi.mock("crypto", () => ({ + createHash: vi.fn(() => ({ + update: vi.fn(() => ({ + digest: vi.fn(() => "a".repeat(32)), // Mock 64-char hex string + })), + })), + randomUUID: vi.fn(() => "test-uuid-123"), +})); - expect(typeof hashedPassword).toBe("string"); - expect(hashedPassword).not.toBe(password); - expect(hashedPassword.length).toBe(60); - }); +// Mock Sentry +vi.mock("@sentry/nextjs", () => ({ + captureException: vi.fn(), +})); - it("should generate different hashes for the same password", async () => { - const hash1 = await hashPassword(password); - const hash2 = await hashPassword(password); +// Mock constants +vi.mock("@/lib/constants", () => ({ + SENTRY_DSN: "test-sentry-dsn", + IS_PRODUCTION: true, + REDIS_URL: "redis://localhost:6379", +})); - expect(hash1).not.toBe(hash2); - }); +// Mock Redis client +const { mockGetRedisClient } = vi.hoisted(() => ({ + mockGetRedisClient: vi.fn(), +})); + +vi.mock("@/modules/cache/redis", () => ({ + getRedisClient: mockGetRedisClient, +})); + +describe("Auth Utils", () => { + beforeEach(() => { + vi.clearAllMocks(); }); - describe("verifyPassword", () => { - it("should verify a correct password", async () => { - const isValid = await verifyPassword(password, hashedPassword); + afterEach(() => { + vi.clearAllTimers(); + }); + + describe("Password Utils", () => { + const password = "password"; + const hashedPassword = "$2a$12$LZsLq.9nkZlU0YDPx2aLNelnwD/nyavqbewLN.5.Q5h/UxRD8Ymcy"; + + test("should hash a password", async () => { + const newHashedPassword = await hashPassword(password); + expect(typeof newHashedPassword).toBe("string"); + expect(newHashedPassword).not.toBe(password); + expect(newHashedPassword.length).toBe(60); + }); + + test("should verify a correct password", async () => { + const isValid = await verifyPassword(password, hashedPassword); expect(isValid).toBe(true); }); - it("should reject an incorrect password", async () => { + test("should reject an incorrect password", async () => { const isValid = await verifyPassword("WrongPassword123!", hashedPassword); - expect(isValid).toBe(false); }); }); + + describe("Audit Identifier Utils", () => { + test("should create a hashed identifier for email", () => { + const email = "user@example.com"; + const identifier = createAuditIdentifier(email, "email"); + + expect(identifier).toBe("email_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + expect(identifier).not.toContain("user@example.com"); + }); + + test("should return unknown for empty/unknown identifiers", () => { + expect(createAuditIdentifier("")).toBe("unknown"); + expect(createAuditIdentifier("unknown")).toBe("unknown"); + expect(createAuditIdentifier("unknown_user")).toBe("unknown"); + }); + + test("should create consistent hashes for same input", () => { + const email = "test@example.com"; + const id1 = createAuditIdentifier(email, "email"); + const id2 = createAuditIdentifier(email, "email"); + + expect(id1).toBe(id2); + }); + + test("should use default prefix when none provided", () => { + const identifier = createAuditIdentifier("test@example.com"); + expect(identifier).toMatch(/^actor_/); + }); + }); + + describe("Rate Limiting", () => { + test("should always allow successful authentication logging", async () => { + // This test doesn't need Redis to be available as it short-circuits for success + mockGetRedisClient.mockResolvedValue(null); + + expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true); + expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true); + }); + + test("should implement fail-closed behavior when Redis is unavailable", async () => { + // Set Redis unavailable for this test + mockGetRedisClient.mockResolvedValue(null); + + const email = "rate-limit-test@example.com"; + + // When Redis is unavailable (mocked as null), the system fails closed for security. + // This prevents authentication failure logging when we cannot enforce rate limiting, + // ensuring consistent security posture across distributed systems. + // All authentication failure attempts should return false (do not log). + expect(await shouldLogAuthFailure(email, false)).toBe(false); // 1st failure - blocked + expect(await shouldLogAuthFailure(email, false)).toBe(false); // 2nd failure - blocked + expect(await shouldLogAuthFailure(email, false)).toBe(false); // 3rd failure - blocked + expect(await shouldLogAuthFailure(email, false)).toBe(false); // 4th failure - blocked + expect(await shouldLogAuthFailure(email, false)).toBe(false); // 5th failure - blocked + expect(await shouldLogAuthFailure(email, false)).toBe(false); // 6th failure - blocked + expect(await shouldLogAuthFailure(email, false)).toBe(false); // 7th failure - blocked + expect(await shouldLogAuthFailure(email, false)).toBe(false); // 8th failure - blocked + expect(await shouldLogAuthFailure(email, false)).toBe(false); // 9th failure - blocked + expect(await shouldLogAuthFailure(email, false)).toBe(false); // 10th failure - blocked + }); + + describe("Redis Available - All Branch Coverage", () => { + let mockRedis: any; + let mockMulti: any; + + beforeEach(() => { + // Clear mocks first + vi.clearAllMocks(); + + // Create comprehensive Redis mock + mockMulti = { + zRemRangeByScore: vi.fn().mockReturnThis(), + zCard: vi.fn().mockReturnThis(), + zAdd: vi.fn().mockReturnThis(), + expire: vi.fn().mockReturnThis(), + exec: vi.fn(), + }; + + mockRedis = { + multi: vi.fn().mockReturnValue(mockMulti), + zRange: vi.fn(), + isReady: true, // Add isReady property + }; + + // Reset the Redis mock for these specific tests + mockGetRedisClient.mockReset(); + mockGetRedisClient.mockReturnValue(mockRedis); // Use mockReturnValue instead of mockResolvedValue + }); + + test("should handle Redis transaction failure - !results branch", async () => { + // Create fresh mock objects for this test + const testMockMulti = { + zRemRangeByScore: vi.fn().mockReturnThis(), + zCard: vi.fn().mockReturnThis(), + zAdd: vi.fn().mockReturnThis(), + expire: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue(null), // Mock transaction returning null + }; + + const testMockRedis = { + multi: vi.fn().mockReturnValue(testMockMulti), + zRange: vi.fn(), + isReady: true, + }; + + // Reset and setup mock for this specific test + mockGetRedisClient.mockReset(); + mockGetRedisClient.mockReturnValue(testMockRedis); + + const email = "transaction-failure@example.com"; + const result = await shouldLogAuthFailure(email, false); + + // Function should return false when Redis transaction fails (fail-closed behavior) + expect(result).toBe(false); + expect(mockGetRedisClient).toHaveBeenCalled(); + expect(testMockRedis.multi).toHaveBeenCalled(); + expect(testMockMulti.zRemRangeByScore).toHaveBeenCalled(); + expect(testMockMulti.zCard).toHaveBeenCalled(); + expect(testMockMulti.zAdd).toHaveBeenCalled(); + expect(testMockMulti.expire).toHaveBeenCalled(); + expect(testMockMulti.exec).toHaveBeenCalled(); + }); + + test("should allow logging when currentCount <= AGGREGATION_THRESHOLD", async () => { + // Mock Redis transaction returning count <= threshold (assuming threshold is 3) + mockMulti.exec.mockResolvedValue([ + null, // zRemRangeByScore result + 2, // zCard result - below threshold + null, // zAdd result + null, // expire result + ]); + + const email = "below-threshold@example.com"; + const result = await shouldLogAuthFailure(email, false); + + expect(result).toBe(true); + expect(mockMulti.exec).toHaveBeenCalled(); + }); + + test("should allow logging when recentEntries.length === 0", async () => { + // Mock Redis transaction returning count above threshold + mockMulti.exec.mockResolvedValue([ + null, // zRemRangeByScore result + 5, // zCard result - above threshold + null, // zAdd result + null, // expire result + ]); + + // Mock zRange returning empty array + mockRedis.zRange.mockResolvedValue([]); + + const email = "no-recent-entries@example.com"; + const result = await shouldLogAuthFailure(email, false); + + expect(result).toBe(true); + expect(mockRedis.zRange).toHaveBeenCalledWith(expect.stringContaining("rate_limit:auth:"), -10, -1); + }); + + test("should allow logging on every 10th attempt - currentCount % 10 === 0", async () => { + const now = Date.now(); + + // Mock Redis transaction returning count that is divisible by 10 + mockMulti.exec.mockResolvedValue([ + null, // zRemRangeByScore result + 10, // zCard result - 10th attempt + null, // zAdd result + null, // expire result + ]); + + // Mock zRange returning recent entries + mockRedis.zRange.mockResolvedValue([ + `${now - 30000}:uuid1`, // 30 seconds ago + ]); + + const email = "tenth-attempt@example.com"; + const result = await shouldLogAuthFailure(email, false); + + expect(result).toBe(true); + expect(mockRedis.zRange).toHaveBeenCalled(); + }); + + test("should allow logging after 1 minute gap - timeSinceLastLog > 60000", async () => { + const now = Date.now(); + + // Mock Redis transaction returning count not divisible by 10 + mockMulti.exec.mockResolvedValue([ + null, // zRemRangeByScore result + 7, // zCard result - 7th attempt (not divisible by 10) + null, // zAdd result + null, // expire result + ]); + + // Mock zRange returning entry older than 1 minute + mockRedis.zRange.mockResolvedValue([ + `${now - 120000}:uuid1`, // 2 minutes ago + ]); + + const email = "one-minute-gap@example.com"; + const result = await shouldLogAuthFailure(email, false); + + expect(result).toBe(true); + expect(mockRedis.zRange).toHaveBeenCalled(); + }); + + test("should block logging when neither condition is met", async () => { + const now = Date.now(); + + // Mock Redis transaction returning count not divisible by 10 + mockMulti.exec.mockResolvedValue([ + null, // zRemRangeByScore result + 7, // zCard result - 7th attempt (not divisible by 10) + null, // zAdd result + null, // expire result + ]); + + // Mock zRange returning recent entry (less than 1 minute) + mockRedis.zRange.mockResolvedValue([ + `${now - 30000}:uuid1`, // 30 seconds ago + ]); + + const email = "blocked-logging@example.com"; + const result = await shouldLogAuthFailure(email, false); + + expect(result).toBe(false); + expect(mockRedis.zRange).toHaveBeenCalled(); + }); + + test("should handle Redis operation errors gracefully", async () => { + // Mock Redis multi throwing an error + mockMulti.exec.mockRejectedValue(new Error("Redis operation failed")); + + const email = "redis-error@example.com"; + const result = await shouldLogAuthFailure(email, false); + + expect(result).toBe(false); + expect(mockMulti.exec).toHaveBeenCalled(); + }); + + test("should handle zRange errors gracefully", async () => { + // Mock successful transaction but zRange failing + mockMulti.exec.mockResolvedValue([ + null, // zRemRangeByScore result + 5, // zCard result - above threshold + null, // zAdd result + null, // expire result + ]); + + mockRedis.zRange.mockRejectedValue(new Error("zRange failed")); + + const email = "zrange-error@example.com"; + const result = await shouldLogAuthFailure(email, false); + + expect(result).toBe(false); + expect(mockRedis.zRange).toHaveBeenCalled(); + }); + + test("should handle malformed timestamp in recent entries", async () => { + // Mock Redis transaction returning count not divisible by 10 + mockMulti.exec.mockResolvedValue([ + null, // zRemRangeByScore result + 7, // zCard result - 7th attempt + null, // zAdd result + null, // expire result + ]); + + // Mock zRange returning entry with malformed timestamp + mockRedis.zRange.mockResolvedValue(["invalid-timestamp:uuid1"]); + + const email = "malformed-timestamp@example.com"; + const result = await shouldLogAuthFailure(email, false); + + // Should handle parseInt(NaN) gracefully and still make a decision + expect(typeof result).toBe("boolean"); + expect(mockRedis.zRange).toHaveBeenCalled(); + }); + + test("should verify correct Redis key generation and operations", async () => { + mockMulti.exec.mockResolvedValue([ + null, // zRemRangeByScore result + 2, // zCard result - below threshold + null, // zAdd result + null, // expire result + ]); + + const email = "key-generation@example.com"; + await shouldLogAuthFailure(email, false); + + // Verify correct Redis operations were called + expect(mockRedis.multi).toHaveBeenCalled(); + expect(mockMulti.zRemRangeByScore).toHaveBeenCalledWith( + expect.stringContaining("rate_limit:auth:"), + 0, + expect.any(Number) + ); + expect(mockMulti.zCard).toHaveBeenCalledWith(expect.stringContaining("rate_limit:auth:")); + expect(mockMulti.zAdd).toHaveBeenCalledWith( + expect.stringContaining("rate_limit:auth:"), + expect.objectContaining({ + score: expect.any(Number), + value: expect.stringMatching(/^\d+:.+$/), + }) + ); + expect(mockMulti.expire).toHaveBeenCalledWith( + expect.stringContaining("rate_limit:auth:"), + expect.any(Number) + ); + }); + }); + }); + + describe("Audit Logging Functions", () => { + test("should log auth event with hashed identifier", () => { + logAuthEvent("authenticationAttempted", "failure", "unknown", "user@example.com", { + failureReason: "invalid_password", + }); + + expect(queueAuditEventBackground).toHaveBeenCalledWith({ + action: "authenticationAttempted", + targetType: "user", + userId: "email_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + targetId: "email_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + organizationId: "unknown", + status: "failure", + userType: "user", + newObject: { + failureReason: "invalid_password", + }, + }); + }); + + test("should use provided userId when available", () => { + logAuthEvent("passwordVerified", "success", "user_123", "user@example.com", { + requires2FA: true, + }); + + expect(queueAuditEventBackground).toHaveBeenCalledWith({ + action: "passwordVerified", + targetType: "user", + userId: "user_123", + targetId: "user_123", + organizationId: "unknown", + status: "success", + userType: "user", + newObject: { + requires2FA: true, + }, + }); + }); + + test("should log authentication attempt with correct structure", () => { + logAuthAttempt( + "invalid_password", + "credentials", + "password_validation", + "user_123", + "user@example.com" + ); + + expect(queueAuditEventBackground).toHaveBeenCalledWith( + expect.objectContaining({ + action: "authenticationAttempted", + status: "failure", + userId: "user_123", + newObject: expect.objectContaining({ + failureReason: "invalid_password", + provider: "credentials", + authMethod: "password_validation", + }), + }) + ); + }); + + test("should log successful authentication", () => { + logAuthSuccess( + "authenticationSucceeded", + "credentials", + "password_only", + "user_123", + "user@example.com" + ); + + expect(queueAuditEventBackground).toHaveBeenCalledWith( + expect.objectContaining({ + action: "authenticationSucceeded", + status: "success", + userId: "user_123", + newObject: expect.objectContaining({ + provider: "credentials", + authMethod: "password_only", + }), + }) + ); + }); + + test("should log two-factor verification", () => { + logTwoFactorAttempt(true, "totp", "user_123", "user@example.com"); + + expect(queueAuditEventBackground).toHaveBeenCalledWith( + expect.objectContaining({ + action: "twoFactorVerified", + status: "success", + newObject: expect.objectContaining({ + provider: "credentials", + authMethod: "totp", + }), + }) + ); + }); + + test("should log failed two-factor attempt", () => { + logTwoFactorAttempt(false, "backup_code", "user_123", "user@example.com", "invalid_backup_code"); + + expect(queueAuditEventBackground).toHaveBeenCalledWith( + expect.objectContaining({ + action: "twoFactorAttempted", + status: "failure", + newObject: expect.objectContaining({ + provider: "credentials", + authMethod: "backup_code", + failureReason: "invalid_backup_code", + }), + }) + ); + }); + + test("should log email verification", () => { + logEmailVerificationAttempt(true, undefined, "user_123", "user@example.com", { + emailVerifiedAt: new Date().toISOString(), + }); + + expect(queueAuditEventBackground).toHaveBeenCalledWith( + expect.objectContaining({ + action: "emailVerified", + status: "success", + newObject: expect.objectContaining({ + provider: "token", + authMethod: "email_verification", + }), + }) + ); + }); + + test("should log failed email verification", () => { + logEmailVerificationAttempt(false, "invalid_token", "user_123", "user@example.com", { + tokenProvided: true, + }); + + expect(queueAuditEventBackground).toHaveBeenCalledWith({ + action: "emailVerificationAttempted", + targetType: "user", + userId: "user_123", + userType: "user", + targetId: "user_123", + organizationId: UNKNOWN_DATA, + status: "failure", + newObject: { + failureReason: "invalid_token", + provider: "token", + authMethod: "email_verification", + tokenProvided: true, + }, + }); + }); + + test("should log user sign out event", () => { + logSignOut("user_123", "user@example.com", { + reason: "user_initiated", + redirectUrl: "/auth/login", + organizationId: "org_123", + }); + + expect(queueAuditEventBackground).toHaveBeenCalledWith({ + action: "userSignedOut", + targetType: "user", + userId: "user_123", + userType: "user", + targetId: "user_123", + organizationId: UNKNOWN_DATA, + status: "success", + newObject: { + provider: "session", + authMethod: "sign_out", + reason: "user_initiated", + redirectUrl: "/auth/login", + organizationId: "org_123", + }, + }); + }); + + test("should log sign out with default reason", () => { + logSignOut("user_123", "user@example.com"); + + expect(queueAuditEventBackground).toHaveBeenCalledWith({ + action: "userSignedOut", + targetType: "user", + userId: "user_123", + userType: "user", + targetId: "user_123", + organizationId: UNKNOWN_DATA, + status: "success", + newObject: { + provider: "session", + authMethod: "sign_out", + reason: "user_initiated", + organizationId: undefined, + redirectUrl: undefined, + }, + }); + }); + }); + + describe("PII Protection", () => { + test("should never log actual email addresses", () => { + const email = "sensitive@company.com"; + + logAuthAttempt("invalid_password", "credentials", "password_validation", "unknown", email); + + const logCall = (queueAuditEventBackground as any).mock.calls[0][0]; + const logString = JSON.stringify(logCall); + + expect(logString).not.toContain("sensitive@company.com"); + expect(logString).not.toContain("company.com"); + expect(logString).not.toContain("sensitive"); + }); + + test("should create consistent hashed identifiers", () => { + const email = "user@example.com"; + + logAuthAttempt("invalid_password", "credentials", "password_validation", "unknown", email); + logAuthAttempt("user_not_found", "credentials", "user_lookup", "unknown", email); + + const calls = (queueAuditEventBackground as any).mock.calls; + expect(calls[0][0].userId).toBe(calls[1][0].userId); + }); + }); + + describe("Sentry Integration", () => { + test("should capture authentication failures to Sentry", () => { + logAuthEvent("authenticationAttempted", "failure", "user_123", "user@example.com", { + failureReason: "invalid_password", + provider: "credentials", + authMethod: "password_validation", + tags: { security_event: "password_failure" }, + }); + + expect(Sentry.captureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: expect.objectContaining({ + component: "authentication", + action: "authenticationAttempted", + status: "failure", + security_event: "password_failure", + }), + extra: expect.objectContaining({ + userId: "user_123", + provider: "credentials", + authMethod: "password_validation", + failureReason: "invalid_password", + }), + }) + ); + }); + + test("should not capture successful authentication to Sentry", () => { + vi.clearAllMocks(); + + logAuthEvent("passwordVerified", "success", "user_123", "user@example.com", { + provider: "credentials", + authMethod: "password_validation", + }); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/web/modules/auth/lib/utils.ts b/apps/web/modules/auth/lib/utils.ts index 79d3f371b345..967d3f530e0e 100644 --- a/apps/web/modules/auth/lib/utils.ts +++ b/apps/web/modules/auth/lib/utils.ts @@ -1,4 +1,11 @@ +import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants"; +import { getRedisClient } from "@/modules/cache/redis"; +import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler"; +import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; +import * as Sentry from "@sentry/nextjs"; import { compare, hash } from "bcryptjs"; +import { createHash, randomUUID } from "crypto"; +import { logger } from "@formbricks/logger"; export const hashPassword = async (password: string) => { const hashedPassword = await hash(password, 12); @@ -9,3 +16,290 @@ export const verifyPassword = async (password: string, hashedPassword: string) = const isValid = await compare(password, hashedPassword); return isValid; }; + +/** + * Creates a consistent hashed identifier for audit logging that protects PII + * while still allowing pattern tracking and rate limiting. + * + * @param identifier - The identifier to hash (email, IP, etc.) + * @param prefix - Optional prefix for the hash (e.g., "email", "ip") + * @returns A consistent SHA-256 hash that can be used for tracking without exposing PII + */ +export const createAuditIdentifier = (identifier: string, prefix: string = "actor"): string => { + if (!identifier || identifier === "unknown" || identifier === "unknown_user") { + return UNKNOWN_DATA; + } + + // Create a consistent hash that can be used for pattern detection + // Use a longer hash for better collision resistance in compliance scenarios + const hash = createHash("sha256").update(identifier.toLowerCase()).digest("hex"); + return `${prefix}_${hash.substring(0, 32)}`; // Use first 32 chars for better uniqueness +}; + +export const logAuthEvent = ( + action: TAuditAction, + status: TAuditStatus, + userId: string, + email?: string, + additionalData: Record = {} +) => { + const auditActorId = userId === UNKNOWN_DATA && email ? createAuditIdentifier(email, "email") : userId; + + // Log failures to Sentry for monitoring and alerting + if (status === "failure" && SENTRY_DSN && IS_PRODUCTION) { + const error = new Error(`Authentication ${action} failed`); + Sentry.captureException(error, { + tags: { + component: "authentication", + action, + status, + ...(additionalData.tags ?? {}), + }, + extra: { + userId: auditActorId, + provider: additionalData.provider, + authMethod: additionalData.authMethod, + failureReason: additionalData.failureReason, + ...additionalData, + }, + }); + } + + queueAuditEventBackground({ + action, + targetType: "user", + userId: auditActorId, + targetId: auditActorId, + organizationId: UNKNOWN_DATA, + status, + userType: "user", + newObject: { + ...additionalData, + }, + }); +}; + +/** + * Helper function for logging authentication attempts with consistent failure reasons. + * + * @param failureReason - Specific reason for authentication failure + * @param provider - Authentication provider (credentials, token, etc.) + * @param authMethod - Authentication method (password, totp, backup_code, etc.) + * @param userId - User ID (use UNKNOWN_DATA if not available) + * @param email - User email (optional) - used ONLY to create hashed identifier, never stored + * @param additionalData - Additional context data + */ +export const logAuthAttempt = ( + failureReason: string, + provider: string, + authMethod: string, + userId: string = UNKNOWN_DATA, + email?: string, + additionalData: Record = {} +) => { + logAuthEvent("authenticationAttempted", "failure", userId, email, { + failureReason, + provider, + authMethod, + ...additionalData, + }); +}; + +/** + * Helper function for logging successful authentication events. + * + * @param action - The specific success action (passwordVerified, twoFactorVerified, etc.) + * @param provider - Authentication provider + * @param authMethod - Authentication method + * @param userId - User ID + * @param email - User email - used ONLY to create hashed identifier, never stored + * @param additionalData - Additional context data + */ +export const logAuthSuccess = ( + action: TAuditAction, + provider: string, + authMethod: string, + userId: string, + email: string, + additionalData: Record = {} +) => { + logAuthEvent(action, "success", userId, email, { + provider, + authMethod, + ...additionalData, + }); +}; + +/** + * Helper function for logging two-factor authentication attempts. + * + * @param isSuccess - Whether the 2FA attempt was successful + * @param authMethod - 2FA method (totp, backup_code) + * @param userId - User ID + * @param email - User email - used ONLY to create hashed identifier, never stored + * @param failureReason - Failure reason (only for failed attempts) + * @param additionalData - Additional context data + */ +export const logTwoFactorAttempt = ( + isSuccess: boolean, + authMethod: string, + userId: string, + email: string, + failureReason?: string, + additionalData: Record = {} +) => { + const action = isSuccess ? "twoFactorVerified" : "twoFactorAttempted"; + const status = isSuccess ? "success" : "failure"; + + logAuthEvent(action, status, userId, email, { + provider: "credentials", + authMethod, + ...(failureReason && !isSuccess ? { failureReason } : {}), + ...additionalData, + }); +}; + +/** + * Helper function for logging email verification attempts. + * + * @param isSuccess - Whether the verification was successful + * @param failureReason - Failure reason (only for failed attempts) + * @param userId - User ID (use UNKNOWN_DATA if not available) + * @param email - User email (optional) - used ONLY to create hashed identifier, never stored + * @param additionalData - Additional context data + */ +export const logEmailVerificationAttempt = ( + isSuccess: boolean, + failureReason?: string, + userId: string = UNKNOWN_DATA, + email?: string, + additionalData: Record = {} +) => { + const action = isSuccess ? "emailVerified" : "emailVerificationAttempted"; + const status = isSuccess ? "success" : "failure"; + + logAuthEvent(action, status, userId, email, { + provider: "token", + authMethod: "email_verification", + ...(failureReason && !isSuccess ? { failureReason } : {}), + ...additionalData, + }); +}; + +// Rate limiting constants +const RATE_LIMIT_WINDOW = 5 * 60 * 1000; // 5 minutes +const AGGREGATION_THRESHOLD = 3; // After 3 failures, start aggregating + +/** + * Rate limiting decision function for authentication audit logs. + * Uses Redis for distributed rate limiting across Kubernetes pods. + * + * **What this function does:** + * - Returns true/false to indicate whether an auth attempt should be logged + * - Always returns true for successful authentications (no rate limiting) + * - For failures: allows first 3 attempts per identifier within 5-minute window + * - After 3 failures: allows every 10th attempt OR after 1+ minute gap + * - Uses hashed identifiers to protect PII while enabling tracking + * - Returns false if Redis is unavailable (fail closed) + * + * **Use cases:** + * - Gate authentication failure logging to prevent spam + * - Provide consistent rate limiting decisions across Kubernetes pods + * - Protect user PII through identifier hashing + * + * **Example usage:** + * ```typescript + * if (await shouldLogAuthFailure(user.email)) { + * logAuthAttempt("invalid_password", "credentials", "password", user.id, user.email); + * } + * ``` + * + * @param identifier - Unique identifier for rate limiting (email, token, etc.) - will be hashed + * @param isSuccess - Whether this is a successful authentication (defaults to false) + * @returns Promise - Whether this attempt should be logged to audit trail + */ +export const shouldLogAuthFailure = async ( + identifier: string, + isSuccess: boolean = false +): Promise => { + // Always log successful authentications + if (isSuccess) return true; + + const rateLimitKey = `rate_limit:auth:${createAuditIdentifier(identifier, "ratelimit")}`; + const now = Date.now(); + + try { + // Get Redis client + const redis = getRedisClient(); + if (!redis) { + logger.warn("Redis not available for rate limiting, not logging due to Redis requirement"); + return false; + } + + // Use Redis for distributed rate limiting + const multi = redis.multi(); + const windowStart = now - RATE_LIMIT_WINDOW; + + // Remove expired entries and count recent failures + multi.zRemRangeByScore(rateLimitKey, 0, windowStart); + multi.zCard(rateLimitKey); + multi.zAdd(rateLimitKey, { score: now, value: `${now}:${randomUUID()}` }); + multi.expire(rateLimitKey, Math.ceil(RATE_LIMIT_WINDOW / 1000)); + + const results = await multi.exec(); + if (!results) { + throw new Error("Redis transaction failed"); + } + + const currentCount = results[1] as number; + + // Apply throttling logic + if (currentCount <= AGGREGATION_THRESHOLD) { + return true; + } + + // Check if we should log (every 10th or after 1 minute gap) + const recentEntries = await redis.zRange(rateLimitKey, -10, -1); + if (recentEntries.length === 0) return true; + + const lastLogTime = Number.parseInt(recentEntries[recentEntries.length - 1].split(":")[0]); + const timeSinceLastLog = now - lastLogTime; + + return currentCount % 10 === 0 || timeSinceLastLog > 60000; + } catch (error) { + logger.warn("Redis rate limiting failed, not logging due to Redis requirement", { error }); + // If Redis fails, do not log as Redis is required for audit logs + return false; + } +}; + +/** + * Logs a user sign out event for audit compliance. + * + * @param userId - The ID of the user signing out + * @param userEmail - The email of the user signing out + * @param context - Additional context about the sign out (reason, redirect URL, etc.) + */ +export const logSignOut = ( + userId: string, + userEmail: string, + context?: { + reason?: + | "user_initiated" + | "account_deletion" + | "email_change" + | "session_timeout" + | "forced_logout" + | "password_reset"; + redirectUrl?: string; + organizationId?: string; + } +) => { + logAuthEvent("userSignedOut", "success", userId, userEmail, { + provider: "session", + authMethod: "sign_out", + reason: context?.reason || "user_initiated", // NOSONAR // We want to check for empty strings + redirectUrl: context?.redirectUrl, + organizationId: context?.organizationId, + }); +}; diff --git a/apps/web/modules/auth/login/components/login-form.tsx b/apps/web/modules/auth/login/components/login-form.tsx index 917fa0704c9f..eeb172da5251 100644 --- a/apps/web/modules/auth/login/components/login-form.tsx +++ b/apps/web/modules/auth/login/components/login-form.tsx @@ -1,5 +1,7 @@ "use client"; +import { cn } from "@/lib/cn"; +import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { createEmailTokenAction } from "@/modules/auth/actions"; import { SSOOptions } from "@/modules/ee/sso/components/sso-options"; @@ -17,8 +19,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; import { z } from "zod"; -import { cn } from "@formbricks/lib/cn"; -import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage"; const ZLoginForm = z.object({ email: z.string().email(), @@ -204,7 +204,7 @@ export const LoginForm = ({ aria-label="password" aria-required="true" required - className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm" + className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 pr-8 shadow-sm sm:text-sm" value={field.value} onChange={(password) => field.onChange(password)} /> diff --git a/apps/web/modules/auth/login/page.tsx b/apps/web/modules/auth/login/page.tsx index cc70cc03f94f..b5cd081e6dca 100644 --- a/apps/web/modules/auth/login/page.tsx +++ b/apps/web/modules/auth/login/page.tsx @@ -1,11 +1,3 @@ -import { FormWrapper } from "@/modules/auth/components/form-wrapper"; -import { Testimonial } from "@/modules/auth/components/testimonial"; -import { - getIsMultiOrgEnabled, - getIsSamlSsoEnabled, - getisSsoEnabled, -} from "@/modules/ee/license-check/lib/utils"; -import { Metadata } from "next"; import { AZURE_OAUTH_ENABLED, EMAIL_AUTH_ENABLED, @@ -18,7 +10,15 @@ import { SAML_PRODUCT, SAML_TENANT, SIGNUP_ENABLED, -} from "@formbricks/lib/constants"; +} from "@/lib/constants"; +import { FormWrapper } from "@/modules/auth/components/form-wrapper"; +import { Testimonial } from "@/modules/auth/components/testimonial"; +import { + getIsMultiOrgEnabled, + getIsSamlSsoEnabled, + getIsSsoEnabled, +} from "@/modules/ee/license-check/lib/utils"; +import { Metadata } from "next"; import { LoginForm } from "./components/login-form"; export const metadata: Metadata = { @@ -29,7 +29,7 @@ export const metadata: Metadata = { export const LoginPage = async () => { const [isMultiOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([ getIsMultiOrgEnabled(), - getisSsoEnabled(), + getIsSsoEnabled(), getIsSamlSsoEnabled(), ]); diff --git a/apps/web/modules/auth/signup-without-verification-success/page.test.tsx b/apps/web/modules/auth/signup-without-verification-success/page.test.tsx new file mode 100644 index 000000000000..93c4ef20184c --- /dev/null +++ b/apps/web/modules/auth/signup-without-verification-success/page.test.tsx @@ -0,0 +1,83 @@ +import { getEmailFromEmailToken } from "@/lib/jwt"; +import { SignupWithoutVerificationSuccessPage } from "@/modules/auth/signup-without-verification-success/page"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, + T: ({ keyName, params }) => { + if (params && params.email) { + return `${keyName} ${params.email}`; + } + return keyName; + }, +})); + +vi.mock("@/lib/constants", () => ({ + INTERCOM_SECRET_KEY: "test-secret-key", + IS_INTERCOM_CONFIGURED: true, + INTERCOM_APP_ID: "test-app-id", + ENCRYPTION_KEY: "test-encryption-key", + ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key", + GITHUB_ID: "test-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_POSTHOG_CONFIGURED: true, + POSTHOG_API_HOST: "test-posthog-api-host", + POSTHOG_API_KEY: "test-posthog-api-key", + FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id", + IS_FORMBRICKS_ENABLED: true, + SESSION_MAX_AGE: 1000, + AVAILABLE_LOCALES: ["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW", "pt-PT"], +})); + +vi.mock("@/modules/auth/components/back-to-login-button", () => ({ + BackToLoginButton: () =>
    Mocked BackToLoginButton
    , +})); + +vi.mock("@/modules/auth/components/form-wrapper", () => ({ + FormWrapper: ({ children }) =>
    {children}
    , +})); + +vi.mock("@/modules/ui/components/alert", () => ({ + Alert: ({ children }) =>
    {children}
    , +})); + +vi.mock("@/lib/jwt", () => ({ + getEmailFromEmailToken: vi.fn(), +})); + +describe("SignupWithoutVerificationSuccessPage", () => { + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + test("renders the success page correctly", async () => { + vi.mocked(getEmailFromEmailToken).mockReturnValue("test@example.com"); + + const Page = await SignupWithoutVerificationSuccessPage({ searchParams: { token: "test-token" } }); + render(Page); + + expect( + screen.getByText("auth.signup_without_verification_success.user_successfully_created") + ).toBeInTheDocument(); + expect( + screen.getByText( + "auth.signup_without_verification_success.user_successfully_created_info test@example.com" + ) + ).toBeInTheDocument(); + expect(screen.getByText("Mocked BackToLoginButton")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/auth/signup-without-verification-success/page.tsx b/apps/web/modules/auth/signup-without-verification-success/page.tsx index 687cdb9f8f81..65fe76271c84 100644 --- a/apps/web/modules/auth/signup-without-verification-success/page.tsx +++ b/apps/web/modules/auth/signup-without-verification-success/page.tsx @@ -1,16 +1,23 @@ +import { getEmailFromEmailToken } from "@/lib/jwt"; import { BackToLoginButton } from "@/modules/auth/components/back-to-login-button"; import { FormWrapper } from "@/modules/auth/components/form-wrapper"; -import { getTranslate } from "@/tolgee/server"; +import { T, getTranslate } from "@/tolgee/server"; -export const SignupWithoutVerificationSuccessPage = async () => { +export const SignupWithoutVerificationSuccessPage = async ({ searchParams }) => { const t = await getTranslate(); + const { token } = await searchParams; + const email = getEmailFromEmailToken(token); + return (

    {t("auth.signup_without_verification_success.user_successfully_created")}

    - {t("auth.signup_without_verification_success.user_successfully_created_description")} + }} + />


    diff --git a/apps/web/modules/auth/signup/actions.ts b/apps/web/modules/auth/signup/actions.ts index 13ecdb657f27..d72bad55ba92 100644 --- a/apps/web/modules/auth/signup/actions.ts +++ b/apps/web/modules/auth/signup/actions.ts @@ -1,21 +1,34 @@ "use server"; +import { hashPassword } from "@/lib/auth"; +import { IS_TURNSTILE_CONFIGURED, TURNSTILE_SECRET_KEY } from "@/lib/constants"; +import { verifyInviteToken } from "@/lib/jwt"; +import { createMembership } from "@/lib/membership/service"; +import { createOrganization } from "@/lib/organization/service"; import { actionClient } from "@/lib/utils/action-client"; +import { ActionClientCtx } from "@/lib/utils/action-client/types/context"; import { createUser, updateUser } from "@/modules/auth/lib/user"; import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite"; import { createTeamMembership } from "@/modules/auth/signup/lib/team"; import { captureFailedSignup, verifyTurnstileToken } from "@/modules/auth/signup/lib/utils"; +import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email"; import { z } from "zod"; -import { hashPassword } from "@formbricks/lib/auth"; -import { IS_TURNSTILE_CONFIGURED, TURNSTILE_SECRET_KEY } from "@formbricks/lib/constants"; -import { verifyInviteToken } from "@formbricks/lib/jwt"; -import { createMembership } from "@formbricks/lib/membership/service"; -import { createOrganization, getOrganization } from "@formbricks/lib/organization/service"; -import { UnknownError } from "@formbricks/types/errors"; -import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships"; -import { ZUserEmail, ZUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user"; +import { InvalidInputError, UnknownError } from "@formbricks/types/errors"; +import { ZUser, ZUserEmail, ZUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user"; + +const ZCreatedUser = ZUser.pick({ + name: true, + email: true, + locale: true, + id: true, + notificationSettings: true, +}); + +type TCreatedUser = z.infer; const ZCreateUserAction = z.object({ name: ZUserName, @@ -23,8 +36,6 @@ const ZCreateUserAction = z.object({ password: ZUserPassword, inviteToken: z.string().optional(), userLocale: ZUserLocale.optional(), - defaultOrganizationId: z.string().optional(), - defaultOrganizationRole: ZOrganizationRole.optional(), emailVerificationDisabled: z.boolean().optional(), turnstileToken: z .string() @@ -35,109 +46,167 @@ const ZCreateUserAction = z.object({ ), }); -export const createUserAction = actionClient.schema(ZCreateUserAction).action(async ({ parsedInput }) => { - if (IS_TURNSTILE_CONFIGURED) { - if (!parsedInput.turnstileToken || !TURNSTILE_SECRET_KEY) { - captureFailedSignup(parsedInput.email, parsedInput.name); - throw new UnknownError("Server configuration error"); - } +async function verifyTurnstileIfConfigured( + turnstileToken: string | undefined, + email: string, + name: string +): Promise { + if (!IS_TURNSTILE_CONFIGURED) return; - const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, parsedInput.turnstileToken); - if (!isHuman) { - captureFailedSignup(parsedInput.email, parsedInput.name); - throw new UnknownError("reCAPTCHA verification failed"); - } + if (!turnstileToken || !TURNSTILE_SECRET_KEY) { + captureFailedSignup(email, name); + throw new UnknownError("Server configuration error"); } - const { inviteToken, emailVerificationDisabled } = parsedInput; - const hashedPassword = await hashPassword(parsedInput.password); - const user = await createUser({ - email: parsedInput.email.toLowerCase(), - name: parsedInput.name, - password: hashedPassword, - locale: parsedInput.userLocale, - }); + const isHuman = await verifyTurnstileToken(TURNSTILE_SECRET_KEY, turnstileToken); + if (!isHuman) { + captureFailedSignup(email, name); + throw new UnknownError("reCAPTCHA verification failed"); + } +} - // Handle invite flow - if (inviteToken) { - const inviteTokenData = verifyInviteToken(inviteToken); - const invite = await getInvite(inviteTokenData.inviteId); - if (!invite) { - throw new Error("Invalid invite ID"); - } +async function createUserSafely( + email: string, + name: string, + hashedPassword: string, + userLocale: z.infer | undefined +): Promise<{ user: TCreatedUser | undefined; userAlreadyExisted: boolean }> { + let user: TCreatedUser | undefined = undefined; + let userAlreadyExisted = false; - await createMembership(invite.organizationId, user.id, { - accepted: true, - role: invite.role, + try { + user = await createUser({ + email: email.toLowerCase(), + name, + password: hashedPassword, + locale: userLocale, }); - - if (invite.teamIds) { - await createTeamMembership( - { - organizationId: invite.organizationId, - role: invite.role, - teamIds: invite.teamIds, - }, - user.id - ); + } catch (error) { + if (error instanceof InvalidInputError) { + userAlreadyExisted = true; + } else { + throw error; } + } - await updateUser(user.id, { - notificationSettings: { - alert: {}, - weeklySummary: {}, - unsubscribedOrganizationIds: [invite.organizationId], - }, - }); + return { user, userAlreadyExisted }; +} - await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email); - await deleteInvite(invite.id); +async function handleInviteAcceptance( + ctx: ActionClientCtx, + inviteToken: string, + user: TCreatedUser +): Promise { + const inviteTokenData = verifyInviteToken(inviteToken); + const invite = await getInvite(inviteTokenData.inviteId); + + if (!invite) { + throw new Error("Invalid invite ID"); } - // Handle organization assignment - else { - let organizationId: string | undefined; - let role: TOrganizationRole = "owner"; - - if (parsedInput.defaultOrganizationId) { - // Use existing or create organization with specific ID - let organization = await getOrganization(parsedInput.defaultOrganizationId); - if (!organization) { - organization = await createOrganization({ - id: parsedInput.defaultOrganizationId, - name: `${user.name}'s Organization`, - }); - } else { - role = parsedInput.defaultOrganizationRole || "owner"; - } - organizationId = organization.id; - } else { - const isMultiOrgEnabled = await getIsMultiOrgEnabled(); - if (isMultiOrgEnabled) { - // Create new organization - const organization = await createOrganization({ name: `${user.name}'s Organization` }); - organizationId = organization.id; - } - } + ctx.auditLoggingCtx.organizationId = invite.organizationId; - if (organizationId) { - await createMembership(organizationId, user.id, { role, accepted: true }); - await updateUser(user.id, { - notificationSettings: { - ...user.notificationSettings, - alert: { ...user.notificationSettings?.alert }, - weeklySummary: { ...user.notificationSettings?.weeklySummary }, - unsubscribedOrganizationIds: Array.from( - new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), organizationId]) - ), - }, - }); - } + await createMembership(invite.organizationId, user.id, { + accepted: true, + role: invite.role, + }); + + if (invite.teamIds) { + await createTeamMembership( + { + organizationId: invite.organizationId, + role: invite.role, + teamIds: invite.teamIds, + }, + user.id + ); + } + + await updateUser(user.id, { + notificationSettings: { + alert: {}, + + unsubscribedOrganizationIds: [invite.organizationId], + }, + }); + + await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email); + await deleteInvite(invite.id); +} + +async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUser): Promise { + const isMultiOrgEnabled = await getIsMultiOrgEnabled(); + if (!isMultiOrgEnabled) return; + + const organization = await createOrganization({ name: `${user.name}'s Organization` }); + ctx.auditLoggingCtx.organizationId = organization.id; + + await createMembership(organization.id, user.id, { + role: "owner", + accepted: true, + }); + + await updateUser(user.id, { + notificationSettings: { + ...user.notificationSettings, + alert: { ...user.notificationSettings?.alert }, + + unsubscribedOrganizationIds: Array.from( + new Set([...(user.notificationSettings?.unsubscribedOrganizationIds ?? []), organization.id]) + ), + }, + }); +} + +async function handlePostUserCreation( + ctx: ActionClientCtx, + user: TCreatedUser, + inviteToken: string | undefined, + emailVerificationDisabled: boolean | undefined +): Promise { + if (inviteToken) { + await handleInviteAcceptance(ctx, inviteToken, user); + } else { + await handleOrganizationCreation(ctx, user); } - // Send verification email if enabled if (!emailVerificationDisabled) { await sendVerificationEmail(user); } +} - return user; -}); +export const createUserAction = actionClient.schema(ZCreateUserAction).action( + withAuditLogging( + "created", + "user", + async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record }) => { + await applyIPRateLimit(rateLimitConfigs.auth.signup); + await verifyTurnstileIfConfigured(parsedInput.turnstileToken, parsedInput.email, parsedInput.name); + + const hashedPassword = await hashPassword(parsedInput.password); + const { user, userAlreadyExisted } = await createUserSafely( + parsedInput.email, + parsedInput.name, + hashedPassword, + parsedInput.userLocale + ); + + if (!userAlreadyExisted && user) { + await handlePostUserCreation( + ctx, + user, + parsedInput.inviteToken, + parsedInput.emailVerificationDisabled + ); + } + + if (user) { + ctx.auditLoggingCtx.userId = user.id; + ctx.auditLoggingCtx.newObject = user; + } + + return { + success: true, + }; + } + ) +); diff --git a/apps/web/modules/auth/signup/components/password-checks.tsx b/apps/web/modules/auth/signup/components/password-checks.tsx index b46fe00eaefd..d044d1b4e8d9 100644 --- a/apps/web/modules/auth/signup/components/password-checks.tsx +++ b/apps/web/modules/auth/signup/components/password-checks.tsx @@ -52,7 +52,7 @@ export const PasswordChecks = ({ password }: PasswordChecksProps) => { return (
    -
      +
        {validations.map((validation) => (
      • diff --git a/apps/web/modules/auth/signup/components/signup-form.test.tsx b/apps/web/modules/auth/signup/components/signup-form.test.tsx index e494668f753e..1e9f66d1e0ed 100644 --- a/apps/web/modules/auth/signup/components/signup-form.test.tsx +++ b/apps/web/modules/auth/signup/components/signup-form.test.tsx @@ -4,13 +4,13 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { useSearchParams } from "next/navigation"; import toast from "react-hot-toast"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { createEmailTokenAction } from "../../../auth/actions"; import { SignupForm } from "./signup-form"; // Mock dependencies -vi.mock("@formbricks/lib/constants", () => ({ +vi.mock("@/lib/constants", () => ({ IS_FORMBRICKS_CLOUD: false, POSTHOG_API_KEY: "mock-posthog-api-key", POSTHOG_HOST: "mock-posthog-host", @@ -119,17 +119,16 @@ const defaultProps = { isTurnstileConfigured: false, samlTenant: "", samlProduct: "", - defaultOrganizationId: "org1", - defaultOrganizationRole: "member", turnstileSiteKey: "dummy", // not used since isTurnstileConfigured is false } as const; describe("SignupForm", () => { afterEach(() => { cleanup(); + vi.clearAllMocks(); }); - it("toggles the signup form on button click", () => { + test("toggles the signup form on button click", () => { render(); // Initially, the signup form is hidden. @@ -149,7 +148,7 @@ describe("SignupForm", () => { expect(screen.getByTestId("signup-password")).toBeInTheDocument(); }); - it("submits the form successfully", async () => { + test("submits the form successfully", async () => { // Set up mocks for the API actions. vi.mocked(createUserAction).mockResolvedValue({ data: true } as any); vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" }); @@ -179,8 +178,6 @@ describe("SignupForm", () => { userLocale: defaultProps.userLocale, inviteToken: "", emailVerificationDisabled: defaultProps.emailVerificationDisabled, - defaultOrganizationId: defaultProps.defaultOrganizationId, - defaultOrganizationRole: defaultProps.defaultOrganizationRole, turnstileToken: undefined, }); }); @@ -194,7 +191,7 @@ describe("SignupForm", () => { expect(pushMock).toHaveBeenCalledWith("/auth/verification-requested?token=token123"); }); - it("submits the form successfully when turnstile is configured", async () => { + test("submits the form successfully when turnstile is configured", async () => { // Override props to enable Turnstile const props = { ...defaultProps, @@ -233,8 +230,6 @@ describe("SignupForm", () => { userLocale: props.userLocale, inviteToken: "", emailVerificationDisabled: true, - defaultOrganizationId: props.defaultOrganizationId, - defaultOrganizationRole: props.defaultOrganizationRole, turnstileToken: "test-turnstile-token", }); }); @@ -243,10 +238,10 @@ describe("SignupForm", () => { expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" }); }); - expect(pushMock).toHaveBeenCalledWith("/auth/signup-without-verification-success"); + expect(pushMock).toHaveBeenCalledWith("/auth/signup-without-verification-success?token=token123"); }); - it("submits the form successfully when turnstile is configured, but createEmailTokenAction don't return data", async () => { + test("submits the form successfully when turnstile is configured, but createEmailTokenAction don't return data", async () => { // Override props to enable Turnstile const props = { ...defaultProps, @@ -286,8 +281,6 @@ describe("SignupForm", () => { userLocale: props.userLocale, inviteToken: "", emailVerificationDisabled: true, - defaultOrganizationId: props.defaultOrganizationId, - defaultOrganizationRole: props.defaultOrganizationRole, turnstileToken: "test-turnstile-token", }); }); @@ -298,7 +291,7 @@ describe("SignupForm", () => { }); }); - it("shows an error message if turnstile is configured, but no token is received", async () => { + test("shows an error message if turnstile is configured, but no token is received", async () => { // Override props to enable Turnstile const props = { ...defaultProps, @@ -332,7 +325,7 @@ describe("SignupForm", () => { }); }); - it("Invite token is in the search params", async () => { + test("Invite token is in the search params", async () => { // Set up mocks for the API actions vi.mocked(createUserAction).mockResolvedValue({ data: true } as any); vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" }); @@ -362,8 +355,6 @@ describe("SignupForm", () => { userLocale: defaultProps.userLocale, inviteToken: "token123", emailVerificationDisabled: defaultProps.emailVerificationDisabled, - defaultOrganizationId: defaultProps.defaultOrganizationId, - defaultOrganizationRole: defaultProps.defaultOrganizationRole, turnstileToken: undefined, }); }); @@ -374,4 +365,42 @@ describe("SignupForm", () => { expect(pushMock).toHaveBeenCalledWith("/auth/verification-requested?token=token123"); }); + + test("shows an error message when createUserAction fails", async () => { + // Set up mocks for the API actions + vi.mocked(createUserAction).mockResolvedValue(undefined); + vi.mocked(createEmailTokenAction).mockResolvedValue({ data: "token123" }); + vi.mocked(getFormattedErrorMessage).mockReturnValue("user creation failed"); + + render(); + + // Click the button to reveal the signup form + const toggleButton = screen.getByTestId("signup-show-login"); + fireEvent.click(toggleButton); + + // Fill out the form fields + fireEvent.change(screen.getByTestId("signup-name"), { target: { value: "Test User" } }); + fireEvent.change(screen.getByTestId("signup-email"), { target: { value: "test@example.com" } }); + fireEvent.change(screen.getByTestId("signup-password"), { target: { value: "Password123" } }); + + // Submit the form. + const submitButton = screen.getByTestId("signup-submit"); + fireEvent.submit(submitButton); + + await waitFor(() => { + expect(createUserAction).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(createEmailTokenAction).toHaveBeenCalledWith({ email: "test@example.com" }); + }); + + // An error message should be shown. + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("user creation failed"); + }); + + // router.push should not have been called. + expect(pushMock).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/modules/auth/signup/components/signup-form.tsx b/apps/web/modules/auth/signup/components/signup-form.tsx index 08636c0f59f3..e2494dd80fe8 100644 --- a/apps/web/modules/auth/signup/components/signup-form.tsx +++ b/apps/web/modules/auth/signup/components/signup-form.tsx @@ -12,14 +12,12 @@ import { PasswordInput } from "@/modules/ui/components/password-input"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslate } from "@tolgee/react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useMemo, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import Turnstile, { useTurnstile } from "react-turnstile"; import { z } from "zod"; -import { TOrganizationRole } from "@formbricks/types/memberships"; import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user"; import { createEmailTokenAction } from "../../../auth/actions"; import { PasswordChecks } from "./password-checks"; @@ -45,8 +43,6 @@ interface SignupFormProps { userLocale: TUserLocale; emailFromSearchParams?: string; emailVerificationDisabled: boolean; - defaultOrganizationId?: string; - defaultOrganizationRole?: TOrganizationRole; isSsoEnabled: boolean; samlSsoEnabled: boolean; isTurnstileConfigured: boolean; @@ -68,8 +64,6 @@ export const SignupForm = ({ userLocale, emailFromSearchParams, emailVerificationDisabled, - defaultOrganizationId, - defaultOrganizationRole, isSsoEnabled, samlSsoEnabled, isTurnstileConfigured, @@ -114,23 +108,22 @@ export const SignupForm = ({ email: data.email, password: data.password, userLocale, - inviteToken: inviteToken || "", + inviteToken: inviteToken ?? "", emailVerificationDisabled, - defaultOrganizationId, - defaultOrganizationRole, turnstileToken, }); + const emailTokenActionResponse = await createEmailTokenAction({ email: data.email }); + const token = emailTokenActionResponse?.data; + + const url = emailVerificationDisabled + ? `/auth/signup-without-verification-success?token=${token}` + : `/auth/verification-requested?token=${token}`; + if (createUserResponse?.data) { - const emailTokenActionResponse = await createEmailTokenAction({ email: data.email }); - if (emailTokenActionResponse?.data) { - const token = emailTokenActionResponse?.data; - const url = emailVerificationDisabled - ? `/auth/signup-without-verification-success` - : `/auth/verification-requested?token=${token}`; + router.push(url); - router.push(url); - } else { + if (!emailTokenActionResponse?.data) { if (isTurnstileConfigured) { setTurnstileToken(undefined); turnstile.reset(); diff --git a/apps/web/modules/auth/signup/lib/__tests__/__mocks__/team-mocks.ts b/apps/web/modules/auth/signup/lib/__tests__/__mocks__/team-mocks.ts new file mode 100644 index 000000000000..760af818985f --- /dev/null +++ b/apps/web/modules/auth/signup/lib/__tests__/__mocks__/team-mocks.ts @@ -0,0 +1,101 @@ +import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites"; +import { OrganizationRole, Team, TeamUserRole } from "@prisma/client"; + +/** + * Common constants and IDs used across tests + */ +export const MOCK_DATE = new Date("2023-01-01T00:00:00.000Z"); + +export const MOCK_IDS = { + // User IDs + userId: "test-user-id", + + // Team IDs + teamId: "test-team-id", + defaultTeamId: "team-123", + + // Organization IDs + organizationId: "test-org-id", + defaultOrganizationId: "org-123", + + // Project IDs + projectId: "test-project-id", +}; + +/** + * Mock team data structures + */ +export const MOCK_TEAM: { + id: string; + organizationId: string; + projectTeams: { projectId: string }[]; +} = { + id: MOCK_IDS.teamId, + organizationId: MOCK_IDS.organizationId, + projectTeams: [ + { + projectId: MOCK_IDS.projectId, + }, + ], +}; + +export const MOCK_DEFAULT_TEAM: Team = { + id: MOCK_IDS.defaultTeamId, + organizationId: MOCK_IDS.defaultOrganizationId, + name: "Default Team", + createdAt: MOCK_DATE, + updatedAt: MOCK_DATE, +}; + +/** + * Mock membership data + */ +export const MOCK_TEAM_USER = { + teamId: MOCK_IDS.teamId, + userId: MOCK_IDS.userId, + role: "admin" as TeamUserRole, + createdAt: MOCK_DATE, + updatedAt: MOCK_DATE, +}; + +export const MOCK_DEFAULT_TEAM_USER = { + teamId: MOCK_IDS.defaultTeamId, + userId: MOCK_IDS.userId, + role: "admin" as TeamUserRole, + createdAt: MOCK_DATE, + updatedAt: MOCK_DATE, +}; + +/** + * Mock invitation data + */ +export const MOCK_INVITE: CreateMembershipInvite = { + organizationId: MOCK_IDS.organizationId, + role: "owner" as OrganizationRole, + teamIds: [MOCK_IDS.teamId], +}; + +export const MOCK_ORGANIZATION_MEMBERSHIP = { + userId: MOCK_IDS.userId, + role: "owner" as OrganizationRole, + organizationId: MOCK_IDS.defaultOrganizationId, + accepted: true, +}; + +/** + * Factory functions for creating test data with custom overrides + */ +export const createMockTeam = (overrides = {}) => ({ + ...MOCK_TEAM, + ...overrides, +}); + +export const createMockTeamUser = (overrides = {}) => ({ + ...MOCK_TEAM_USER, + ...overrides, +}); + +export const createMockInvite = (overrides = {}) => ({ + ...MOCK_INVITE, + ...overrides, +}); diff --git a/apps/web/modules/auth/signup/lib/__tests__/team.test.ts b/apps/web/modules/auth/signup/lib/__tests__/team.test.ts new file mode 100644 index 000000000000..e622a2c5e721 --- /dev/null +++ b/apps/web/modules/auth/signup/lib/__tests__/team.test.ts @@ -0,0 +1,120 @@ +import { MOCK_IDS, MOCK_INVITE, MOCK_TEAM, MOCK_TEAM_USER } from "./__mocks__/team-mocks"; +import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites"; +import { OrganizationRole } from "@prisma/client"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { createTeamMembership } from "../team"; + +// Setup all mocks +const setupMocks = () => { + // Mock dependencies + vi.mock("@formbricks/database", () => ({ + prisma: { + team: { + findUnique: vi.fn(), + }, + teamUser: { + create: vi.fn(), + }, + }, + })); + + vi.mock("@/lib/constants", () => ({ + DEFAULT_TEAM_ID: "team-123", + DEFAULT_ORGANIZATION_ID: "org-123", + })); + + vi.mock("@/lib/membership/service", () => ({ + getMembershipByUserIdOrganizationId: vi.fn(), + })); + + vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, + })); + + // Mock reactCache to control the getDefaultTeam function + vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { + ...actual, + cache: vi.fn().mockImplementation((fn) => fn), + }; + }); +}; + +// Set up mocks +setupMocks(); + +describe("Team Management", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createTeamMembership", () => { + describe("when user is an admin", () => { + test("creates a team membership with admin role", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM); + vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER); + + await createTeamMembership(MOCK_INVITE, MOCK_IDS.userId); + expect(prisma.team.findUnique).toHaveBeenCalledWith({ + where: { + id: MOCK_IDS.teamId, + organizationId: MOCK_IDS.organizationId, + }, + select: { + projectTeams: { + select: { + projectId: true, + }, + }, + }, + }); + + expect(prisma.teamUser.create).toHaveBeenCalledWith({ + data: { + teamId: MOCK_IDS.teamId, + userId: MOCK_IDS.userId, + role: "admin", + }, + }); + }); + }); + + describe("when user is not an admin", () => { + test("creates a team membership with contributor role", async () => { + const nonAdminInvite: CreateMembershipInvite = { + ...MOCK_INVITE, + role: "member" as OrganizationRole, + }; + + vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM); + vi.mocked(prisma.teamUser.create).mockResolvedValue({ + ...MOCK_TEAM_USER, + role: "contributor", + }); + + await createTeamMembership(nonAdminInvite, MOCK_IDS.userId); + + expect(prisma.teamUser.create).toHaveBeenCalledWith({ + data: { + teamId: MOCK_IDS.teamId, + userId: MOCK_IDS.userId, + role: "contributor", + }, + }); + }); + }); + + describe("error handling", () => { + test("throws error when database operation fails", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM); + vi.mocked(prisma.teamUser.create).mockRejectedValue(new Error("Database error")); + + await expect(createTeamMembership(MOCK_INVITE, MOCK_IDS.userId)).rejects.toThrow("Database error"); + }); + }); + }); +}); diff --git a/apps/web/modules/auth/signup/lib/invite.test.ts b/apps/web/modules/auth/signup/lib/invite.test.ts index e2628d8aed4b..2a2bea05837b 100644 --- a/apps/web/modules/auth/signup/lib/invite.test.ts +++ b/apps/web/modules/auth/signup/lib/invite.test.ts @@ -1,6 +1,5 @@ -import { inviteCache } from "@/lib/cache/invite"; import { Prisma } from "@prisma/client"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; import { logger } from "@formbricks/logger"; @@ -40,16 +39,6 @@ vi.mock("@formbricks/database", () => ({ }, })); -// Mock cache -vi.mock("@/lib/cache/invite", () => ({ - inviteCache: { - revalidate: vi.fn(), - tag: { - byId: (id: string) => `invite-${id}`, - }, - }, -})); - // Mock logger vi.mock("@formbricks/logger", () => ({ logger: { @@ -63,7 +52,7 @@ describe("Invite Management", () => { }); describe("deleteInvite", () => { - it("deletes an invite successfully and invalidates cache", async () => { + test("deletes an invite successfully and invalidates cache", async () => { vi.mocked(prisma.invite.delete).mockResolvedValue(mockInvite); const result = await deleteInvite(mockInviteId); @@ -73,13 +62,9 @@ describe("Invite Management", () => { where: { id: mockInviteId }, select: { id: true, organizationId: true }, }); - expect(inviteCache.revalidate).toHaveBeenCalledWith({ - id: mockInviteId, - organizationId: mockOrganizationId, - }); }); - it("throws DatabaseError when invite doesn't exist", async () => { + test("throws DatabaseError when invite doesn't exist", async () => { const errToThrow = new Prisma.PrismaClientKnownRequestError("Record not found", { code: PrismaErrorType.RecordDoesNotExist, clientVersion: "0.0.1", @@ -89,7 +74,7 @@ describe("Invite Management", () => { await expect(deleteInvite(mockInviteId)).rejects.toThrow(DatabaseError); }); - it("throws DatabaseError for other Prisma errors", async () => { + test("throws DatabaseError for other Prisma errors", async () => { const errToThrow = new Prisma.PrismaClientKnownRequestError("Database error", { code: "P2002", clientVersion: "0.0.1", @@ -99,7 +84,7 @@ describe("Invite Management", () => { await expect(deleteInvite(mockInviteId)).rejects.toThrow(DatabaseError); }); - it("throws DatabaseError for generic errors", async () => { + test("throws DatabaseError for generic errors", async () => { vi.mocked(prisma.invite.delete).mockRejectedValue(new Error("Generic error")); await expect(deleteInvite(mockInviteId)).rejects.toThrow(DatabaseError); @@ -107,7 +92,7 @@ describe("Invite Management", () => { }); describe("getInvite", () => { - it("retrieves an invite with creator details successfully", async () => { + test("retrieves an invite with creator details successfully", async () => { vi.mocked(prisma.invite.findUnique).mockResolvedValue(mockInvite); const result = await getInvite(mockInviteId); @@ -131,7 +116,7 @@ describe("Invite Management", () => { }); }); - it("returns null when invite doesn't exist", async () => { + test("returns null when invite doesn't exist", async () => { vi.mocked(prisma.invite.findUnique).mockResolvedValue(null); const result = await getInvite(mockInviteId); @@ -139,7 +124,7 @@ describe("Invite Management", () => { expect(result).toBeNull(); }); - it("throws DatabaseError on prisma error", async () => { + test("throws DatabaseError on prisma error", async () => { const errToThrow = new Prisma.PrismaClientKnownRequestError("Database error", { code: "P2002", clientVersion: "0.0.1", @@ -149,7 +134,7 @@ describe("Invite Management", () => { await expect(getInvite(mockInviteId)).rejects.toThrow(DatabaseError); }); - it("throws DatabaseError for generic errors", async () => { + test("throws DatabaseError for generic errors", async () => { vi.mocked(prisma.invite.findUnique).mockRejectedValue(new Error("Generic error")); await expect(getInvite(mockInviteId)).rejects.toThrow(DatabaseError); @@ -157,7 +142,7 @@ describe("Invite Management", () => { }); describe("getIsValidInviteToken", () => { - it("returns true for valid invite", async () => { + test("returns true for valid invite", async () => { vi.mocked(prisma.invite.findUnique).mockResolvedValue(mockInvite); const result = await getIsValidInviteToken(mockInviteId); @@ -168,7 +153,7 @@ describe("Invite Management", () => { }); }); - it("returns false when invite doesn't exist", async () => { + test("returns false when invite doesn't exist", async () => { vi.mocked(prisma.invite.findUnique).mockResolvedValue(null); const result = await getIsValidInviteToken(mockInviteId); @@ -176,7 +161,7 @@ describe("Invite Management", () => { expect(result).toBe(false); }); - it("returns false for expired invite", async () => { + test("returns false for expired invite", async () => { const expiredInvite = { ...mockInvite, expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24 hours ago @@ -195,7 +180,7 @@ describe("Invite Management", () => { ); }); - it("returns false and logs error when database error occurs", async () => { + test("returns false and logs error when database error occurs", async () => { const error = new Error("Database error"); vi.mocked(prisma.invite.findUnique).mockRejectedValue(error); @@ -205,7 +190,7 @@ describe("Invite Management", () => { expect(logger.error).toHaveBeenCalledWith(error, "Error getting invite"); }); - it("returns false for invite with null expiresAt", async () => { + test("returns false for invite with null expiresAt", async () => { const invalidInvite = { ...mockInvite, expiresAt: null, @@ -224,7 +209,7 @@ describe("Invite Management", () => { ); }); - it("returns false for invite with invalid expiresAt", async () => { + test("returns false for invite with invalid expiresAt", async () => { const invalidInvite = { ...mockInvite, expiresAt: new Date("invalid-date"), diff --git a/apps/web/modules/auth/signup/lib/invite.ts b/apps/web/modules/auth/signup/lib/invite.ts index fd879abbefd4..76faf50ebaad 100644 --- a/apps/web/modules/auth/signup/lib/invite.ts +++ b/apps/web/modules/auth/signup/lib/invite.ts @@ -1,9 +1,7 @@ -import { inviteCache } from "@/lib/cache/invite"; import { InviteWithCreator } from "@/modules/auth/signup/types/invites"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { cache } from "@formbricks/lib/cache"; import { logger } from "@formbricks/logger"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; @@ -23,11 +21,6 @@ export const deleteInvite = async (inviteId: string): Promise => { throw new ResourceNotFoundError("Invite", inviteId); } - inviteCache.revalidate({ - id: invite.id, - organizationId: invite.organizationId, - }); - return true; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -37,85 +30,67 @@ export const deleteInvite = async (inviteId: string): Promise => { } }; -export const getInvite = reactCache( - async (inviteId: string): Promise => - cache( - async () => { - try { - const invite = await prisma.invite.findUnique({ - where: { - id: inviteId, - }, - select: { - id: true, - organizationId: true, - role: true, - teamIds: true, - creator: { - select: { - name: true, - email: true, - locale: true, - }, - }, - }, - }); - - return invite; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - throw new DatabaseError(error instanceof Error ? error.message : "Unknown error occurred"); - } +export const getInvite = reactCache(async (inviteId: string): Promise => { + try { + const invite = await prisma.invite.findUnique({ + where: { + id: inviteId, }, - [`signup-getInvite-${inviteId}`], - { - tags: [inviteCache.tag.byId(inviteId)], - } - )() -); - -export const getIsValidInviteToken = reactCache( - async (inviteId: string): Promise => - cache( - async () => { - try { - const invite = await prisma.invite.findUnique({ - where: { id: inviteId }, - }); - if (!invite) { - return false; - } - if (!invite.expiresAt || isNaN(invite.expiresAt.getTime())) { - logger.error( - { - inviteId, - expiresAt: invite.expiresAt, - }, - "SSO: Invite token expired" - ); - return false; - } - if (invite.expiresAt < new Date()) { - logger.error( - { - inviteId, - expiresAt: invite.expiresAt, - }, - "SSO: Invite token expired" - ); - return false; - } - return true; - } catch (err) { - logger.error(err, "Error getting invite"); - return false; - } + select: { + id: true, + organizationId: true, + role: true, + teamIds: true, + creator: { + select: { + name: true, + email: true, + locale: true, + }, + }, }, - [`getIsValidInviteToken-${inviteId}`], - { - tags: [inviteCache.tag.byId(inviteId)], - } - )() -); + }); + + return invite; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw new DatabaseError(error instanceof Error ? error.message : "Unknown error occurred"); + } +}); + +export const getIsValidInviteToken = reactCache(async (inviteId: string): Promise => { + try { + const invite = await prisma.invite.findUnique({ + where: { id: inviteId }, + }); + if (!invite) { + return false; + } + if (!invite.expiresAt || isNaN(invite.expiresAt.getTime())) { + logger.error( + { + inviteId, + expiresAt: invite.expiresAt, + }, + "SSO: Invite token expired" + ); + return false; + } + if (invite.expiresAt < new Date()) { + logger.error( + { + inviteId, + expiresAt: invite.expiresAt, + }, + "SSO: Invite token expired" + ); + return false; + } + return true; + } catch (err) { + logger.error(err, "Error getting invite"); + return false; + } +}); diff --git a/apps/web/modules/auth/signup/lib/team.ts b/apps/web/modules/auth/signup/lib/team.ts index d3564a1512b7..df18080f1cae 100644 --- a/apps/web/modules/auth/signup/lib/team.ts +++ b/apps/web/modules/auth/signup/lib/team.ts @@ -1,35 +1,22 @@ import "server-only"; -import { teamCache } from "@/lib/cache/team"; +import { getAccessFlags } from "@/lib/membership/utils"; import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites"; import { Prisma } from "@prisma/client"; +import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { getAccessFlags } from "@formbricks/lib/membership/utils"; -import { projectCache } from "@formbricks/lib/project/cache"; +import { logger } from "@formbricks/logger"; import { DatabaseError } from "@formbricks/types/errors"; export const createTeamMembership = async (invite: CreateMembershipInvite, userId: string): Promise => { const teamIds = invite.teamIds || []; + const userMembershipRole = invite.role; const { isOwner, isManager } = getAccessFlags(userMembershipRole); - const validTeamIds: string[] = []; - const validProjectIds: string[] = []; - const isOwnerOrManager = isOwner || isManager; try { for (const teamId of teamIds) { - const team = await prisma.team.findUnique({ - where: { - id: teamId, - }, - select: { - projectTeams: { - select: { - projectId: true, - }, - }, - }, - }); + const team = await getTeamProjectIds(teamId, invite.organizationId); if (team) { await prisma.teamUser.create({ @@ -39,23 +26,10 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI role: isOwnerOrManager ? "admin" : "contributor", }, }); - - validTeamIds.push(teamId); - validProjectIds.push(...team.projectTeams.map((pt) => pt.projectId)); } } - - for (const projectId of validProjectIds) { - teamCache.revalidate({ id: projectId }); - } - - for (const teamId of validTeamIds) { - teamCache.revalidate({ id: teamId }); - } - - teamCache.revalidate({ userId, organizationId: invite.organizationId }); - projectCache.revalidate({ userId, organizationId: invite.organizationId }); } catch (error) { + logger.error(error, `Error creating team membership ${invite.organizationId} ${userId}`); if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError(error.message); } @@ -63,3 +37,27 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI throw error; } }; + +export const getTeamProjectIds = reactCache( + async (teamId: string, organizationId: string): Promise<{ projectTeams: { projectId: string }[] }> => { + const team = await prisma.team.findUnique({ + where: { + id: teamId, + organizationId, + }, + select: { + projectTeams: { + select: { + projectId: true, + }, + }, + }, + }); + + if (!team) { + throw new Error("Team not found"); + } + + return team; + } +); diff --git a/apps/web/modules/auth/signup/lib/utils.test.ts b/apps/web/modules/auth/signup/lib/utils.test.ts index 6564a213e580..4bf22150dda2 100644 --- a/apps/web/modules/auth/signup/lib/utils.test.ts +++ b/apps/web/modules/auth/signup/lib/utils.test.ts @@ -1,5 +1,5 @@ import posthog from "posthog-js"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { captureFailedSignup, verifyTurnstileToken } from "./utils"; beforeEach(() => { @@ -16,7 +16,7 @@ describe("verifyTurnstileToken", () => { const secretKey = "test-secret"; const token = "test-token"; - it("should return true when verification is successful", async () => { + test("should return true when verification is successful", async () => { const mockResponse = { success: true }; (global.fetch as any).mockResolvedValue({ ok: true, @@ -36,7 +36,7 @@ describe("verifyTurnstileToken", () => { ); }); - it("should return false when response is not ok", async () => { + test("should return false when response is not ok", async () => { (global.fetch as any).mockResolvedValue({ ok: false, status: 400, @@ -46,14 +46,14 @@ describe("verifyTurnstileToken", () => { expect(result).toBe(false); }); - it("should return false when verification fails", async () => { + test("should return false when verification fails", async () => { (global.fetch as any).mockRejectedValue(new Error("Network error")); const result = await verifyTurnstileToken(secretKey, token); expect(result).toBe(false); }); - it("should return false when request times out", async () => { + test("should return false when request times out", async () => { const mockAbortError = new Error("The operation was aborted"); mockAbortError.name = "AbortError"; (global.fetch as any).mockRejectedValue(mockAbortError); @@ -64,7 +64,7 @@ describe("verifyTurnstileToken", () => { }); describe("captureFailedSignup", () => { - it("should capture TELEMETRY_FAILED_SIGNUP event with email and name", () => { + test("should capture TELEMETRY_FAILED_SIGNUP event with email and name", () => { const captureSpy = vi.spyOn(posthog, "capture"); const email = "test@example.com"; const name = "Test User"; diff --git a/apps/web/modules/auth/signup/page.test.tsx b/apps/web/modules/auth/signup/page.test.tsx index 88d4ac18f1f9..06bd5d134613 100644 --- a/apps/web/modules/auth/signup/page.test.tsx +++ b/apps/web/modules/auth/signup/page.test.tsx @@ -1,15 +1,15 @@ +import { verifyInviteToken } from "@/lib/jwt"; +import { findMatchingLocale } from "@/lib/utils/locale"; import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite"; import { getIsMultiOrgEnabled, getIsSamlSsoEnabled, - getisSsoEnabled, + getIsSsoEnabled, } from "@/modules/ee/license-check/lib/utils"; import "@testing-library/jest-dom/vitest"; import { cleanup, render, screen } from "@testing-library/react"; import { notFound } from "next/navigation"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { verifyInviteToken } from "@formbricks/lib/jwt"; -import { findMatchingLocale } from "@formbricks/lib/utils/locale"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { SignupPage } from "./page"; // Mock the necessary dependencies @@ -29,19 +29,25 @@ vi.mock("@/modules/auth/signup/components/signup-form", () => ({ vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsMultiOrgEnabled: vi.fn(), + getIsSsoEnabled: vi.fn(), getIsSamlSsoEnabled: vi.fn(), - getisSsoEnabled: vi.fn(), + getIsWhitelabelEnabled: vi.fn(), + getIsRemoveBrandingEnabled: vi.fn(), + getIsContactsEnabled: vi.fn(), + getIsAiEnabled: vi.fn(), + getIsSpamProtectionEnabled: vi.fn(), + getIsPendingDowngrade: vi.fn(), })); vi.mock("@/modules/auth/signup/lib/invite", () => ({ getIsValidInviteToken: vi.fn(), })); -vi.mock("@formbricks/lib/jwt", () => ({ +vi.mock("@/lib/jwt", () => ({ verifyInviteToken: vi.fn(), })); -vi.mock("@formbricks/lib/utils/locale", () => ({ +vi.mock("@/lib/utils/locale", () => ({ findMatchingLocale: vi.fn(), })); @@ -50,7 +56,7 @@ vi.mock("next/navigation", () => ({ })); // Mock environment variables and constants -vi.mock("@formbricks/lib/constants", () => ({ +vi.mock("@/lib/constants", () => ({ IS_FORMBRICKS_CLOUD: false, POSTHOG_API_KEY: "mock-posthog-api-key", POSTHOG_HOST: "mock-posthog-host", @@ -89,7 +95,6 @@ vi.mock("@formbricks/lib/constants", () => ({ AZURE_OAUTH_ENABLED: true, OIDC_OAUTH_ENABLED: true, DEFAULT_ORGANIZATION_ID: "test-default-organization-id", - DEFAULT_ORGANIZATION_ROLE: "test-default-organization-role", IS_TURNSTILE_CONFIGURED: true, SAML_TENANT: "test-saml-tenant", SAML_PRODUCT: "test-saml-product", @@ -111,10 +116,10 @@ describe("SignupPage", () => { cleanup(); }); - it("renders the signup page with all components when signup is enabled", async () => { + test("renders the signup page with all components when signup is enabled", async () => { // Mock the license check functions to return true vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); - vi.mocked(getisSsoEnabled).mockResolvedValue(true); + vi.mocked(getIsSsoEnabled).mockResolvedValue(true); vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true); vi.mocked(findMatchingLocale).mockResolvedValue("en-US"); vi.mocked(verifyInviteToken).mockReturnValue({ @@ -132,7 +137,7 @@ describe("SignupPage", () => { expect(screen.getByTestId("signup-form")).toBeInTheDocument(); }); - it("calls notFound when signup is disabled and no valid invite token is provided", async () => { + test("calls notFound when signup is disabled and no valid invite token is provided", async () => { // Mock the license check functions to return false vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); vi.mocked(verifyInviteToken).mockImplementation(() => { @@ -144,7 +149,7 @@ describe("SignupPage", () => { expect(notFound).toHaveBeenCalled(); }); - it("calls notFound when invite token is invalid", async () => { + test("calls notFound when invite token is invalid", async () => { // Mock the license check functions to return false vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); vi.mocked(verifyInviteToken).mockImplementation(() => { @@ -156,7 +161,7 @@ describe("SignupPage", () => { expect(notFound).toHaveBeenCalled(); }); - it("calls notFound when invite token is valid but invite is not found", async () => { + test("calls notFound when invite token is valid but invite is not found", async () => { // Mock the license check functions to return false vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); vi.mocked(verifyInviteToken).mockReturnValue({ @@ -170,10 +175,10 @@ describe("SignupPage", () => { expect(notFound).toHaveBeenCalled(); }); - it("renders the page with email from search params", async () => { + test("renders the page with email from search params", async () => { // Mock the license check functions to return true vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); - vi.mocked(getisSsoEnabled).mockResolvedValue(true); + vi.mocked(getIsSsoEnabled).mockResolvedValue(true); vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true); vi.mocked(findMatchingLocale).mockResolvedValue("en-US"); vi.mocked(verifyInviteToken).mockReturnValue({ diff --git a/apps/web/modules/auth/signup/page.tsx b/apps/web/modules/auth/signup/page.tsx index 3d8f2c2fbc87..bb7780285105 100644 --- a/apps/web/modules/auth/signup/page.tsx +++ b/apps/web/modules/auth/signup/page.tsx @@ -1,16 +1,5 @@ -import { FormWrapper } from "@/modules/auth/components/form-wrapper"; -import { Testimonial } from "@/modules/auth/components/testimonial"; -import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite"; -import { - getIsMultiOrgEnabled, - getIsSamlSsoEnabled, - getisSsoEnabled, -} from "@/modules/ee/license-check/lib/utils"; -import { notFound } from "next/navigation"; import { AZURE_OAUTH_ENABLED, - DEFAULT_ORGANIZATION_ID, - DEFAULT_ORGANIZATION_ROLE, EMAIL_AUTH_ENABLED, EMAIL_VERIFICATION_DISABLED, GITHUB_OAUTH_ENABLED, @@ -26,9 +15,18 @@ import { TERMS_URL, TURNSTILE_SITE_KEY, WEBAPP_URL, -} from "@formbricks/lib/constants"; -import { verifyInviteToken } from "@formbricks/lib/jwt"; -import { findMatchingLocale } from "@formbricks/lib/utils/locale"; +} from "@/lib/constants"; +import { verifyInviteToken } from "@/lib/jwt"; +import { findMatchingLocale } from "@/lib/utils/locale"; +import { FormWrapper } from "@/modules/auth/components/form-wrapper"; +import { Testimonial } from "@/modules/auth/components/testimonial"; +import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite"; +import { + getIsMultiOrgEnabled, + getIsSamlSsoEnabled, + getIsSsoEnabled, +} from "@/modules/ee/license-check/lib/utils"; +import { notFound } from "next/navigation"; import { SignupForm } from "./components/signup-form"; export const SignupPage = async ({ searchParams: searchParamsProps }) => { @@ -36,7 +34,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => { const inviteToken = searchParams["inviteToken"] ?? null; const [isMultOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([ getIsMultiOrgEnabled(), - getisSsoEnabled(), + getIsSsoEnabled(), getIsSamlSsoEnabled(), ]); @@ -77,8 +75,6 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => { oidcDisplayName={OIDC_DISPLAY_NAME} userLocale={locale} emailFromSearchParams={emailFromSearchParams} - defaultOrganizationId={DEFAULT_ORGANIZATION_ID} - defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE} isSsoEnabled={isSsoEnabled} samlSsoEnabled={samlSsoEnabled} isTurnstileConfigured={IS_TURNSTILE_CONFIGURED} diff --git a/apps/web/modules/auth/verification-requested/actions.test.ts b/apps/web/modules/auth/verification-requested/actions.test.ts new file mode 100644 index 000000000000..f9d329c5a961 --- /dev/null +++ b/apps/web/modules/auth/verification-requested/actions.test.ts @@ -0,0 +1,344 @@ +import { getUserByEmail } from "@/modules/auth/lib/user"; +// Import mocked functions +import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; +import { sendVerificationEmail } from "@/modules/email"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { resendVerificationEmailAction } from "./actions"; + +// Mock dependencies +vi.mock("@/modules/core/rate-limit/helpers", () => ({ + applyIPRateLimit: vi.fn(), +})); + +vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({ + rateLimitConfigs: { + auth: { + verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" }, + }, + }, +})); + +vi.mock("@/modules/auth/lib/user", () => ({ + getUserByEmail: vi.fn(), +})); + +vi.mock("@/modules/email", () => ({ + sendVerificationEmail: vi.fn(), +})); + +vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ + withAuditLogging: vi.fn((type, object, fn) => fn), +})); + +vi.mock("@/lib/utils/action-client", () => ({ + actionClient: { + schema: vi.fn().mockReturnThis(), + action: vi.fn((fn) => fn), + }, +})); + +describe("resendVerificationEmailAction", () => { + const validInput = { + email: "test@example.com", + }; + + const mockUser = { + id: "user123", + email: "test@example.com", + emailVerified: null, // Not verified + name: "Test User", + }; + + const mockVerifiedUser = { + id: "user123", + email: "test@example.com", + emailVerified: new Date(), + name: "Test User", + }; + + const mockCtx = { + auditLoggingCtx: { + organizationId: "", + userId: "", + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Rate Limiting", () => { + test("should apply rate limiting before processing verification email resend", async () => { + vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any); + + await resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: validInput, + } as any); + + expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail); + expect(applyIPRateLimit).toHaveBeenCalledBefore(getUserByEmail as any); + }); + + test("should throw rate limit error when limit exceeded", async () => { + vi.mocked(applyIPRateLimit).mockRejectedValue( + new Error("Maximum number of requests reached. Please try again later.") + ); + + await expect( + resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: validInput, + } as any) + ).rejects.toThrow("Maximum number of requests reached. Please try again later."); + + expect(getUserByEmail).not.toHaveBeenCalled(); + expect(sendVerificationEmail).not.toHaveBeenCalled(); + }); + + test("should use correct rate limit configuration", async () => { + vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any); + + await resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: validInput, + } as any); + + expect(applyIPRateLimit).toHaveBeenCalledWith({ + interval: 3600, + allowedPerInterval: 10, + namespace: "auth:verify", + }); + }); + + test("should apply rate limiting even when user doesn't exist", async () => { + vi.mocked(getUserByEmail).mockResolvedValue(null); + + await expect( + resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: validInput, + } as any) + ).rejects.toThrow(ResourceNotFoundError); + + expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail); + }); + }); + + describe("Verification Email Resend Flow", () => { + test("should send verification email when user exists and email is not verified", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any); + + const result = await resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: validInput, + } as any); + + expect(applyIPRateLimit).toHaveBeenCalled(); + expect(getUserByEmail).toHaveBeenCalledWith(validInput.email); + expect(sendVerificationEmail).toHaveBeenCalledWith(mockUser); + expect(result).toEqual({ success: true }); + }); + + test("should return success without sending email when user email is already verified", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(mockVerifiedUser as any); + + const result = await resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: validInput, + } as any); + + expect(applyIPRateLimit).toHaveBeenCalled(); + expect(getUserByEmail).toHaveBeenCalledWith(validInput.email); + expect(sendVerificationEmail).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + test("should throw ResourceNotFoundError when user doesn't exist", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(null); + + await expect( + resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: validInput, + } as any) + ).rejects.toThrow(ResourceNotFoundError); + + expect(applyIPRateLimit).toHaveBeenCalled(); + expect(getUserByEmail).toHaveBeenCalledWith(validInput.email); + expect(sendVerificationEmail).not.toHaveBeenCalled(); + }); + }); + + describe("Audit Logging", () => { + test("should be wrapped with audit logging decorator", () => { + // withAuditLogging is called at module load time to wrap the action + // We just verify the mock was set up correctly + expect(withAuditLogging).toBeDefined(); + }); + + test("should set audit context userId when sending verification email", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any); + + const testCtx = { + auditLoggingCtx: { + organizationId: "", + userId: "", + }, + }; + + await resendVerificationEmailAction({ + ctx: testCtx, + parsedInput: validInput, + } as any); + + // The userId should be set in the audit context + expect(testCtx.auditLoggingCtx.userId).toBe(mockUser.id); + }); + + test("should not set audit context userId when email is already verified", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(mockVerifiedUser as any); + + const testCtx = { + auditLoggingCtx: { + organizationId: "", + userId: "", + }, + }; + + await resendVerificationEmailAction({ + ctx: testCtx, + parsedInput: validInput, + } as any); + + // The userId should not be set since no email was sent + expect(testCtx.auditLoggingCtx.userId).toBe(""); + }); + }); + + describe("Error Handling", () => { + test("should propagate rate limiting errors", async () => { + const rateLimitError = new Error("Maximum number of requests reached. Please try again later."); + vi.mocked(applyIPRateLimit).mockRejectedValue(rateLimitError); + + await expect( + resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: validInput, + } as any) + ).rejects.toThrow("Maximum number of requests reached. Please try again later."); + }); + + test("should handle user lookup errors after rate limiting", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockRejectedValue(new Error("Database error")); + + await expect( + resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: validInput, + } as any) + ).rejects.toThrow("Database error"); + + expect(applyIPRateLimit).toHaveBeenCalled(); + }); + + test("should handle email sending errors after rate limiting", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any); + vi.mocked(sendVerificationEmail).mockRejectedValue(new Error("Email service error")); + + await expect( + resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: validInput, + } as any) + ).rejects.toThrow("Email service error"); + + expect(applyIPRateLimit).toHaveBeenCalled(); + expect(getUserByEmail).toHaveBeenCalled(); + }); + }); + + describe("Input Validation", () => { + test("should handle empty email input", async () => { + const invalidInput = { email: "" }; + + // This would be caught by the Zod schema validation in the actual action + // but we test the behavior if it somehow gets through + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(null); + + await expect( + resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: invalidInput, + } as any) + ).rejects.toThrow(ResourceNotFoundError); + }); + + test("should handle malformed email input", async () => { + const invalidInput = { email: "invalid-email" }; + + // This would be caught by the Zod schema validation in the actual action + // but we test the behavior if it somehow gets through + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(null); + + await expect( + resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: invalidInput, + } as any) + ).rejects.toThrow(ResourceNotFoundError); + }); + }); + + describe("Security Considerations", () => { + test("should always apply rate limiting regardless of user existence", async () => { + vi.mocked(getUserByEmail).mockResolvedValue(null); + + await expect( + resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: validInput, + } as any) + ).rejects.toThrow(ResourceNotFoundError); + + expect(applyIPRateLimit).toHaveBeenCalled(); + }); + + test("should not leak information about user existence through different error messages", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue(); + vi.mocked(getUserByEmail).mockResolvedValue(null); + + // Both non-existent users should throw the same ResourceNotFoundError + await expect( + resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: validInput, + } as any) + ).rejects.toThrow(ResourceNotFoundError); + + const anotherEmail = { email: "another@example.com" }; + await expect( + resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: anotherEmail, + } as any) + ).rejects.toThrow(ResourceNotFoundError); + }); + }); +}); diff --git a/apps/web/modules/auth/verification-requested/actions.ts b/apps/web/modules/auth/verification-requested/actions.ts index 87acd750020e..485147e38bfb 100644 --- a/apps/web/modules/auth/verification-requested/actions.ts +++ b/apps/web/modules/auth/verification-requested/actions.ts @@ -1,25 +1,41 @@ "use server"; import { actionClient } from "@/lib/utils/action-client"; +import { ActionClientCtx } from "@/lib/utils/action-client/types/context"; import { getUserByEmail } from "@/modules/auth/lib/user"; +import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { sendVerificationEmail } from "@/modules/email"; import { z } from "zod"; -import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ZUserEmail } from "@formbricks/types/user"; const ZResendVerificationEmailAction = z.object({ email: ZUserEmail, }); -export const resendVerificationEmailAction = actionClient - .schema(ZResendVerificationEmailAction) - .action(async ({ parsedInput }) => { - const user = await getUserByEmail(parsedInput.email); - if (!user) { - throw new ResourceNotFoundError("user", parsedInput.email); - } - if (user.emailVerified) { - throw new InvalidInputError("Email address has already been verified"); +export const resendVerificationEmailAction = actionClient.schema(ZResendVerificationEmailAction).action( + withAuditLogging( + "verificationEmailSent", + "user", + async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record }) => { + await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail); + + const user = await getUserByEmail(parsedInput.email); + if (!user) { + throw new ResourceNotFoundError("user", parsedInput.email); + } + if (user.emailVerified) { + return { + success: true, + }; + } + ctx.auditLoggingCtx.userId = user.id; + await sendVerificationEmail(user); + return { + success: true, + }; } - return await sendVerificationEmail(user); - }); + ) +); diff --git a/apps/web/modules/auth/verification-requested/components/request-verification-email.test.tsx b/apps/web/modules/auth/verification-requested/components/request-verification-email.test.tsx new file mode 100644 index 000000000000..89117806d84a --- /dev/null +++ b/apps/web/modules/auth/verification-requested/components/request-verification-email.test.tsx @@ -0,0 +1,81 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import toast from "react-hot-toast"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { resendVerificationEmailAction } from "../actions"; +import { RequestVerificationEmail } from "./request-verification-email"; + +// Mock dependencies +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string, params?: { email?: string }) => { + if (key === "auth.verification-requested.no_email_provided") { + return "No email provided"; + } + 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"; + } + return key; + }, + }), +})); + +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("../actions", () => ({ + resendVerificationEmailAction: vi.fn(), +})); + +describe("RequestVerificationEmail", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders resend verification email button", () => { + render(); + expect(screen.getByText("Resend verification email")).toBeInTheDocument(); + }); + + test("shows error toast when no email is provided", async () => { + render(); + const button = screen.getByText("Resend verification email"); + await fireEvent.click(button); + expect(toast.error).toHaveBeenCalledWith("No email provided"); + }); + + test("shows success toast when verification email is sent successfully", async () => { + const mockEmail = "test@example.com"; + vi.mocked(resendVerificationEmailAction).mockResolvedValueOnce({ data: true }); + + render(); + const button = screen.getByText("Resend verification email"); + await fireEvent.click(button); + + expect(resendVerificationEmailAction).toHaveBeenCalledWith({ email: mockEmail }); + expect(toast.success).toHaveBeenCalledWith(`Verification email sent! Please check your inbox.`); + }); + + test("reloads page when visibility changes to visible", () => { + const mockReload = vi.fn(); + Object.defineProperty(window, "location", { + value: { reload: mockReload }, + writable: true, + }); + + render(); + + // Simulate visibility change + document.dispatchEvent(new Event("visibilitychange")); + + expect(mockReload).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/auth/verification-requested/components/request-verification-email.tsx b/apps/web/modules/auth/verification-requested/components/request-verification-email.tsx index 0e8f9d672c24..8bc1fa0537a3 100644 --- a/apps/web/modules/auth/verification-requested/components/request-verification-email.tsx +++ b/apps/web/modules/auth/verification-requested/components/request-verification-email.tsx @@ -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")); + toast.success(t("auth.verification-requested.verification_email_resent_successfully")); } else { const errorMessage = getFormattedErrorMessage(response); toast.error(errorMessage); diff --git a/apps/web/modules/auth/verification-requested/page.test.tsx b/apps/web/modules/auth/verification-requested/page.test.tsx new file mode 100644 index 000000000000..345e23abb9b7 --- /dev/null +++ b/apps/web/modules/auth/verification-requested/page.test.tsx @@ -0,0 +1,138 @@ +import { getEmailFromEmailToken } from "@/lib/jwt"; +import { VerificationRequestedPage } from "@/modules/auth/verification-requested/page"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("@/lib/jwt", () => ({ + getEmailFromEmailToken: vi.fn(), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +vi.mock("@/tolgee/server", () => ({ + getTranslate: async () => (key: string) => key, + T: ({ keyName, params }) => { + if (params && params.email) { + return `${keyName} ${params.email}`; + } + return keyName; + }, +})); + +vi.mock("@/lib/constants", () => ({ + INTERCOM_SECRET_KEY: "test-secret-key", + IS_INTERCOM_CONFIGURED: true, + INTERCOM_APP_ID: "test-app-id", + ENCRYPTION_KEY: "test-encryption-key", + ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key", + GITHUB_ID: "test-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "test-webapp-url", + IS_POSTHOG_CONFIGURED: true, + POSTHOG_API_HOST: "test-posthog-api-host", + POSTHOG_API_KEY: "test-posthog-api-key", + FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id", + IS_FORMBRICKS_ENABLED: true, + SESSION_MAX_AGE: 1000, + AVAILABLE_LOCALES: ["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW", "pt-PT"], +})); + +vi.mock("@/modules/auth/components/form-wrapper", () => ({ + FormWrapper: ({ children }) =>
        {children}
        , +})); + +vi.mock("@/modules/auth/verification-requested/components/request-verification-email", () => ({ + RequestVerificationEmail: ({ email }) =>
        Mocked RequestVerificationEmail: {email}
        , +})); + +vi.mock("@/modules/ui/components/alert", () => ({ + Alert: ({ children }) =>
        {children}
        , +})); + +describe("VerificationRequestedPage", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("renders the page with valid email", async () => { + const mockEmail = "test@example.com"; + vi.mocked(getEmailFromEmailToken).mockReturnValue(mockEmail); + + const searchParams = { token: "valid-token" }; + const Page = await VerificationRequestedPage({ searchParams }); + render(Page); + + expect( + screen.getByText("auth.verification-requested.please_confirm_your_email_address") + ).toBeInTheDocument(); + expect(screen.getAllByText(/test@example\.com/)).toHaveLength(2); + expect( + screen.getByText( + "auth.verification-requested.verification_email_successfully_sent_info test@example.com" + ) + ).toBeInTheDocument(); + expect( + screen.getByText(`Mocked RequestVerificationEmail: ${mockEmail.toLowerCase()}`) + ).toBeInTheDocument(); + }); + + test("renders invalid email message when email parsing fails", async () => { + vi.mocked(getEmailFromEmailToken).mockReturnValue("invalid-email"); + + const searchParams = { token: "valid-token" }; + const Page = await VerificationRequestedPage({ searchParams }); + render(Page); + + expect(screen.getByText("auth.verification-requested.invalid_email_address")).toBeInTheDocument(); + }); + + test("renders invalid token message when token is invalid", async () => { + const mockError = new Error("Invalid token"); + const { logger } = await import("@formbricks/logger"); + + vi.mocked(getEmailFromEmailToken).mockImplementation(() => { + throw mockError; + }); + + const searchParams = { token: "invalid-token" }; + const Page = await VerificationRequestedPage({ searchParams }); + render(Page); + + expect(logger.error).toHaveBeenCalledWith(mockError, "Invalid token"); + expect(screen.getByText("auth.verification-requested.invalid_token")).toBeInTheDocument(); + }); + + test("calls logger.error when token parsing throws an error", async () => { + const mockError = new Error("JWT malformed"); + const { logger } = await import("@formbricks/logger"); + + vi.mocked(getEmailFromEmailToken).mockImplementation(() => { + throw mockError; + }); + + const searchParams = { token: "malformed-token" }; + await VerificationRequestedPage({ searchParams }); + + expect(logger.error).toHaveBeenCalledWith(mockError, "Invalid token"); + expect(logger.error).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/modules/auth/verification-requested/page.tsx b/apps/web/modules/auth/verification-requested/page.tsx index b6d1fafac2bc..62f7bd58b8e2 100644 --- a/apps/web/modules/auth/verification-requested/page.tsx +++ b/apps/web/modules/auth/verification-requested/page.tsx @@ -1,7 +1,8 @@ +import { getEmailFromEmailToken } from "@/lib/jwt"; import { FormWrapper } from "@/modules/auth/components/form-wrapper"; import { RequestVerificationEmail } from "@/modules/auth/verification-requested/components/request-verification-email"; import { T, getTranslate } from "@/tolgee/server"; -import { getEmailFromEmailToken } from "@formbricks/lib/jwt"; +import { logger } from "@formbricks/logger"; import { ZUserEmail } from "@formbricks/types/user"; export const VerificationRequestedPage = async ({ searchParams }) => { @@ -19,10 +20,9 @@ export const VerificationRequestedPage = async ({ searchParams }) => {

        }} /> - {t("auth.verification-requested.please_click_the_link_in_the_email_to_activate_your_account")}


        @@ -42,6 +42,7 @@ export const VerificationRequestedPage = async ({ searchParams }) => { ); } } catch (error) { + logger.error(error, "Invalid token"); return (

        {t("auth.verification-requested.invalid_token")}

        diff --git a/apps/web/modules/auth/verify-email-change/actions.ts b/apps/web/modules/auth/verify-email-change/actions.ts new file mode 100644 index 000000000000..952b008b943c --- /dev/null +++ b/apps/web/modules/auth/verify-email-change/actions.ts @@ -0,0 +1,35 @@ +"use server"; + +import { verifyEmailChangeToken } from "@/lib/jwt"; +import { actionClient } from "@/lib/utils/action-client"; +import { ActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; +import { getUser, updateUser } from "@/modules/auth/lib/user"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; +import { z } from "zod"; + +export const verifyEmailChangeAction = actionClient.schema(z.object({ token: z.string() })).action( + withAuditLogging( + "updated", + "user", + async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record }) => { + const { id, email } = await verifyEmailChangeToken(parsedInput.token); + + if (!email) { + throw new Error("Email not found in token"); + } + const oldObject = await getUser(id); + const user = await updateUser(id, { email, emailVerified: new Date() }); + if (!user) { + throw new Error("User not found or email update failed"); + } + + ctx.auditLoggingCtx.userId = id; + ctx.auditLoggingCtx.oldObject = oldObject; + ctx.auditLoggingCtx.newObject = user; + + await updateBrevoCustomer({ id: user.id, email: user.email }); + return user; + } + ) +); diff --git a/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.test.tsx b/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.test.tsx new file mode 100644 index 000000000000..7b48981eca3a --- /dev/null +++ b/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.test.tsx @@ -0,0 +1,75 @@ +import { verifyEmailChangeAction } from "@/modules/auth/verify-email-change/actions"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { signOut } from "next-auth/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { EmailChangeSignIn } from "./email-change-sign-in"; + +// Mock dependencies +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("next-auth/react", () => ({ + signOut: vi.fn(), +})); + +vi.mock("@/modules/auth/verify-email-change/actions", () => ({ + verifyEmailChangeAction: vi.fn(), +})); + +describe("EmailChangeSignIn", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("shows loading state initially", () => { + render(); + expect(screen.getByText("auth.email-change.email_verification_loading")).toBeInTheDocument(); + }); + + test("handles successful email change verification", async () => { + vi.mocked(verifyEmailChangeAction).mockResolvedValueOnce({ + data: { + id: "123", + email: "test@example.com", + emailVerified: new Date().toISOString(), + locale: "en-US", + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("auth.email-change.email_change_success")).toBeInTheDocument(); + expect(screen.getByText("auth.email-change.email_change_success_description")).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(signOut).toHaveBeenCalledWith({ redirect: false }); + }); + }); + + test("handles failed email change verification", async () => { + vi.mocked(verifyEmailChangeAction).mockResolvedValueOnce({ serverError: "Error" }); + + render(); + + await waitFor(() => { + expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument(); + expect(screen.getByText("auth.email-change.invalid_or_expired_token")).toBeInTheDocument(); + }); + + expect(signOut).not.toHaveBeenCalled(); + }); + + test("handles empty token", () => { + render(); + + expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument(); + expect(screen.getByText("auth.email-change.invalid_or_expired_token")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.tsx b/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.tsx new file mode 100644 index 000000000000..7b8c0d6db514 --- /dev/null +++ b/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { verifyEmailChangeAction } from "@/modules/auth/verify-email-change/actions"; +import { useTranslate } from "@tolgee/react"; +import { signOut } from "next-auth/react"; +import { useEffect, useState } from "react"; + +interface EmailChangeSignInProps { + token: string; +} + +export const EmailChangeSignIn = ({ token }: EmailChangeSignInProps) => { + const { t } = useTranslate(); + const [status, setStatus] = useState<"success" | "error" | "loading">("loading"); + + useEffect(() => { + const validateToken = async () => { + if (typeof token === "string" && token.trim() !== "") { + const result = await verifyEmailChangeAction({ token }); + + if (!result?.data) { + setStatus("error"); + } else { + setStatus("success"); + } + } else { + setStatus("error"); + } + }; + + if (token) { + validateToken(); + } else { + setStatus("error"); + } + }, [token]); + + useEffect(() => { + if (status === "success") { + signOut({ redirect: false }); + } + }, [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 ( + <> +

        + {text.heading[status]} +

        +

        {text.description[status]}

        +
        + + ); +}; diff --git a/apps/web/modules/auth/verify-email-change/page.test.tsx b/apps/web/modules/auth/verify-email-change/page.test.tsx new file mode 100644 index 000000000000..fd9d8d6a362c --- /dev/null +++ b/apps/web/modules/auth/verify-email-change/page.test.tsx @@ -0,0 +1,47 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { VerifyEmailChangePage } from "./page"; + +// Mock the necessary dependencies +vi.mock("@/modules/auth/components/back-to-login-button", () => ({ + BackToLoginButton: () =>
        Back to Login
        , +})); + +vi.mock("@/modules/auth/components/form-wrapper", () => ({ + FormWrapper: ({ children }: { children: React.ReactNode }) => ( +
        {children}
        + ), +})); + +vi.mock("@/modules/auth/verify-email-change/components/email-change-sign-in", () => ({ + EmailChangeSignIn: ({ token }: { token: string }) => ( +
        Email Change Sign In with token: {token}
        + ), +})); + +describe("VerifyEmailChangePage", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the page with form wrapper and components", async () => { + const searchParams = { token: "test-token" }; + render(await VerifyEmailChangePage({ searchParams })); + + expect(screen.getByTestId("form-wrapper")).toBeInTheDocument(); + expect(screen.getByTestId("email-change-sign-in")).toBeInTheDocument(); + expect(screen.getByTestId("back-to-login")).toBeInTheDocument(); + expect(screen.getByText("Email Change Sign In with token: test-token")).toBeInTheDocument(); + }); + + test("handles missing token", async () => { + const searchParams = {}; + render(await VerifyEmailChangePage({ searchParams })); + + expect(screen.getByTestId("form-wrapper")).toBeInTheDocument(); + expect(screen.getByTestId("email-change-sign-in")).toBeInTheDocument(); + expect(screen.getByTestId("back-to-login")).toBeInTheDocument(); + expect(screen.getByText("Email Change Sign In with token:")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/auth/verify-email-change/page.tsx b/apps/web/modules/auth/verify-email-change/page.tsx new file mode 100644 index 000000000000..f4813eac2670 --- /dev/null +++ b/apps/web/modules/auth/verify-email-change/page.tsx @@ -0,0 +1,16 @@ +import { BackToLoginButton } from "@/modules/auth/components/back-to-login-button"; +import { FormWrapper } from "@/modules/auth/components/form-wrapper"; +import { EmailChangeSignIn } from "@/modules/auth/verify-email-change/components/email-change-sign-in"; + +export const VerifyEmailChangePage = async ({ searchParams }) => { + const { token } = await searchParams; + + return ( +
        + + + + +
        + ); +}; diff --git a/apps/web/modules/cache/lib/cacheKeys.test.ts b/apps/web/modules/cache/lib/cacheKeys.test.ts new file mode 100644 index 000000000000..aff96afa1dde --- /dev/null +++ b/apps/web/modules/cache/lib/cacheKeys.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, test } from "vitest"; +import { createCacheKey, parseCacheKey, validateCacheKey } from "./cacheKeys"; + +describe("cacheKeys", () => { + describe("createCacheKey", () => { + describe("environment keys", () => { + test("should create environment state key", () => { + const key = createCacheKey.environment.state("env123"); + expect(key).toBe("fb:env:env123:state"); + }); + + test("should create environment surveys key", () => { + const key = createCacheKey.environment.surveys("env456"); + expect(key).toBe("fb:env:env456:surveys"); + }); + + test("should create environment actionClasses key", () => { + const key = createCacheKey.environment.actionClasses("env789"); + expect(key).toBe("fb:env:env789:action_classes"); + }); + + test("should create environment config key", () => { + const key = createCacheKey.environment.config("env101"); + expect(key).toBe("fb:env:env101:config"); + }); + + test("should create environment segments key", () => { + const key = createCacheKey.environment.segments("env202"); + expect(key).toBe("fb:env:env202:segments"); + }); + }); + + describe("organization keys", () => { + test("should create organization billing key", () => { + const key = createCacheKey.organization.billing("org123"); + expect(key).toBe("fb:org:org123:billing"); + }); + + test("should create organization environments key", () => { + const key = createCacheKey.organization.environments("org456"); + expect(key).toBe("fb:org:org456:environments"); + }); + + test("should create organization config key", () => { + const key = createCacheKey.organization.config("org789"); + expect(key).toBe("fb:org:org789:config"); + }); + + test("should create organization limits key", () => { + const key = createCacheKey.organization.limits("org101"); + expect(key).toBe("fb:org:org101:limits"); + }); + }); + + describe("license keys", () => { + test("should create license status key", () => { + const key = createCacheKey.license.status("org123"); + expect(key).toBe("fb:license:org123:status"); + }); + + test("should create license features key", () => { + const key = createCacheKey.license.features("org456"); + expect(key).toBe("fb:license:org456:features"); + }); + + test("should create license usage key", () => { + const key = createCacheKey.license.usage("org789"); + expect(key).toBe("fb:license:org789:usage"); + }); + + test("should create license check key", () => { + const key = createCacheKey.license.check("org123", "feature-x"); + expect(key).toBe("fb:license:org123:check:feature-x"); + }); + + test("should create license previous_result key", () => { + const key = createCacheKey.license.previous_result("org456"); + expect(key).toBe("fb:license:org456:previous_result"); + }); + }); + + describe("user keys", () => { + test("should create user profile key", () => { + const key = createCacheKey.user.profile("user123"); + expect(key).toBe("fb:user:user123:profile"); + }); + + test("should create user preferences key", () => { + const key = createCacheKey.user.preferences("user456"); + expect(key).toBe("fb:user:user456:preferences"); + }); + + test("should create user organizations key", () => { + const key = createCacheKey.user.organizations("user789"); + expect(key).toBe("fb:user:user789:organizations"); + }); + + test("should create user permissions key", () => { + const key = createCacheKey.user.permissions("user123", "org456"); + expect(key).toBe("fb:user:user123:org:org456:permissions"); + }); + }); + + describe("project keys", () => { + test("should create project config key", () => { + const key = createCacheKey.project.config("proj123"); + expect(key).toBe("fb:project:proj123:config"); + }); + + test("should create project environments key", () => { + const key = createCacheKey.project.environments("proj456"); + expect(key).toBe("fb:project:proj456:environments"); + }); + + test("should create project surveys key", () => { + const key = createCacheKey.project.surveys("proj789"); + expect(key).toBe("fb:project:proj789:surveys"); + }); + }); + + describe("survey keys", () => { + test("should create survey metadata key", () => { + const key = createCacheKey.survey.metadata("survey123"); + expect(key).toBe("fb:survey:survey123:metadata"); + }); + + test("should create survey responses key", () => { + const key = createCacheKey.survey.responses("survey456"); + expect(key).toBe("fb:survey:survey456:responses"); + }); + + test("should create survey stats key", () => { + const key = createCacheKey.survey.stats("survey789"); + expect(key).toBe("fb:survey:survey789:stats"); + }); + }); + + describe("session keys", () => { + test("should create session data key", () => { + const key = createCacheKey.session.data("session123"); + expect(key).toBe("fb:session:session123:data"); + }); + + test("should create session permissions key", () => { + const key = createCacheKey.session.permissions("session456"); + expect(key).toBe("fb:session:session456:permissions"); + }); + }); + + describe("rate limit keys", () => { + test("should create rate limit api key", () => { + const key = createCacheKey.rateLimit.api("api-key-123", "endpoint-v1"); + expect(key).toBe("fb:rate_limit:api:api-key-123:endpoint-v1"); + }); + + test("should create rate limit login key", () => { + const key = createCacheKey.rateLimit.login("user-ip-hash"); + expect(key).toBe("fb:rate_limit:login:user-ip-hash"); + }); + + test("should create rate limit core key", () => { + const key = createCacheKey.rateLimit.core("auth:login", "user123", 1703174400); + expect(key).toBe("fb:rate_limit:auth:login:user123:1703174400"); + }); + }); + + describe("custom keys", () => { + test("should create custom key without subResource", () => { + const key = createCacheKey.custom("temp", "identifier123"); + expect(key).toBe("fb:temp:identifier123"); + }); + + test("should create custom key with subResource", () => { + const key = createCacheKey.custom("analytics", "user456", "daily-stats"); + expect(key).toBe("fb:analytics:user456:daily-stats"); + }); + + test("should work with all valid namespaces", () => { + const validNamespaces = ["temp", "analytics", "webhook", "integration", "backup"]; + + validNamespaces.forEach((namespace) => { + const key = createCacheKey.custom(namespace, "test-id"); + expect(key).toBe(`fb:${namespace}:test-id`); + }); + }); + + test("should throw error for invalid namespace", () => { + expect(() => createCacheKey.custom("invalid", "identifier")).toThrow( + "Invalid cache namespace: invalid. Use: temp, analytics, webhook, integration, backup" + ); + }); + + test("should throw error for empty namespace", () => { + expect(() => createCacheKey.custom("", "identifier")).toThrow( + "Invalid cache namespace: . Use: temp, analytics, webhook, integration, backup" + ); + }); + }); + }); + + describe("validateCacheKey", () => { + test("should validate correct cache keys", () => { + const validKeys = [ + "fb:env:env123:state", + "fb:user:user456:profile", + "fb:org:org789:billing", + "fb:rate_limit:api:key123:endpoint", + "fb:custom:namespace:identifier:sub:resource", + ]; + + validKeys.forEach((key) => { + expect(validateCacheKey(key)).toBe(true); + }); + }); + + test("should reject keys without fb prefix", () => { + const invalidKeys = ["env:env123:state", "user:user456:profile", "redis:key:value", "cache:item:data"]; + + invalidKeys.forEach((key) => { + expect(validateCacheKey(key)).toBe(false); + }); + }); + + test("should reject keys with insufficient parts", () => { + const invalidKeys = ["fb:", "fb:env", "fb:env:", "fb:user:user123:"]; + + invalidKeys.forEach((key) => { + expect(validateCacheKey(key)).toBe(false); + }); + }); + + test("should reject keys with empty parts", () => { + const invalidKeys = ["fb::env123:state", "fb:env::state", "fb:env:env123:", "fb:user::profile"]; + + invalidKeys.forEach((key) => { + expect(validateCacheKey(key)).toBe(false); + }); + }); + + test("should validate minimum valid key", () => { + expect(validateCacheKey("fb:a:b")).toBe(true); + }); + }); + + describe("parseCacheKey", () => { + test("should parse basic cache key", () => { + const result = parseCacheKey("fb:env:env123:state"); + + expect(result).toEqual({ + prefix: "fb", + resource: "env", + identifier: "env123", + subResource: "state", + full: "fb:env:env123:state", + }); + }); + + test("should parse key without subResource", () => { + const result = parseCacheKey("fb:user:user123"); + + expect(result).toEqual({ + prefix: "fb", + resource: "user", + identifier: "user123", + subResource: undefined, + full: "fb:user:user123", + }); + }); + + test("should parse key with multiple subResource parts", () => { + const result = parseCacheKey("fb:user:user123:org:org456:permissions"); + + expect(result).toEqual({ + prefix: "fb", + resource: "user", + identifier: "user123", + subResource: "org:org456:permissions", + full: "fb:user:user123:org:org456:permissions", + }); + }); + + test("should parse rate limit key with timestamp", () => { + const result = parseCacheKey("fb:rate_limit:auth:login:user123:1703174400"); + + expect(result).toEqual({ + prefix: "fb", + resource: "rate_limit", + identifier: "auth", + subResource: "login:user123:1703174400", + full: "fb:rate_limit:auth:login:user123:1703174400", + }); + }); + + test("should throw error for invalid cache key", () => { + const invalidKeys = ["invalid:key:format", "fb:env", "fb::env123:state", "redis:user:profile"]; + + invalidKeys.forEach((key) => { + expect(() => parseCacheKey(key)).toThrow(`Invalid cache key format: ${key}`); + }); + }); + }); + + describe("cache key patterns and consistency", () => { + test("all environment keys should follow same pattern", () => { + const envId = "test-env-123"; + const envKeys = [ + createCacheKey.environment.state(envId), + createCacheKey.environment.surveys(envId), + createCacheKey.environment.actionClasses(envId), + createCacheKey.environment.config(envId), + createCacheKey.environment.segments(envId), + ]; + + envKeys.forEach((key) => { + expect(key).toMatch(/^fb:env:test-env-123:.+$/); + expect(validateCacheKey(key)).toBe(true); + }); + }); + + test("all organization keys should follow same pattern", () => { + const orgId = "test-org-456"; + const orgKeys = [ + createCacheKey.organization.billing(orgId), + createCacheKey.organization.environments(orgId), + createCacheKey.organization.config(orgId), + createCacheKey.organization.limits(orgId), + ]; + + orgKeys.forEach((key) => { + expect(key).toMatch(/^fb:org:test-org-456:.+$/); + expect(validateCacheKey(key)).toBe(true); + }); + }); + + test("all generated keys should be parseable", () => { + const testKeys = [ + createCacheKey.environment.state("env123"), + createCacheKey.user.profile("user456"), + createCacheKey.organization.billing("org789"), + createCacheKey.survey.metadata("survey101"), + createCacheKey.session.data("session202"), + createCacheKey.rateLimit.core("auth:login", "user303", 1703174400), + createCacheKey.custom("temp", "temp404", "cleanup"), + ]; + + testKeys.forEach((key) => { + expect(() => parseCacheKey(key)).not.toThrow(); + + const parsed = parseCacheKey(key); + expect(parsed.prefix).toBe("fb"); + expect(parsed.full).toBe(key); + expect(parsed.resource).toBeTruthy(); + expect(parsed.identifier).toBeTruthy(); + }); + }); + + test("keys should be unique across different resources", () => { + const keys = [ + createCacheKey.environment.state("same-id"), + createCacheKey.user.profile("same-id"), + createCacheKey.organization.billing("same-id"), + createCacheKey.project.config("same-id"), + createCacheKey.survey.metadata("same-id"), + ]; + + const uniqueKeys = new Set(keys); + expect(uniqueKeys.size).toBe(keys.length); + }); + + test("namespace validation should prevent collisions", () => { + // These should not throw (valid namespaces) + expect(() => createCacheKey.custom("temp", "id")).not.toThrow(); + expect(() => createCacheKey.custom("analytics", "id")).not.toThrow(); + + // These should throw (reserved/invalid namespaces) + expect(() => createCacheKey.custom("env", "id")).toThrow(); + expect(() => createCacheKey.custom("user", "id")).toThrow(); + expect(() => createCacheKey.custom("org", "id")).toThrow(); + }); + }); +}); diff --git a/apps/web/modules/cache/lib/cacheKeys.ts b/apps/web/modules/cache/lib/cacheKeys.ts new file mode 100644 index 000000000000..d03612a82419 --- /dev/null +++ b/apps/web/modules/cache/lib/cacheKeys.ts @@ -0,0 +1,126 @@ +import "server-only"; + +/** + * Enterprise-grade cache key generator following industry best practices + * Pattern: fb:{resource}:{identifier}[:{subresource}] + * + * Benefits: + * - Clear namespace hierarchy (fb = formbricks) + * - Collision-proof across environments + * - Easy debugging and monitoring + * - Predictable invalidation patterns + * - Multi-tenant safe + */ + +export const createCacheKey = { + // Environment-related keys + environment: { + state: (environmentId: string) => `fb:env:${environmentId}:state`, + surveys: (environmentId: string) => `fb:env:${environmentId}:surveys`, + actionClasses: (environmentId: string) => `fb:env:${environmentId}:action_classes`, + config: (environmentId: string) => `fb:env:${environmentId}:config`, + segments: (environmentId: string) => `fb:env:${environmentId}:segments`, + }, + + // Organization-related keys + organization: { + billing: (organizationId: string) => `fb:org:${organizationId}:billing`, + environments: (organizationId: string) => `fb:org:${organizationId}:environments`, + config: (organizationId: string) => `fb:org:${organizationId}:config`, + limits: (organizationId: string) => `fb:org:${organizationId}:limits`, + }, + + // License and enterprise features + license: { + status: (organizationId: string) => `fb:license:${organizationId}:status`, + features: (organizationId: string) => `fb:license:${organizationId}:features`, + usage: (organizationId: string) => `fb:license:${organizationId}:usage`, + check: (organizationId: string, feature: string) => `fb:license:${organizationId}:check:${feature}`, + previous_result: (organizationId: string) => `fb:license:${organizationId}:previous_result`, + }, + + // User-related keys + user: { + profile: (userId: string) => `fb:user:${userId}:profile`, + preferences: (userId: string) => `fb:user:${userId}:preferences`, + organizations: (userId: string) => `fb:user:${userId}:organizations`, + permissions: (userId: string, organizationId: string) => + `fb:user:${userId}:org:${organizationId}:permissions`, + }, + + // Project-related keys + project: { + config: (projectId: string) => `fb:project:${projectId}:config`, + environments: (projectId: string) => `fb:project:${projectId}:environments`, + surveys: (projectId: string) => `fb:project:${projectId}:surveys`, + }, + + // Survey-related keys + survey: { + metadata: (surveyId: string) => `fb:survey:${surveyId}:metadata`, + responses: (surveyId: string) => `fb:survey:${surveyId}:responses`, + stats: (surveyId: string) => `fb:survey:${surveyId}:stats`, + }, + + // Session and authentication + session: { + data: (sessionId: string) => `fb:session:${sessionId}:data`, + permissions: (sessionId: string) => `fb:session:${sessionId}:permissions`, + }, + + // Rate limiting and security + rateLimit: { + api: (identifier: string, endpoint: string) => `fb:rate_limit:api:${identifier}:${endpoint}`, + login: (identifier: string) => `fb:rate_limit:login:${identifier}`, + core: (namespace: string, identifier: string, windowStart: number) => + `fb:rate_limit:${namespace}:${identifier}:${windowStart}`, + }, + + // Custom keys with validation + custom: (namespace: string, identifier: string, subResource?: string) => { + // Validate namespace to prevent collisions + const validNamespaces = ["temp", "analytics", "webhook", "integration", "backup"]; + if (!validNamespaces.includes(namespace)) { + throw new Error(`Invalid cache namespace: ${namespace}. Use: ${validNamespaces.join(", ")}`); + } + + const base = `fb:${namespace}:${identifier}`; + return subResource ? `${base}:${subResource}` : base; + }, +}; + +/** + * Cache key validation helpers + */ +export const validateCacheKey = (key: string): boolean => { + // Must start with fb: prefix + if (!key.startsWith("fb:")) return false; + + // Must have at least 3 parts (fb:resource:identifier) + const parts = key.split(":"); + if (parts.length < 3) return false; + + // No empty parts + if (parts.some((part) => part.length === 0)) return false; + + return true; +}; + +/** + * Extract cache key components for debugging/monitoring + */ +export const parseCacheKey = (key: string) => { + if (!validateCacheKey(key)) { + throw new Error(`Invalid cache key format: ${key}`); + } + + const [prefix, resource, identifier, ...subResources] = key.split(":"); + + return { + prefix, + resource, + identifier, + subResource: subResources.length > 0 ? subResources.join(":") : undefined, + full: key, + }; +}; diff --git a/apps/web/modules/cache/lib/service.test.ts b/apps/web/modules/cache/lib/service.test.ts new file mode 100644 index 000000000000..da0b477bda04 --- /dev/null +++ b/apps/web/modules/cache/lib/service.test.ts @@ -0,0 +1,159 @@ +import KeyvRedis from "@keyv/redis"; +import { createCache } from "cache-manager"; +import { Keyv } from "keyv"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; + +// Mock dependencies +vi.mock("keyv"); +vi.mock("@keyv/redis"); +vi.mock("cache-manager"); +vi.mock("@formbricks/logger"); + +const mockCacheInstance = { + set: vi.fn(), + get: vi.fn(), + del: vi.fn(), +}; + +describe("Cache Service", () => { + let originalRedisUrl: string | undefined; + let originalNextRuntime: string | undefined; + + beforeEach(() => { + originalRedisUrl = process.env.REDIS_URL; + originalNextRuntime = process.env.NEXT_RUNTIME; + + // Ensure we're in runtime mode (not build time) + process.env.NEXT_RUNTIME = "nodejs"; + + vi.resetAllMocks(); + vi.resetModules(); + + // Setup default mock implementations + vi.mocked(createCache).mockReturnValue(mockCacheInstance as any); + vi.mocked(Keyv).mockClear(); + vi.mocked(KeyvRedis).mockClear(); + vi.mocked(logger.warn).mockClear(); + vi.mocked(logger.error).mockClear(); + vi.mocked(logger.info).mockClear(); + + // Mock successful cache operations for Redis connection test + mockCacheInstance.set.mockResolvedValue(undefined); + mockCacheInstance.get.mockResolvedValue({ test: true }); + mockCacheInstance.del.mockResolvedValue(undefined); + }); + + afterEach(() => { + process.env.REDIS_URL = originalRedisUrl; + process.env.NEXT_RUNTIME = originalNextRuntime; + }); + + describe("Initialization and getCache", () => { + test("should use Redis store and return it via getCache if REDIS_URL is set", async () => { + process.env.REDIS_URL = "redis://localhost:6379"; + const { getCache } = await import("./service"); + + const cache = await getCache(); + + expect(KeyvRedis).toHaveBeenCalledWith("redis://localhost:6379"); + expect(Keyv).toHaveBeenCalledWith({ + store: expect.any(KeyvRedis), + }); + expect(createCache).toHaveBeenCalledWith({ + stores: [expect.any(Keyv)], + }); + expect(logger.info).toHaveBeenCalledWith("Cache initialized with Redis"); + expect(cache).toBe(mockCacheInstance); + }); + + test("should fall back to memory store if Redis connection fails", async () => { + process.env.REDIS_URL = "redis://localhost:6379"; + const mockError = new Error("Connection refused"); + + // Mock cache operations to fail for Redis connection test + mockCacheInstance.get.mockRejectedValueOnce(mockError); + + const { getCache } = await import("./service"); + + const cache = await getCache(); + + expect(KeyvRedis).toHaveBeenCalledWith("redis://localhost:6379"); + expect(logger.warn).toHaveBeenCalledWith("Redis connection failed, using memory cache", { + error: mockError, + }); + expect(cache).toBe(mockCacheInstance); + }); + + test("should use memory store and return it via getCache if REDIS_URL is not set", async () => { + delete process.env.REDIS_URL; + const { getCache } = await import("./service"); + + const cache = await getCache(); + + expect(KeyvRedis).not.toHaveBeenCalled(); + expect(Keyv).toHaveBeenCalledWith(); + expect(createCache).toHaveBeenCalledWith({ + stores: [expect.any(Keyv)], + }); + expect(cache).toBe(mockCacheInstance); + }); + + test("should use memory store and return it via getCache if REDIS_URL is an empty string", async () => { + process.env.REDIS_URL = ""; + const { getCache } = await import("./service"); + + const cache = await getCache(); + + expect(KeyvRedis).not.toHaveBeenCalled(); + expect(Keyv).toHaveBeenCalledWith(); + expect(createCache).toHaveBeenCalledWith({ + stores: [expect.any(Keyv)], + }); + expect(cache).toBe(mockCacheInstance); + }); + + test("should return same instance on multiple calls to getCache", async () => { + process.env.REDIS_URL = "redis://localhost:6379"; + const { getCache } = await import("./service"); + + const cache1 = await getCache(); + const cache2 = await getCache(); + + expect(cache1).toBe(cache2); + expect(cache1).toBe(mockCacheInstance); + // Should only initialize once + expect(createCache).toHaveBeenCalledTimes(1); + }); + + test("should use memory cache during build time", async () => { + process.env.REDIS_URL = "redis://localhost:6379"; + delete process.env.NEXT_RUNTIME; // Simulate build time + + const { getCache } = await import("./service"); + + const cache = await getCache(); + + expect(KeyvRedis).not.toHaveBeenCalled(); + expect(Keyv).toHaveBeenCalledWith(); + expect(cache).toBe(mockCacheInstance); + }); + + test("should provide cache health information", async () => { + process.env.REDIS_URL = "redis://localhost:6379"; + const { getCache, getCacheHealth } = await import("./service"); + + // Before initialization + let health = getCacheHealth(); + expect(health.isInitialized).toBe(false); + expect(health.hasInstance).toBe(false); + + // After initialization + await getCache(); + health = getCacheHealth(); + expect(health.isInitialized).toBe(true); + expect(health.hasInstance).toBe(true); + expect(health.isRedisConnected).toBe(true); + }); + }); +}); diff --git a/apps/web/modules/cache/lib/service.ts b/apps/web/modules/cache/lib/service.ts new file mode 100644 index 000000000000..a42b56f2e91c --- /dev/null +++ b/apps/web/modules/cache/lib/service.ts @@ -0,0 +1,135 @@ +import "server-only"; +import KeyvRedis from "@keyv/redis"; +import { type Cache, createCache } from "cache-manager"; +import { Keyv } from "keyv"; +import { logger } from "@formbricks/logger"; + +// Singleton state management +interface CacheState { + instance: Cache | null; + isInitialized: boolean; + isRedisConnected: boolean; + initializationPromise: Promise | null; +} + +const state: CacheState = { + instance: null, + isInitialized: false, + isRedisConnected: false, + initializationPromise: null, +}; + +/** + * Creates a memory cache fallback + */ +const createMemoryCache = (): Cache => { + return createCache({ stores: [new Keyv()] }); +}; + +/** + * Creates Redis cache with proper async connection handling + */ +const createRedisCache = async (redisUrl: string): Promise => { + const redisStore = new KeyvRedis(redisUrl); + const cache = createCache({ stores: [new Keyv({ store: redisStore })] }); + + // Test connection + const testKey = "__health_check__"; + await cache.set(testKey, { test: true }, 5000); + const result = await cache.get<{ test: boolean }>(testKey); + await cache.del(testKey); + + if (!result?.test) { + throw new Error("Redis connection test failed"); + } + + return cache; +}; + +/** + * Async cache initialization with proper singleton pattern + */ +const initializeCache = async (): Promise => { + if (state.initializationPromise) { + return state.initializationPromise; + } + + state.initializationPromise = (async () => { + try { + const redisUrl = process.env.REDIS_URL?.trim(); + + if (!redisUrl) { + state.instance = createMemoryCache(); + state.isRedisConnected = false; + return state.instance; + } + + try { + state.instance = await createRedisCache(redisUrl); + state.isRedisConnected = true; + logger.info("Cache initialized with Redis"); + } catch (error) { + logger.warn("Redis connection failed, using memory cache", { error }); + state.instance = createMemoryCache(); + state.isRedisConnected = false; + } + + return state.instance; + } catch (error) { + logger.error("Cache initialization failed", { error }); + state.instance = createMemoryCache(); + return state.instance; + } finally { + state.isInitialized = true; + state.initializationPromise = null; + } + })(); + + return state.initializationPromise; +}; + +/** + * Simple Next.js build environment detection + * Works in 99% of cases with minimal complexity + */ +const isBuildTime = () => !process.env.NEXT_RUNTIME; + +/** + * Get cache instance with proper async initialization + * Always re-evaluates Redis URL at runtime to handle build-time vs runtime differences + */ +export const getCache = async (): Promise => { + if (isBuildTime()) { + if (!state.instance) { + state.instance = createMemoryCache(); + state.isInitialized = true; + state.isRedisConnected = false; + } + return state.instance; + } + + const currentRedisUrl = process.env.REDIS_URL?.trim(); + + // Re-initialize if Redis URL is now available but we're using memory cache + if (state.instance && state.isInitialized && !state.isRedisConnected && currentRedisUrl) { + logger.info("Re-initializing cache with Redis"); + state.instance = null; + state.isInitialized = false; + state.initializationPromise = null; + } + + if (state.instance && state.isInitialized) { + return state.instance; + } + + return initializeCache(); +}; + +/** + * Cache health monitoring for diagnostics + */ +export const getCacheHealth = () => ({ + isInitialized: state.isInitialized, + isRedisConnected: state.isRedisConnected, + hasInstance: !!state.instance, +}); diff --git a/apps/web/modules/cache/lib/withCache.ts b/apps/web/modules/cache/lib/withCache.ts new file mode 100644 index 000000000000..aa21f132eb3d --- /dev/null +++ b/apps/web/modules/cache/lib/withCache.ts @@ -0,0 +1,85 @@ +import "server-only"; +import { logger } from "@formbricks/logger"; +import { getCache } from "./service"; + +/** + * Simple cache wrapper for functions that return promises + */ + +type CacheOptions = { + key: string; + ttl: number; // TTL in milliseconds +}; + +/** + * Simple cache wrapper for functions that return promises + * + * @example + * ```typescript + * const getCachedEnvironment = withCache( + * () => fetchEnvironmentFromDB(environmentId), + * { + * key: `env:${environmentId}`, + * ttl: 3600000 // 1 hour in milliseconds + * } + * ); + * ``` + */ +export const withCache = (fn: () => Promise, options: CacheOptions): (() => Promise) => { + return async (): Promise => { + const { key, ttl } = options; + + try { + const cache = await getCache(); + + // Try to get from cache - cache-manager with Keyv handles serialization automatically + const cached = await cache.get(key); + + if (cached !== null && cached !== undefined) { + return cached; + } + + // Cache miss - fetch fresh data + const fresh = await fn(); + + // Cache the result with proper TTL conversion + // cache-manager with Keyv expects TTL in milliseconds + await cache.set(key, fresh, ttl); + + return fresh; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + + // On cache error, still try to fetch fresh data + logger.warn({ key, error: err }, "Cache operation failed, fetching fresh data"); + + try { + return await fn(); + } catch (fnError) { + const fnErr = fnError instanceof Error ? fnError : new Error(String(fnError)); + logger.error("Failed to fetch fresh data after cache error", { + key, + cacheError: err, + functionError: fnErr, + }); + throw fnErr; + } + } + }; +}; + +/** + * Simple cache invalidation helper + * Prefer explicit key invalidation over complex tag systems + */ +export const invalidateCache = async (keys: string | string[]): Promise => { + const cache = await getCache(); + const keyArray = Array.isArray(keys) ? keys : [keys]; + + await Promise.all(keyArray.map((key) => cache.del(key))); + + logger.info("Cache invalidated", { keys: keyArray }); +}; + +// Re-export cache key utilities for backwards compatibility +export { createCacheKey, validateCacheKey, parseCacheKey } from "./cacheKeys"; diff --git a/apps/web/modules/cache/redis.test.ts b/apps/web/modules/cache/redis.test.ts new file mode 100644 index 000000000000..609cc5f9ff70 --- /dev/null +++ b/apps/web/modules/cache/redis.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock the logger +vi.mock("@formbricks/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock the redis client +const mockRedisClient = { + connect: vi.fn(), + disconnect: vi.fn(), + on: vi.fn(), + isReady: true, + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + exists: vi.fn(), + expire: vi.fn(), + ttl: vi.fn(), + keys: vi.fn(), + flushall: vi.fn(), +}; + +vi.mock("redis", () => ({ + createClient: vi.fn(() => mockRedisClient), +})); + +// Mock crypto for UUID generation +vi.mock("crypto", () => ({ + randomUUID: vi.fn(() => "test-uuid-123"), +})); + +describe("Redis module", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Reset environment variable + process.env.REDIS_URL = "redis://localhost:6379"; + + // Reset isReady state + mockRedisClient.isReady = true; + + // Make connect resolve successfully + mockRedisClient.connect.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.resetModules(); + process.env.REDIS_URL = undefined; + }); + + describe("Module initialization", () => { + test("should create Redis client when REDIS_URL is set", async () => { + const { createClient } = await import("redis"); + + // Re-import the module to trigger initialization + await import("./redis"); + + expect(createClient).toHaveBeenCalledWith({ + url: "redis://localhost:6379", + socket: { + reconnectStrategy: expect.any(Function), + }, + }); + }); + + test("should not create Redis client when REDIS_URL is not set", async () => { + delete process.env.REDIS_URL; + + const { createClient } = await import("redis"); + + // Clear the module cache and re-import + vi.resetModules(); + await import("./redis"); + + expect(createClient).not.toHaveBeenCalled(); + }); + + test("should set up event listeners", async () => { + // Re-import the module to trigger initialization + await import("./redis"); + + expect(mockRedisClient.on).toHaveBeenCalledWith("error", expect.any(Function)); + expect(mockRedisClient.on).toHaveBeenCalledWith("connect", expect.any(Function)); + expect(mockRedisClient.on).toHaveBeenCalledWith("reconnecting", expect.any(Function)); + expect(mockRedisClient.on).toHaveBeenCalledWith("ready", expect.any(Function)); + }); + + test("should attempt initial connection", async () => { + // Re-import the module to trigger initialization + await import("./redis"); + + expect(mockRedisClient.connect).toHaveBeenCalled(); + }); + }); + + describe("getRedisClient", () => { + test("should return client when ready", async () => { + mockRedisClient.isReady = true; + + const { getRedisClient } = await import("./redis"); + const client = getRedisClient(); + + expect(client).toBe(mockRedisClient); + }); + + test("should return null when client is not ready", async () => { + mockRedisClient.isReady = false; + + const { getRedisClient } = await import("./redis"); + const client = getRedisClient(); + + expect(client).toBeNull(); + }); + + test("should return null when no REDIS_URL is set", async () => { + delete process.env.REDIS_URL; + + vi.resetModules(); + const { getRedisClient } = await import("./redis"); + const client = getRedisClient(); + + expect(client).toBeNull(); + }); + }); + + describe("disconnectRedis", () => { + test("should disconnect the client", async () => { + const { disconnectRedis } = await import("./redis"); + + await disconnectRedis(); + + expect(mockRedisClient.disconnect).toHaveBeenCalled(); + }); + + test("should handle case when client is null", async () => { + delete process.env.REDIS_URL; + + vi.resetModules(); + const { disconnectRedis } = await import("./redis"); + + await expect(disconnectRedis()).resolves.toBeUndefined(); + }); + }); + + describe("Reconnection strategy", () => { + test("should configure reconnection strategy properly", async () => { + const { createClient } = await import("redis"); + + // Re-import the module to trigger initialization + await import("./redis"); + + const createClientCall = vi.mocked(createClient).mock.calls[0]; + const config = createClientCall[0] as any; + + expect(config.socket.reconnectStrategy).toBeDefined(); + expect(typeof config.socket.reconnectStrategy).toBe("function"); + }); + }); + + describe("Event handlers", () => { + test("should log error events", async () => { + const { logger } = await import("@formbricks/logger"); + + // Re-import the module to trigger initialization + await import("./redis"); + + // Find the error event handler + const errorCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "error"); + const errorHandler = errorCall?.[1]; + + const testError = new Error("Test error"); + errorHandler?.(testError); + + expect(logger.error).toHaveBeenCalledWith("Redis client error:", testError); + }); + + test("should log connect events", async () => { + const { logger } = await import("@formbricks/logger"); + + // Re-import the module to trigger initialization + await import("./redis"); + + // Find the connect event handler + const connectCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "connect"); + const connectHandler = connectCall?.[1]; + + connectHandler?.(); + + expect(logger.info).toHaveBeenCalledWith("Redis client connected"); + }); + + test("should log reconnecting events", async () => { + const { logger } = await import("@formbricks/logger"); + + // Re-import the module to trigger initialization + await import("./redis"); + + // Find the reconnecting event handler + const reconnectingCall = vi + .mocked(mockRedisClient.on) + .mock.calls.find((call) => call[0] === "reconnecting"); + const reconnectingHandler = reconnectingCall?.[1]; + + reconnectingHandler?.(); + + expect(logger.info).toHaveBeenCalledWith("Redis client reconnecting"); + }); + + test("should log ready events", async () => { + const { logger } = await import("@formbricks/logger"); + + // Re-import the module to trigger initialization + await import("./redis"); + + // Find the ready event handler + const readyCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "ready"); + const readyHandler = readyCall?.[1]; + + readyHandler?.(); + + expect(logger.info).toHaveBeenCalledWith("Redis client ready"); + }); + + test("should log end events", async () => { + const { logger } = await import("@formbricks/logger"); + + // Re-import the module to trigger initialization + await import("./redis"); + + // Find the end event handler + const endCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "end"); + const endHandler = endCall?.[1]; + + endHandler?.(); + + expect(logger.info).toHaveBeenCalledWith("Redis client disconnected"); + }); + }); + + describe("Connection failure handling", () => { + test("should handle initial connection failure", async () => { + const { logger } = await import("@formbricks/logger"); + + const connectionError = new Error("Connection failed"); + mockRedisClient.connect.mockRejectedValue(connectionError); + + vi.resetModules(); + await import("./redis"); + + // Wait for the connection promise to resolve + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(logger.error).toHaveBeenCalledWith("Initial Redis connection failed:", connectionError); + }); + }); +}); diff --git a/apps/web/modules/cache/redis.ts b/apps/web/modules/cache/redis.ts new file mode 100644 index 000000000000..985611639945 --- /dev/null +++ b/apps/web/modules/cache/redis.ts @@ -0,0 +1,69 @@ +import { createClient } from "redis"; +import { logger } from "@formbricks/logger"; + +type RedisClient = ReturnType; + +const REDIS_URL = process.env.REDIS_URL; + +let client: RedisClient | null = null; + +if (REDIS_URL) { + client = createClient({ + url: REDIS_URL, + socket: { + reconnectStrategy: (retries) => { + logger.info(`Redis reconnection attempt ${retries}`); + + // For the first 5 attempts, use exponential backoff with max 5 second delay + if (retries <= 5) { + return Math.min(retries * 1000, 5000); + } + + // After 5 attempts, use a longer delay but never give up + // This ensures the client keeps trying to reconnect when Redis comes back online + logger.info("Redis reconnection using extended delay (30 seconds)"); + return 30000; // 30 second delay for persistent reconnection attempts + }, + }, + }); + + client.on("error", (err) => { + logger.error("Redis client error:", err); + }); + + client.on("connect", () => { + logger.info("Redis client connected"); + }); + + client.on("reconnecting", () => { + logger.info("Redis client reconnecting"); + }); + + client.on("ready", () => { + logger.info("Redis client ready"); + }); + + client.on("end", () => { + logger.info("Redis client disconnected"); + }); + + // Connect immediately + client.connect().catch((err) => { + logger.error("Initial Redis connection failed:", err); + }); +} + +export const getRedisClient = (): RedisClient | null => { + if (!client?.isReady) { + logger.warn("Redis client not ready, operations will be skipped"); + return null; + } + return client; +}; + +export const disconnectRedis = async (): Promise => { + if (client) { + await client.disconnect(); + client = null; + } +}; diff --git a/apps/web/modules/core/rate-limit/helpers.test.ts b/apps/web/modules/core/rate-limit/helpers.test.ts new file mode 100644 index 000000000000..458044184a97 --- /dev/null +++ b/apps/web/modules/core/rate-limit/helpers.test.ts @@ -0,0 +1,199 @@ +import { hashString } from "@/lib/hash-string"; +// Import modules after mocking +import { getClientIpFromHeaders } from "@/lib/utils/client-ip"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; +import { err, ok } from "@formbricks/types/error-handlers"; +import { applyIPRateLimit, applyRateLimit, getClientIdentifier } from "./helpers"; +import { checkRateLimit } from "./rate-limit"; + +// Mock all dependencies +vi.mock("@/lib/utils/client-ip", () => ({ + getClientIpFromHeaders: vi.fn(), +})); + +vi.mock("@/lib/hash-string", () => ({ + hashString: vi.fn(), +})); + +vi.mock("./rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + }, +})); + +describe("helpers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getClientIdentifier", () => { + test("should get client IP and return hashed identifier", async () => { + const mockIp = "192.168.1.1"; + const mockHashedIp = "abc123hashedip"; + + (getClientIpFromHeaders as any).mockResolvedValue(mockIp); + (hashString as any).mockReturnValue(mockHashedIp); + + const result = await getClientIdentifier(); + + expect(getClientIpFromHeaders).toHaveBeenCalledOnce(); + expect(hashString).toHaveBeenCalledWith(mockIp); + expect(result).toBe(mockHashedIp); + + // Verify no error was logged on successful hashing + expect(logger.error).not.toHaveBeenCalled(); + }); + + test("should handle IP retrieval errors", async () => { + const mockError = new Error("Failed to get IP"); + (getClientIpFromHeaders as any).mockRejectedValue(mockError); + + await expect(getClientIdentifier()).rejects.toThrow("Failed to get IP"); + }); + + test("should handle hashing errors with proper logging", async () => { + const mockIp = "192.168.1.1"; + const originalError = new Error("Hashing failed"); + + (getClientIpFromHeaders as any).mockResolvedValue(mockIp); + (hashString as any).mockImplementation(() => { + throw originalError; + }); + + await expect(getClientIdentifier()).rejects.toThrow("Failed to hash IP"); + + // Verify that the error was logged with proper context + expect(logger.error).toHaveBeenCalledWith("Failed to hash IP", { error: originalError }); + }); + }); + + describe("applyRateLimit", () => { + const mockConfig = { + interval: 300, + allowedPerInterval: 5, + namespace: "test", + }; + + const mockIdentifier = "test-identifier"; + + test("should allow request when rate limit check passes", async () => { + (checkRateLimit as any).mockResolvedValue(ok({ allowed: true })); + + await expect(applyRateLimit(mockConfig, mockIdentifier)).resolves.toBeUndefined(); + + expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier); + }); + + test("should throw error when rate limit is exceeded", async () => { + (checkRateLimit as any).mockResolvedValue(ok({ allowed: false })); + + await expect(applyRateLimit(mockConfig, mockIdentifier)).rejects.toThrow( + "Maximum number of requests reached. Please try again later." + ); + + expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier); + }); + + test("should throw error when rate limit check fails", async () => { + (checkRateLimit as any).mockResolvedValue(err("Redis connection failed")); + + await expect(applyRateLimit(mockConfig, mockIdentifier)).rejects.toThrow( + "Maximum number of requests reached. Please try again later." + ); + + expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier); + }); + + test("should throw error when rate limit check throws exception", async () => { + const mockError = new Error("Unexpected error"); + (checkRateLimit as any).mockRejectedValue(mockError); + + await expect(applyRateLimit(mockConfig, mockIdentifier)).rejects.toThrow("Unexpected error"); + + expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier); + }); + + test("should work with different configurations", async () => { + const customConfig = { + interval: 3600, + allowedPerInterval: 100, + namespace: "api:v1", + }; + + (checkRateLimit as any).mockResolvedValue(ok({ allowed: true })); + + await expect(applyRateLimit(customConfig, "api-key-identifier")).resolves.toBeUndefined(); + + expect(checkRateLimit).toHaveBeenCalledWith(customConfig, "api-key-identifier"); + }); + + test("should work with different identifiers", async () => { + (checkRateLimit as any).mockResolvedValue(ok({ allowed: true })); + + const identifiers = ["user-123", "ip-192.168.1.1", "auth-login-hashedip", "api-key-abc123"]; + + for (const identifier of identifiers) { + await expect(applyRateLimit(mockConfig, identifier)).resolves.toBeUndefined(); + expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, identifier); + } + + expect(checkRateLimit).toHaveBeenCalledTimes(identifiers.length); + }); + }); + + describe("applyIPRateLimit", () => { + test("should be a convenience function that gets IP and applies rate limit", async () => { + // This is an integration test - the function calls getClientIdentifier internally + // and then calls applyRateLimit, which we've already tested extensively + const mockConfig = { + interval: 3600, + allowedPerInterval: 100, + namespace: "test:page", + }; + + // Mock the IP getting functions + (getClientIpFromHeaders as any).mockResolvedValue("192.168.1.1"); + (hashString as any).mockReturnValue("hashed-ip-123"); + (checkRateLimit as any).mockResolvedValue(ok({ allowed: true })); + + await expect(applyIPRateLimit(mockConfig)).resolves.toBeUndefined(); + + expect(getClientIpFromHeaders).toHaveBeenCalledTimes(1); + expect(hashString).toHaveBeenCalledWith("192.168.1.1"); + expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, "hashed-ip-123"); + }); + + test("should propagate errors from getClientIdentifier", async () => { + const mockConfig = { + interval: 3600, + allowedPerInterval: 100, + namespace: "test:page", + }; + + (getClientIpFromHeaders as any).mockRejectedValue(new Error("IP fetch failed")); + + await expect(applyIPRateLimit(mockConfig)).rejects.toThrow("IP fetch failed"); + }); + + test("should propagate rate limit exceeded errors", async () => { + const mockConfig = { + interval: 3600, + allowedPerInterval: 100, + namespace: "test:page", + }; + + (getClientIpFromHeaders as any).mockResolvedValue("192.168.1.1"); + (hashString as any).mockReturnValue("hashed-ip-123"); + (checkRateLimit as any).mockResolvedValue(ok({ allowed: false })); + + await expect(applyIPRateLimit(mockConfig)).rejects.toThrow( + "Maximum number of requests reached. Please try again later." + ); + }); + }); +}); diff --git a/apps/web/modules/core/rate-limit/helpers.ts b/apps/web/modules/core/rate-limit/helpers.ts new file mode 100644 index 000000000000..d61500b7851b --- /dev/null +++ b/apps/web/modules/core/rate-limit/helpers.ts @@ -0,0 +1,52 @@ +import { hashString } from "@/lib/hash-string"; +import { getClientIpFromHeaders } from "@/lib/utils/client-ip"; +import { logger } from "@formbricks/logger"; +import { TooManyRequestsError } from "@formbricks/types/errors"; +import { checkRateLimit } from "./rate-limit"; +import { type TRateLimitConfig } from "./types/rate-limit"; + +/** + * Get client identifier for rate limiting with IP hashing + * Used when the user is not authenticated or the api is called from the client + * + * @returns {Promise} Hashed IP address for rate limiting + * @throws {Error} When IP hashing fails due to invalid IP format or hashing algorithm issues + */ +export const getClientIdentifier = async (): Promise => { + const ip = await getClientIpFromHeaders(); + + try { + return hashString(ip); + } catch (error) { + const errorMessage = "Failed to hash IP"; + logger.error(errorMessage, { error }); + throw new Error(errorMessage); + } +}; + +/** + * Generic rate limit application function + * + * @param config - Rate limit configuration + * @param identifier - Unique identifier for rate limiting (IP hash, user ID, API key, etc.) + * @throws {Error} When rate limit is exceeded or rate limiting system fails + */ +export const applyRateLimit = async (config: TRateLimitConfig, identifier: string): Promise => { + const result = await checkRateLimit(config, identifier); + + if (!result.ok || !result.data.allowed) { + throw new TooManyRequestsError("Maximum number of requests reached. Please try again later."); + } +}; + +/** + * Apply IP-based rate limiting for unauthenticated requests + * Generic function for IP-based rate limiting in authentication flows and public pages + * + * @param config - Rate limit configuration to apply + * @throws {Error} When rate limit is exceeded or IP hashing fails + */ +export const applyIPRateLimit = async (config: TRateLimitConfig): Promise => { + const identifier = await getClientIdentifier(); + await applyRateLimit(config, identifier); +}; diff --git a/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts b/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts new file mode 100644 index 000000000000..7df4ff274d21 --- /dev/null +++ b/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts @@ -0,0 +1,179 @@ +import { ZRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { applyRateLimit } from "./helpers"; +import { checkRateLimit } from "./rate-limit"; +import { rateLimitConfigs } from "./rate-limit-configs"; + +const { mockEval, mockRedisClient, mockGetRedisClient } = vi.hoisted(() => { + const _mockEval = vi.fn(); + const _mockRedisClient = { eval: _mockEval } as any; + const _mockGetRedisClient = vi.fn().mockReturnValue(_mockRedisClient); + return { mockEval: _mockEval, mockRedisClient: _mockRedisClient, mockGetRedisClient: _mockGetRedisClient }; +}); + +// Mock dependencies for integration tests +vi.mock("@/lib/constants", () => ({ + REDIS_URL: "redis://localhost:6379", + RATE_LIMITING_DISABLED: false, + SENTRY_DSN: "https://test@sentry.io/test", +})); + +vi.mock("@/modules/cache/redis", () => ({ + getRedisClient: mockGetRedisClient, +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@sentry/nextjs", () => ({ + addBreadcrumb: vi.fn(), + captureException: vi.fn(), +})); + +vi.mock("@/modules/cache/lib/cacheKeys", () => ({ + createCacheKey: { + rateLimit: { + core: vi.fn( + (namespace, identifier, windowStart) => `fb:rate_limit:${namespace}:${identifier}:${windowStart}` + ), + }, + }, +})); + +describe("rateLimitConfigs", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset the mock to return our mock client + mockGetRedisClient.mockReturnValue(mockRedisClient); + }); + + describe("Configuration Structure", () => { + test("should have all required config groups", () => { + expect(rateLimitConfigs).toHaveProperty("auth"); + expect(rateLimitConfigs).toHaveProperty("api"); + expect(rateLimitConfigs).toHaveProperty("actions"); + }); + + test("should have all auth configurations", () => { + const authConfigs = Object.keys(rateLimitConfigs.auth); + expect(authConfigs).toEqual(["login", "signup", "forgotPassword", "verifyEmail"]); + }); + + test("should have all API configurations", () => { + const apiConfigs = Object.keys(rateLimitConfigs.api); + expect(apiConfigs).toEqual(["v1", "v2", "client", "syncUserIdentification"]); + }); + + test("should have all action configurations", () => { + const actionConfigs = Object.keys(rateLimitConfigs.actions); + expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp"]); + }); + }); + + describe("Zod Validation", () => { + test("all configurations should pass Zod validation", () => { + const allConfigs = [ + ...Object.values(rateLimitConfigs.auth), + ...Object.values(rateLimitConfigs.api), + ...Object.values(rateLimitConfigs.actions), + ]; + + for (const config of allConfigs) { + expect(() => ZRateLimitConfig.parse(config)).not.toThrow(); + } + }); + }); + + describe("Configuration Logic", () => { + test("all namespaces should be unique", () => { + const allNamespaces: string[] = []; + + // Collect all namespaces + Object.values(rateLimitConfigs.auth).forEach((config) => allNamespaces.push(config.namespace)); + Object.values(rateLimitConfigs.api).forEach((config) => allNamespaces.push(config.namespace)); + Object.values(rateLimitConfigs.actions).forEach((config) => allNamespaces.push(config.namespace)); + + const uniqueNamespaces = new Set(allNamespaces); + expect(uniqueNamespaces.size).toBe(allNamespaces.length); + }); + }); + + describe("Integration with Rate Limiting", () => { + test("should work with checkRateLimit function", async () => { + mockEval.mockResolvedValue([1, 1]); + + const config = rateLimitConfigs.auth.login; + const result = await checkRateLimit(config, "test-identifier"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.allowed).toBe(true); + } + }); + + test("should work with applyRateLimit helper", async () => { + mockEval.mockResolvedValue([1, 1]); + + const config = rateLimitConfigs.api.v1; + await expect(applyRateLimit(config, "api-key-123")).resolves.toBeUndefined(); + }); + + test("should enforce limits correctly for each config type", async () => { + const testCases = [ + { config: rateLimitConfigs.auth.login, identifier: "user-login" }, + { config: rateLimitConfigs.auth.signup, identifier: "user-signup" }, + { config: rateLimitConfigs.api.v1, identifier: "api-v1-key" }, + { config: rateLimitConfigs.api.v2, identifier: "api-v2-key" }, + { config: rateLimitConfigs.api.client, identifier: "client-api-key" }, + { config: rateLimitConfigs.api.syncUserIdentification, identifier: "sync-user-id" }, + { config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" }, + ]; + + for (const { config, identifier } of testCases) { + // Test allowed request + mockEval.mockClear(); + mockEval.mockResolvedValue([1, 1]); + const allowedResult = await checkRateLimit(config, identifier); + expect(allowedResult.ok).toBe(true); + expect((allowedResult as any).data.allowed).toBe(true); + + // Test exceeded limit + mockEval.mockClear(); + mockEval.mockResolvedValue([config.allowedPerInterval + 1, 0]); + const exceededResult = await checkRateLimit(config, identifier); + expect(exceededResult.ok).toBe(true); + expect((exceededResult as any).data.allowed).toBe(false); + } + }); + + test("should properly configure syncUserIdentification rate limit", async () => { + const config = rateLimitConfigs.api.syncUserIdentification; + + // Verify configuration values + expect(config.interval).toBe(60); // 1 minute + expect(config.allowedPerInterval).toBe(5); // 5 requests per minute + expect(config.namespace).toBe("api:sync-user-identification"); + + // Test with allowed request + mockEval.mockResolvedValue([1, 1]); // 1 request used, allowed (1 = true) + const allowedResult = await checkRateLimit(config, "env-user-123"); + expect(allowedResult.ok).toBe(true); + if (allowedResult.ok) { + expect(allowedResult.data.allowed).toBe(true); + } + + // Test when limit is exceeded + mockEval.mockResolvedValue([6, 0]); // 6 requests used (exceeds limit of 5), not allowed (0 = false) + const exceededResult = await checkRateLimit(config, "env-user-123"); + expect(exceededResult.ok).toBe(true); + if (exceededResult.ok) { + expect(exceededResult.data.allowed).toBe(false); + } + }); + }); +}); diff --git a/apps/web/modules/core/rate-limit/rate-limit-configs.ts b/apps/web/modules/core/rate-limit/rate-limit-configs.ts new file mode 100644 index 000000000000..c3df9b561abf --- /dev/null +++ b/apps/web/modules/core/rate-limit/rate-limit-configs.ts @@ -0,0 +1,27 @@ +export const rateLimitConfigs = { + // Authentication endpoints - stricter limits for security + auth: { + login: { interval: 900, allowedPerInterval: 30, namespace: "auth:login" }, // 30 per 15 minutes + signup: { interval: 3600, allowedPerInterval: 30, namespace: "auth:signup" }, // 30 per hour + forgotPassword: { interval: 3600, allowedPerInterval: 5, namespace: "auth:forgot" }, // 5 per hour + verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" }, // 10 per hour + }, + + // API endpoints - higher limits for legitimate usage + api: { + v1: { interval: 60, allowedPerInterval: 100, namespace: "api:v1" }, // 100 per minute (Management API) + v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" }, // 100 per minute + client: { interval: 60, allowedPerInterval: 100, namespace: "api:client" }, // 100 per minute (Client API) + syncUserIdentification: { + interval: 60, + allowedPerInterval: 5, + namespace: "api:sync-user-identification", + }, // 5 per minute per environment-user pair + }, + + // Server actions - varies by action type + actions: { + emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour + surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour + }, +}; diff --git a/apps/web/modules/core/rate-limit/rate-limit-load.test.ts b/apps/web/modules/core/rate-limit/rate-limit-load.test.ts new file mode 100644 index 000000000000..5afcfcb3b2fe --- /dev/null +++ b/apps/web/modules/core/rate-limit/rate-limit-load.test.ts @@ -0,0 +1,542 @@ +import { getRedisClient } from "@/modules/cache/redis"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { applyRateLimit } from "./helpers"; +import { checkRateLimit } from "./rate-limit"; +import { TRateLimitConfig } from "./types/rate-limit"; + +// Check if Redis is available (basic requirements) +let isRedisAvailable = false; + +// Test Redis availability +async function checkRedisAvailability() { + try { + const redis = getRedisClient(); + if (redis === null) { + console.log("Redis client is null - Redis not available"); + return false; + } + + // Test basic Redis operation + await redis.ping(); + console.log("Redis ping successful - Redis is available"); + return true; + } catch (error) { + console.error("Error checking Redis availability:", error); + return false; + } +} + +/** + * Rate Limiter Load Tests - Race Condition Detection + * + * This test suite verifies that the rate limiter implementation is free from race conditions + * and handles high concurrency correctly. The rate limiter uses Redis with Lua scripts for + * atomic operations to prevent race conditions in multi-pod Kubernetes environments. + * + * Prerequisites: + * - Redis server must be running and accessible + * - REDIS_URL environment variable must be set to a valid Redis connection string + * - Tests will be automatically skipped if REDIS_URL is empty or Redis client is not available + * + * Running the tests: + * Local development: cd apps/web && npx vitest run modules/core/rate-limit/rate-limit-load.test.ts + * CI Environment: Tests run automatically in E2E workflow with Redis/Valkey service + * + * Test Scenarios: + * + * 1. Basic Race Condition Test + * - Purpose: Verify atomic operations under high concurrency + * - Method: Send 20 concurrent requests to the same identifier (limit: 3) + * - Expected: Exactly 3 requests allowed, 17 denied + * - Failure Indicates: Race conditions in the Redis Lua script + * + * 2. Multiple Waves Test + * - Purpose: Test consistency across multiple request waves + * - Method: Send 3 waves of 15 concurrent requests each (limit: 10) + * - Expected: Exactly 10 requests allowed total across all waves + * - Failure Indicates: Window boundary issues or counter corruption + * + * 3. Different Identifiers Test + * - Purpose: Ensure identifiers don't interfere with each other + * - Method: 5 different identifiers, 10 requests each (limit: 3 per identifier) + * - Expected: Each identifier gets exactly 3 allowed requests + * - Failure Indicates: Key collision or identifier mixing + * + * 4. Window Boundary Test + * - Purpose: Verify correct window expiration and reset + * - Method: Send requests, wait for window expiry, send more requests + * - Expected: Fresh limits after window expiry + * - Failure Indicates: TTL or window calculation issues + * + * 5. High Throughput Stress Test + * - Purpose: Test performance under sustained load within single time window + * - Method: 200 concurrent requests (limit: 50) + * - Expected: Exactly 50 requests allowed, consistent performance + * - Failure Indicates: Performance degradation or counter corruption + * - Note: Fixed to send all requests concurrently to avoid window boundary race conditions + * + * 6. applyRateLimit Function Test + * - Purpose: Test the higher-level wrapper function + * - Method: Concurrent requests using applyRateLimit instead of checkRateLimit + * - Expected: Exact limit compliance with proper error handling + * - Failure Indicates: Issues in the wrapper function logic + * + * 7. Mixed Identifier Patterns Test + * - Purpose: Test real-world identifier patterns under load + * - Method: Different identifier formats running concurrently + * - Expected: Each pattern respects its individual limits + * - Failure Indicates: Pattern-specific issues + * + * 8. TTL Expiration Test + * - Purpose: Verify that rate limit keys expire correctly and unblock requests + * - Method: Hit rate limit, wait for TTL expiration, verify unblocking + * - Expected: Keys expire automatically, fresh limits after expiration + * - Failure Indicates: TTL not working, keys not expiring, memory leaks + * + * Success Indicators: + * ✅ Exact limit compliance (no more, no less than configured limit) + * ✅ Consistent behavior across multiple runs + * ✅ No interference between different identifiers + * ✅ Proper window reset behavior + * + * Failure Indicators: + * ❌ More requests allowed than limit: Race condition in increment + * ❌ Fewer requests allowed than limit: Lock contention or failed operations + * ❌ Identifier interference: Key collision or namespace issues + * ❌ Window boundary failures: TTL or timestamp calculation errors + */ + +// The availability check and logging is now handled in the beforeAll hook + +// Test configurations +const TEST_CONFIGS = { + // Very restrictive for race condition testing + strict: { + interval: 5, // 5 seconds + allowedPerInterval: 3, + namespace: "test:strict", + } as TRateLimitConfig, + + // Medium restrictive + medium: { + interval: 10, + allowedPerInterval: 10, + namespace: "test:medium", + } as TRateLimitConfig, + + // High throughput + high: { + interval: 5, + allowedPerInterval: 50, + namespace: "test:high", + } as TRateLimitConfig, +} as const; + +describe("Rate Limiter Load Tests - Race Conditions", () => { + beforeAll(async () => { + // Check Redis availability first + isRedisAvailable = await checkRedisAvailability(); + + if (!isRedisAvailable) { + console.log("🟡 Rate Limiter Load Tests: Redis not available - tests will be skipped"); + console.log(" To run these tests locally, ensure Redis is running and REDIS_URL is set"); + return; + } + + console.log("🟢 Rate Limiter Load Tests: Redis available - tests will run"); + + // Clear any existing test keys + const redis = getRedisClient(); + if (redis) { + const testKeys = await redis.keys("fb:rate_limit:test:*"); + if (testKeys.length > 0) { + await redis.del(testKeys); + } + } + }); + + afterAll(async () => { + // Clean up test keys + const redis = getRedisClient(); + if (redis) { + const testKeys = await redis.keys("fb:rate_limit:test:*"); + if (testKeys.length > 0) { + await redis.del(testKeys); + } + } + }); + + test("Race condition test: concurrent requests to same identifier", async () => { + if (!isRedisAvailable) { + console.log("Skipping test: Redis not available"); + return; + } + + const config = TEST_CONFIGS.strict; + const identifier = "race-test-same-id"; + const concurrentRequests = 20; // More than allowed (3) + + // Create array of concurrent promises + const promises = Array.from({ length: concurrentRequests }, () => checkRateLimit(config, identifier)); + + // Execute all requests concurrently + const results = await Promise.all(promises); + + // Count allowed vs denied requests + const allowed = results.filter((r) => r.ok && r.data.allowed).length; + const denied = results.filter((r) => r.ok && !r.data.allowed).length; + + console.log(`Race condition test results: ${allowed} allowed, ${denied} denied`); + + // Should allow exactly the configured limit + expect(allowed).toBe(config.allowedPerInterval); + expect(denied).toBe(concurrentRequests - config.allowedPerInterval); + expect(allowed + denied).toBe(concurrentRequests); + }, 15000); + + test("Race condition test: multiple waves of concurrent requests", async () => { + if (!isRedisAvailable) { + console.log("Skipping test: Redis not available"); + return; + } + + const config = TEST_CONFIGS.medium; + const identifier = "race-test-waves"; + const wavesCount = 3; + const requestsPerWave = 15; // More than allowed (10) + + const allResults: Array>> = []; + + // Send waves of concurrent requests (no delay to ensure same window) + for (let wave = 0; wave < wavesCount; wave++) { + const promises = Array.from({ length: requestsPerWave }, () => checkRateLimit(config, identifier)); + + const waveResults = await Promise.all(promises); + allResults.push(...waveResults); + + // No delay - we want all waves in the same window for this test + } + + const totalAllowed = allResults.filter((r) => r.ok && r.data.allowed).length; + const totalDenied = allResults.filter((r) => r.ok && !r.data.allowed).length; + + console.log(`Multi-wave test: ${totalAllowed} allowed, ${totalDenied} denied`); + + // Should still only allow the configured limit across all waves + expect(totalAllowed).toBe(config.allowedPerInterval); + expect(totalDenied).toBe(wavesCount * requestsPerWave - config.allowedPerInterval); + }, 20000); + + test("Race condition test: different identifiers should not interfere", async () => { + if (!isRedisAvailable) { + console.log("Skipping test: Redis not available"); + return; + } + + const config = TEST_CONFIGS.strict; + const identifiersCount = 5; + const requestsPerIdentifier = 10; + + // Create promises for multiple identifiers concurrently + const allPromises: Promise<{ identifier: string; result: Awaited> }>[] = + []; + for (let i = 0; i < identifiersCount; i++) { + const identifier = `race-test-different-${i}`; + for (let j = 0; j < requestsPerIdentifier; j++) { + allPromises.push(checkRateLimit(config, identifier).then((result) => ({ identifier, result }))); + } + } + + // Execute all requests concurrently + const results = await Promise.all(allPromises); + + // Group results by identifier + const resultsByIdentifier = results.reduce( + (acc, { identifier, result }) => { + if (!acc[identifier]) acc[identifier] = []; + acc[identifier].push(result); + return acc; + }, + {} as Record + ); + + // Each identifier should have exactly the allowed limit + Object.entries(resultsByIdentifier).forEach(([identifier, identifierResults]) => { + const allowed = identifierResults.filter((r) => r.ok && r.data.allowed).length; + const denied = identifierResults.filter((r) => r.ok && !r.data.allowed).length; + + console.log(`Identifier ${identifier}: ${allowed} allowed, ${denied} denied`); + + expect(allowed).toBe(config.allowedPerInterval); + expect(denied).toBe(requestsPerIdentifier - config.allowedPerInterval); + }); + }, 20000); + + test("Window boundary race condition test", async () => { + if (!isRedisAvailable) { + console.log("Skipping test: Redis not available"); + return; + } + + const config = { + interval: 2, // Very short window for testing + allowedPerInterval: 5, + namespace: "test:boundary", + } as TRateLimitConfig; + + const identifier = "boundary-test"; + + // First batch of requests + const firstBatch = Array.from({ length: 8 }, () => checkRateLimit(config, identifier)); + + const firstResults = await Promise.all(firstBatch); + const firstAllowed = firstResults.filter((r) => r.ok && r.data.allowed).length; + + console.log(`First batch: ${firstAllowed} allowed`); + expect(firstAllowed).toBe(config.allowedPerInterval); + + // Wait for window to expire + await new Promise((resolve) => setTimeout(resolve, config.interval * 1000 + 100)); + + // Second batch should get fresh limits + const secondBatch = Array.from({ length: 8 }, () => checkRateLimit(config, identifier)); + + const secondResults = await Promise.all(secondBatch); + const secondAllowed = secondResults.filter((r) => r.ok && r.data.allowed).length; + + console.log(`Second batch: ${secondAllowed} allowed`); + expect(secondAllowed).toBe(config.allowedPerInterval); + }, 15000); + + test("High throughput stress test", async () => { + if (!isRedisAvailable) { + console.log("Skipping test: Redis not available"); + return; + } + + const config = TEST_CONFIGS.high; + const totalRequests = 200; + const identifier = "stress-test"; + + // Clear any existing keys first to ensure clean state + const redis = getRedisClient(); + if (redis) { + const existingKeys = await redis.keys(`fb:rate_limit:${config.namespace}:*`); + if (existingKeys.length > 0) { + await redis.del(existingKeys); + } + } + + // Send ALL requests concurrently within the same time window + // This eliminates window boundary race conditions that caused intermittent failures + const allPromises = Array.from({ length: totalRequests }, () => checkRateLimit(config, identifier)); + + console.log(`Sending ${totalRequests} concurrent requests...`); + const results = await Promise.all(allPromises); + + const totalAllowed = results.filter((r) => r.ok && r.data.allowed).length; + const totalDenied = results.filter((r) => r.ok && !r.data.allowed).length; + + console.log(`Stress test: ${totalAllowed} allowed, ${totalDenied} denied`); + + // Should respect the rate limit even under high load + expect(totalAllowed).toBe(config.allowedPerInterval); + expect(totalDenied).toBe(totalRequests - config.allowedPerInterval); + expect(totalAllowed + totalDenied).toBe(totalRequests); + }, 30000); + + test("applyRateLimit function race condition test", async () => { + if (!isRedisAvailable) { + console.log("Skipping test: Redis not available"); + return; + } + + const config = TEST_CONFIGS.strict; + const identifier = "apply-rate-limit-test"; + const concurrentRequests = 15; + + // Test the higher-level applyRateLimit function + const promises = Array.from({ length: concurrentRequests }, async () => { + try { + await applyRateLimit(config, identifier); + return { success: true, error: null }; + } catch (error) { + return { success: false, error: error.message }; + } + }); + + const results = await Promise.all(promises); + + const successes = results.filter((r) => r.success).length; + const failures = results.filter((r) => !r.success).length; + + console.log(`applyRateLimit test: ${successes} successes, ${failures} failures`); + + // Should allow exactly the configured limit + expect(successes).toBe(config.allowedPerInterval); + expect(failures).toBe(concurrentRequests - config.allowedPerInterval); + + // All failures should be "Maximum number of requests reached. Please try again later." + const rateLimitErrors = results.filter( + (r) => r.error === "Maximum number of requests reached. Please try again later." + ).length; + expect(rateLimitErrors).toBe(failures); + }, 15000); + + test("Mixed identifier patterns under load", async () => { + if (!isRedisAvailable) { + console.log("Skipping test: Redis not available"); + return; + } + + const config = TEST_CONFIGS.medium; + const patterns = ["user-123", "ip-192.168.1.1", "api-key-abc", "session-xyz"]; + + const requestsPerPattern = 15; + + // Create mixed concurrent requests + const allPromises: Promise<{ pattern: string; result: any }>[] = []; + for (const pattern of patterns) { + for (let i = 0; i < requestsPerPattern; i++) { + allPromises.push(checkRateLimit(config, pattern).then((result) => ({ pattern, result }))); + } + } + + // Shuffle the array to simulate random request order + for (let i = allPromises.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [allPromises[i], allPromises[j]] = [allPromises[j], allPromises[i]]; + } + + const results = await Promise.all(allPromises); + + // Group and verify results + const resultsByPattern = results.reduce( + (acc, { pattern, result }) => { + if (!acc[pattern]) acc[pattern] = []; + acc[pattern].push(result); + return acc; + }, + {} as Record + ); + + Object.entries(resultsByPattern).forEach(([pattern, patternResults]) => { + const allowed = patternResults.filter((r) => r.ok && r.data.allowed).length; + const denied = patternResults.filter((r) => r.ok && !r.data.allowed).length; + + console.log(`Pattern ${pattern}: ${allowed} allowed, ${denied} denied`); + + expect(allowed).toBe(config.allowedPerInterval); + expect(denied).toBe(requestsPerPattern - config.allowedPerInterval); + }); + }, 25000); + + test("TTL expiration test: rate limit key should expire and unblock requests", async () => { + if (!isRedisAvailable) { + console.log("Skipping test: Redis not available"); + return; + } + + // Use a very short interval for faster testing + const config: TRateLimitConfig = { + interval: 3, // 3 seconds + allowedPerInterval: 2, + namespace: "test:ttl", + }; + + const identifier = "ttl-test-user"; + + // Clear any existing keys first + const redis = getRedisClient(); + if (redis) { + const existingKeys = await redis.keys(`fb:rate_limit:${config.namespace}:*`); + if (existingKeys.length > 0) { + await redis.del(existingKeys); + } + } + + console.log("Phase 1: Hitting rate limit..."); + + // Phase 1: Make requests until rate limit is hit + const phase1Promises = Array.from({ length: 5 }, () => checkRateLimit(config, identifier)); + + const phase1Results = await Promise.all(phase1Promises); + const phase1Allowed = phase1Results.filter((r) => r.ok && r.data.allowed).length; + const phase1Denied = phase1Results.filter((r) => r.ok && !r.data.allowed).length; + + console.log(`Phase 1 results: ${phase1Allowed} allowed, ${phase1Denied} denied`); + + // Verify rate limit is working + expect(phase1Allowed).toBe(config.allowedPerInterval); + expect(phase1Denied).toBe(5 - config.allowedPerInterval); + + // Check that the key exists in Redis + if (redis) { + const now = Date.now(); + const windowStart = Math.floor(now / (config.interval * 1000)) * config.interval; + const expectedKey = `fb:rate_limit:${config.namespace}:${identifier}:${windowStart}`; + + const keyExists = await redis.exists(expectedKey); + expect(keyExists).toBe(1); + console.log(`Redis key exists: ${expectedKey}`); + + // Check the TTL + const ttl = await redis.ttl(expectedKey); + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(config.interval); + console.log(`Key TTL: ${ttl} seconds`); + + // Phase 2: Wait for TTL to expire + console.log(`Phase 2: Waiting for TTL expiration (${config.interval + 1} seconds)...`); + await new Promise((resolve) => setTimeout(resolve, (config.interval + 1) * 1000)); + + // Verify key has been automatically deleted by Redis + const keyExistsAfterTTL = await redis.exists(expectedKey); + expect(keyExistsAfterTTL).toBe(0); + console.log("Key automatically deleted by Redis TTL ✅"); + } + + // Phase 3: Make new requests after TTL expiration + console.log("Phase 3: Making requests after TTL expiration..."); + + const phase3Promises = Array.from({ length: 5 }, () => checkRateLimit(config, identifier)); + + const phase3Results = await Promise.all(phase3Promises); + const phase3Allowed = phase3Results.filter((r) => r.ok && r.data.allowed).length; + const phase3Denied = phase3Results.filter((r) => r.ok && !r.data.allowed).length; + + console.log(`Phase 3 results: ${phase3Allowed} allowed, ${phase3Denied} denied`); + + // Should get fresh limits after TTL expiration + expect(phase3Allowed).toBe(config.allowedPerInterval); + expect(phase3Denied).toBe(5 - config.allowedPerInterval); + + // Verify new key was created for the new window + if (redis) { + const newNow = Date.now(); + const newWindowStart = Math.floor(newNow / (config.interval * 1000)) * config.interval; + const newKey = `fb:rate_limit:${config.namespace}:${identifier}:${newWindowStart}`; + + const newKeyExists = await redis.exists(newKey); + expect(newKeyExists).toBe(1); + console.log(`New Redis key created: ${newKey}`); + } + + // Phase 4: Test that we're blocked again within the new window + console.log("Phase 4: Verifying rate limit is active in new window..."); + + const phase4Promises = Array.from({ length: 3 }, () => checkRateLimit(config, identifier)); + + const phase4Results = await Promise.all(phase4Promises); + const phase4Allowed = phase4Results.filter((r) => r.ok && r.data.allowed).length; + const phase4Denied = phase4Results.filter((r) => r.ok && !r.data.allowed).length; + + console.log(`Phase 4 results: ${phase4Allowed} allowed, ${phase4Denied} denied`); + + // Should be blocked since we already used up the limit in phase 3 + expect(phase4Allowed).toBe(0); + expect(phase4Denied).toBe(3); + + console.log("✅ TTL expiration working correctly - rate limits properly reset after expiration"); + }, 20000); +}); diff --git a/apps/web/modules/core/rate-limit/rate-limit.test.ts b/apps/web/modules/core/rate-limit/rate-limit.test.ts new file mode 100644 index 000000000000..47799a407fcf --- /dev/null +++ b/apps/web/modules/core/rate-limit/rate-limit.test.ts @@ -0,0 +1,344 @@ +// Import modules after mocking +import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +// Import after mocking +import { checkRateLimit } from "./rate-limit"; +import { TRateLimitConfig } from "./types/rate-limit"; + +const { mockEval, mockRedisClient, mockGetRedisClient } = vi.hoisted(() => { + const _mockEval = vi.fn(); + const _mockRedisClient = { + eval: _mockEval, + } as any; + + const _mockGetRedisClient = vi.fn().mockReturnValue(_mockRedisClient); + + return { + mockEval: _mockEval, + mockRedisClient: _mockRedisClient, + mockGetRedisClient: _mockGetRedisClient, + }; +}); + +// Mock all dependencies (will use the hoisted mocks above) +vi.mock("@/modules/cache/redis", () => ({ + getRedisClient: mockGetRedisClient, +})); + +vi.mock("@/lib/constants", () => ({ + REDIS_URL: "redis://localhost:6379", + RATE_LIMITING_DISABLED: false, + SENTRY_DSN: "https://test@sentry.io/test", +})); +vi.mock("@formbricks/logger", () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); +vi.mock("@sentry/nextjs", () => ({ + addBreadcrumb: vi.fn(), + captureException: vi.fn(), +})); + +describe("checkRateLimit", () => { + const testConfig: TRateLimitConfig = { + interval: 300, // 5 minutes + allowedPerInterval: 5, + namespace: "test", + }; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset the mock to return our mock client + mockGetRedisClient.mockReturnValue(mockRedisClient); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Ensure mocks don't leak to other test suites (e.g. load tests) + afterAll(() => { + vi.resetModules(); + vi.resetAllMocks(); + }); + + test("should allow request when under limit", async () => { + // Mock Redis returning count of 2, which is under limit of 5 + mockEval.mockResolvedValue([2, 1]); + + const result = await checkRateLimit(testConfig, "test-user"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.allowed).toBe(true); + } + }); + + test("should deny request when over limit", async () => { + // Mock Redis returning count of 6, which is over limit of 5 + mockEval.mockResolvedValue([6, 0]); + + const result = await checkRateLimit(testConfig, "test-user"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.allowed).toBe(false); + } + }); + + test("should fail open when Redis is unavailable", async () => { + // Mock Redis throwing an error + mockEval.mockRejectedValue(new Error("Redis connection failed")); + + const result = await checkRateLimit(testConfig, "test-user"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.allowed).toBe(true); + } + }); + + test("should fail open when rate limiting is disabled", async () => { + vi.resetModules(); + vi.doMock("@/lib/constants", () => ({ + REDIS_URL: "redis://localhost:6379", + RATE_LIMITING_DISABLED: true, + SENTRY_DSN: "https://test@sentry.io/test", + })); + + // Dynamic import after mocking + const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit"); + const result = await checkRateLimitMocked(testConfig, "test-user"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.allowed).toBe(true); + } + }); + + test("should fail open when Redis is not configured", async () => { + vi.resetModules(); + vi.doMock("@/modules/cache/redis", () => ({ + getRedisClient: vi.fn().mockReturnValue(null), + })); + + // Dynamic import after mocking + const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit"); + const result = await checkRateLimitMocked(testConfig, "test-user"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.allowed).toBe(true); + } + }); + + test("should generate correct Redis key with window alignment", async () => { + mockEval.mockResolvedValue([1, 1]); + + await checkRateLimit(testConfig, "test-user"); + + expect(mockEval).toHaveBeenCalledWith( + expect.stringContaining("redis.call('INCR', key)"), + expect.objectContaining({ + keys: [expect.stringMatching(/^fb:rate_limit:test:test-user:\d+$/)], + arguments: ["5", expect.any(String)], + }) + ); + }); + + test("should use provided namespace", async () => { + const configWithCustomNamespace: TRateLimitConfig = { + interval: 300, + allowedPerInterval: 5, + namespace: "custom", + }; + + mockEval.mockResolvedValue([1, 1]); + + await checkRateLimit(configWithCustomNamespace, "test-user"); + + expect(mockEval).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + keys: [expect.stringMatching(/^fb:rate_limit:custom:test-user:\d+$/)], + arguments: ["5", expect.any(String)], + }) + ); + }); + + test("should calculate correct TTL for window expiration", async () => { + mockEval.mockResolvedValue([1, 1]); + + await checkRateLimit(testConfig, "test-user"); + + // TTL should be between 0 and 300 seconds (window interval) + const ttlUsed = Number.parseInt(mockEval.mock.calls[0][1].arguments[1]); + expect(ttlUsed).toBeGreaterThan(0); + expect(ttlUsed).toBeLessThanOrEqual(300); + }); + + test("should set TTL only on first increment", async () => { + mockEval.mockResolvedValue([1, 1]); + + await checkRateLimit(testConfig, "test-user"); + + // Verify the Lua script contains the conditional TTL logic + const luaScript = mockEval.mock.calls[0][0]; + expect(luaScript).toContain("if current == 1 then"); + expect(luaScript).toContain("redis.call('EXPIRE', key, ttl)"); + expect(luaScript).toContain("end"); + + // Verify script structure for atomic increment and conditional expire + expect(luaScript).toContain("redis.call('INCR', key)"); + expect(luaScript).toContain("return {current, current <= limit and 1 or 0}"); + }); + + test("should not call Sentry when SENTRY_DSN is not configured", async () => { + vi.resetModules(); + + // Re-mock all dependencies after resetModules + vi.doMock("@/lib/constants", () => ({ + REDIS_URL: "redis://localhost:6379", + RATE_LIMITING_DISABLED: false, + SENTRY_DSN: undefined, + })); + + const mockAddBreadcrumb = vi.fn(); + const mockCaptureException = vi.fn(); + vi.doMock("@sentry/nextjs", () => ({ + addBreadcrumb: mockAddBreadcrumb, + captureException: mockCaptureException, + })); + + vi.doMock("@formbricks/logger", () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + })); + + vi.doMock("@/modules/cache/redis", () => ({ + getRedisClient: vi.fn().mockReturnValue({ + eval: vi.fn().mockResolvedValue([6, 0]), + }), + })); + + // Dynamic import after mocking + const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit"); + + await checkRateLimitMocked(testConfig, "test-user"); + + // Verify Sentry functions were not called + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + test("should call Sentry when SENTRY_DSN is configured and rate limit exceeded", async () => { + vi.resetModules(); + + // Re-mock all dependencies after resetModules + vi.doMock("@/lib/constants", () => ({ + REDIS_URL: "redis://localhost:6379", + RATE_LIMITING_DISABLED: false, + SENTRY_DSN: "https://test@sentry.io/test", + })); + + const mockAddBreadcrumb = vi.fn(); + const mockCaptureException = vi.fn(); + vi.doMock("@sentry/nextjs", () => ({ + addBreadcrumb: mockAddBreadcrumb, + captureException: mockCaptureException, + })); + + vi.doMock("@formbricks/logger", () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + })); + + vi.doMock("@/modules/cache/redis", () => ({ + getRedisClient: vi.fn().mockReturnValue({ + eval: vi.fn().mockResolvedValue([6, 0]), + }), + })); + + // Dynamic import after mocking + const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit"); + + await checkRateLimitMocked(testConfig, "test-user"); + + // Verify Sentry breadcrumb was added + expect(mockAddBreadcrumb).toHaveBeenCalledWith({ + message: "Rate limit exceeded", + level: "warning", + data: expect.objectContaining({ + identifier: "test-user", + currentCount: 6, + limit: 5, + namespace: "test", + }), + }); + }); + + test("should call Sentry when SENTRY_DSN is configured and Redis error occurs", async () => { + vi.resetModules(); + + // Re-mock all dependencies after resetModules + vi.doMock("@/lib/constants", () => ({ + REDIS_URL: "redis://localhost:6379", + RATE_LIMITING_DISABLED: false, + SENTRY_DSN: "https://test@sentry.io/test", + })); + + const mockAddBreadcrumb = vi.fn(); + const mockCaptureException = vi.fn(); + vi.doMock("@sentry/nextjs", () => ({ + addBreadcrumb: mockAddBreadcrumb, + captureException: mockCaptureException, + })); + + vi.doMock("@formbricks/logger", () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + })); + + const redisError = new Error("Redis connection failed"); + vi.doMock("@/modules/cache/redis", () => ({ + getRedisClient: vi.fn().mockReturnValue({ + eval: vi.fn().mockRejectedValue(redisError), + }), + })); + + // Dynamic import after mocking + const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit"); + + await checkRateLimitMocked(testConfig, "test-user"); + + // Verify Sentry exception was captured + expect(mockCaptureException).toHaveBeenCalledWith( + redisError, + expect.objectContaining({ + tags: { + component: "rate-limiter", + namespace: "test", + }, + extra: expect.objectContaining({ + error: redisError, + identifier: "test-user", + namespace: "test", + }), + }) + ); + }); +}); diff --git a/apps/web/modules/core/rate-limit/rate-limit.ts b/apps/web/modules/core/rate-limit/rate-limit.ts new file mode 100644 index 000000000000..9f39d9fd08cb --- /dev/null +++ b/apps/web/modules/core/rate-limit/rate-limit.ts @@ -0,0 +1,131 @@ +import { RATE_LIMITING_DISABLED, SENTRY_DSN } from "@/lib/constants"; +import { createCacheKey } from "@/modules/cache/lib/cacheKeys"; +import { getRedisClient } from "@/modules/cache/redis"; +import * as Sentry from "@sentry/nextjs"; +import { logger } from "@formbricks/logger"; +import { Result, ok } from "@formbricks/types/error-handlers"; +import { TRateLimitConfig, type TRateLimitResponse } from "./types/rate-limit"; + +/** + * Atomic Redis-based rate limiter using Lua scripts + * Prevents race conditions in multi-pod Kubernetes environments + */ +export const checkRateLimit = async ( + config: TRateLimitConfig, + identifier: string +): Promise> => { + // Skip rate limiting if disabled + if (RATE_LIMITING_DISABLED) { + logger.debug(`Rate limiting disabled`); + return ok({ + allowed: true, + }); + } + + try { + // Get Redis client + const redis = getRedisClient(); + if (!redis) { + logger.debug(`Redis unavailable`); + return ok({ + allowed: true, + }); + } + + const now = Date.now(); + const windowStart = Math.floor(now / (config.interval * 1000)) * config.interval; + const key = createCacheKey.rateLimit.core(config.namespace, identifier, windowStart); + + // Calculate TTL to expire exactly at window end, value in seconds + const windowEnd = windowStart + config.interval; + // Convert window end from seconds to milliseconds, subtract current time, then convert back to seconds for Redis EXPIRE + const ttlSeconds = Math.ceil((windowEnd * 1000 - now) / 1000); + + // Lua script for atomic increment and conditional expire + // This prevents race conditions between INCR and EXPIRE operations + const luaScript = ` + local key = KEYS[1] + local limit = tonumber(ARGV[1]) + local ttl = tonumber(ARGV[2]) + + -- Atomically increment and get current count + local current = redis.call('INCR', key) + + -- Set TTL only if this is the first increment (avoids extending windows) + if current == 1 then + redis.call('EXPIRE', key, ttl) + end + + -- Return current count and whether it's within limit + return {current, current <= limit and 1 or 0} + `; + + const result = (await redis.eval(luaScript, { + keys: [key], + arguments: [config.allowedPerInterval.toString(), ttlSeconds.toString()], + })) as [number, number]; + const [currentCount, isAllowed] = result; + + // Log debug information for every Redis count increase + logger.debug(`Rate limit check`, { + identifier, + currentCount, + limit: config.allowedPerInterval, + window: config.interval, + key, + allowed: isAllowed === 1, + windowEnd, + }); + + const response: TRateLimitResponse = { + allowed: isAllowed === 1, + }; + + // Log rate limit violations for security monitoring + if (!response.allowed) { + const violationContext = { + identifier, + currentCount, + limit: config.allowedPerInterval, + window: config.interval, + key, + namespace: config.namespace, + }; + + logger.error(`Rate limit exceeded`, violationContext); + + if (SENTRY_DSN) { + // Breadcrumb because the exception will be captured in the error handler + Sentry.addBreadcrumb({ + message: `Rate limit exceeded`, + level: "warning", + data: violationContext, + }); + } + } + + return ok(response); + } catch (error) { + const errorMessage = `Rate limit check failed`; + const errorContext = { error, identifier, namespace: config.namespace }; + + logger.error(errorMessage, errorContext); + + if (SENTRY_DSN) { + // Log error to Sentry + Sentry.captureException(error, { + tags: { + component: "rate-limiter", + namespace: config.namespace, + }, + extra: errorContext, + }); + } + + // Fail open - allow request if rate limiting fails + // This ensures system availability over perfect rate limiting + return ok({ + allowed: true, + }); + } +}; diff --git a/apps/web/modules/core/rate-limit/types/rate-limit.ts b/apps/web/modules/core/rate-limit/types/rate-limit.ts new file mode 100644 index 000000000000..2a5e7d4e7814 --- /dev/null +++ b/apps/web/modules/core/rate-limit/types/rate-limit.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const ZRateLimitConfig = z.object({ + /** Rate limit window in seconds */ + interval: z.number().int().positive().describe("Rate limit window in seconds"), + /** Maximum allowed requests per interval */ + allowedPerInterval: z.number().int().positive().describe("Maximum allowed requests per interval"), + /** Namespace for grouping rate limit per feature */ + namespace: z.string().min(1).describe("Namespace for grouping rate limit per feature"), +}); + +export type TRateLimitConfig = z.infer; + +const ZRateLimitResponse = z.object({ + allowed: z.boolean().describe("Whether the request is allowed"), +}); + +export type TRateLimitResponse = z.infer; diff --git a/apps/web/modules/ee/audit-logs/lib/handler.test.ts b/apps/web/modules/ee/audit-logs/lib/handler.test.ts new file mode 100644 index 000000000000..06ec53ed95e1 --- /dev/null +++ b/apps/web/modules/ee/audit-logs/lib/handler.test.ts @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TActor, TAuditAction, TAuditStatus, TAuditTarget } from "../types/audit-log"; +// Import original module to access its original exports for the mock factory +import * as OriginalHandler from "./handler"; + +// Use 'var' for all mock handles used in vi.mock factories to avoid hoisting/TDZ issues +var serviceLogAuditEventMockHandle: ReturnType; // NOSONAR / test code +var loggerErrorMockHandle: ReturnType; // NOSONAR / test code + +// Use 'var' for mutableConstants due to hoisting issues with vi.mock factories +var mutableConstants: { AUDIT_LOG_ENABLED: boolean }; // NOSONAR / test code +// Initialize mutableConstants here, after its declaration, but before vi.mock calls if possible, +// or ensure factories handle potential undefined state if initialization is further down. +// For safety with hoisted mocks, initialize immediately. +mutableConstants = { AUDIT_LOG_ENABLED: true }; + +vi.mock("@/lib/constants", () => ({ + // AUDIT_LOG_ENABLED will be controlled by mutableConstants + get AUDIT_LOG_ENABLED() { + // Guard against mutableConstants being undefined during early hoisting phases if not initialized above + return mutableConstants ? mutableConstants.AUDIT_LOG_ENABLED : true; // Default to true if somehow undefined + }, + AUDIT_LOG_GET_USER_IP: true, +})); +vi.mock("@/lib/utils/client-ip", () => ({ + getClientIpFromHeaders: vi.fn().mockResolvedValue("127.0.0.1"), +})); + +vi.mock("@/modules/ee/audit-logs/lib/service", () => { + const mock = vi.fn(); + serviceLogAuditEventMockHandle = mock; + return { logAuditEvent: mock }; +}); + +vi.mock("./utils", async () => { + const actualUtils = await vi.importActual("./utils"); + return { + ...(actualUtils as object), + redactPII: vi.fn((obj) => obj), // Keep others as simple mocks or actuals if needed + deepDiff: vi.fn((a, b) => ({ diff: true })), + }; +}); + +// Special handling for @formbricks/logger due to hoisting issues +vi.mock("@formbricks/logger", () => { + const localLoggerErrorMock = vi.fn(); + loggerErrorMockHandle = localLoggerErrorMock; + return { + logger: { + error: localLoggerErrorMock, + // Ensure other logger methods are available if needed, or mock them as vi.fn() + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + fatal: vi.fn(), + withContext: vi.fn(() => ({ + // basic stub for withContext + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: localLoggerErrorMock, + fatal: vi.fn(), + })), + request: vi.fn(() => ({ + // basic stub for request + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: localLoggerErrorMock, + fatal: vi.fn(), + })), + }, + }; +}); + +const baseEventParams = { + action: "created" as TAuditAction, + targetType: "survey" as TAuditTarget, + userId: "u1", + userType: "user" as TActor, + targetId: "t1", + organizationId: "org1", + ipAddress: "127.0.0.1", + status: "success" as TAuditStatus, + oldObject: { foo: "bar" }, + newObject: { foo: "baz" }, + apiUrl: "/api/test", +}; + +const fullUser = { + id: "u1", + name: "Test User", + createdAt: new Date(), + updatedAt: new Date(), + email: "test@example.com", + emailVerified: null, + imageUrl: null, + twoFactorEnabled: false, + identityProvider: "email", + organizationId: "org1", + isActive: true, + lastLoginAt: new Date(), + locale: "en", + notificationSettings: {}, + onboardingDisplayed: true, + productId: "p1", + role: "user", + source: null, + teams: [], + type: "user", + objective: null, + intention: null, +}; + +const mockCtxBase = { + user: fullUser, + auditLoggingCtx: { + ipAddress: "127.0.0.1", + organizationId: "org1", + surveyId: "t1", + oldObject: { foo: "bar" }, + newObject: { foo: "baz" }, + eventId: "event-1", + }, +}; + +// Helper to clear all mock handles +function clearAllMockHandles() { + if (serviceLogAuditEventMockHandle) serviceLogAuditEventMockHandle.mockClear().mockResolvedValue(undefined); + if (loggerErrorMockHandle) loggerErrorMockHandle.mockClear(); + if (mutableConstants) { + // Check because it's a var and could be re-assigned (though not in this code) + mutableConstants.AUDIT_LOG_ENABLED = true; + } +} + +describe("queueAuditEvent", () => { + beforeEach(() => { + clearAllMockHandles(); + }); + afterEach(() => { + vi.resetModules(); // Reset if any dynamic imports were used, or for general cleanliness + }); + + test("correctly processes event and its dependencies are called", async () => { + await OriginalHandler.queueAuditEvent(baseEventParams); + // Now, OriginalHandler.queueAuditEvent will call the REAL OriginalHandler.buildAndLogAuditEvent + // We expect the MOCKED dependencies of buildAndLogAuditEvent to be called. + expect(serviceLogAuditEventMockHandle).toHaveBeenCalled(); + // Add more specific assertions on what serviceLogAuditEventMockHandle was called with if necessary + // This would be similar to the direct tests for buildAndLogAuditEvent + const logCall = serviceLogAuditEventMockHandle.mock.calls[0][0]; + expect(logCall.action).toBe(baseEventParams.action); + }); + + test("handles errors from buildAndLogAuditEvent dependencies", async () => { + const testError = new Error("Service error in test"); + serviceLogAuditEventMockHandle.mockImplementationOnce(() => { + throw testError; + }); + await OriginalHandler.queueAuditEvent(baseEventParams); + // queueAuditEvent should catch errors from buildAndLogAuditEvent and log them + // buildAndLogAuditEvent in turn logs errors from its dependencies + expect(loggerErrorMockHandle).toHaveBeenCalledWith(testError, "Failed to create audit log event"); + expect(serviceLogAuditEventMockHandle).toHaveBeenCalled(); + }); +}); + +describe("queueAuditEventBackground", () => { + beforeEach(() => { + clearAllMockHandles(); + }); + afterEach(() => { + vi.resetModules(); + }); + + test("correctly processes event in background and dependencies are called", async () => { + await OriginalHandler.queueAuditEventBackground(baseEventParams); + await new Promise(setImmediate); // Wait for setImmediate to run + expect(serviceLogAuditEventMockHandle).toHaveBeenCalled(); + const logCall = serviceLogAuditEventMockHandle.mock.calls[0][0]; + expect(logCall.action).toBe(baseEventParams.action); + }); +}); + +describe("withAuditLogging", () => { + beforeEach(() => { + clearAllMockHandles(); + }); + afterEach(() => { + vi.resetModules(); + }); + + const mockParsedInput = {}; + + test("logs audit event for successful handler", async () => { + const handlerImpl = vi.fn().mockResolvedValue("ok"); + const wrapped = OriginalHandler.withAuditLogging("created", "survey", handlerImpl); + await wrapped({ ctx: mockCtxBase as any, parsedInput: mockParsedInput }); + await new Promise(setImmediate); + expect(handlerImpl).toHaveBeenCalled(); + expect(serviceLogAuditEventMockHandle).toHaveBeenCalled(); + const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0]; + expect(callArgs.action).toBe("created"); + expect(callArgs.status).toBe("success"); + expect(callArgs.target.id).toBe("t1"); + }); + + test("logs audit event for failed handler and throws", async () => { + const handlerImpl = vi.fn().mockRejectedValue(new Error("fail")); + const wrapped = OriginalHandler.withAuditLogging("created", "survey", handlerImpl); + await expect(wrapped({ ctx: mockCtxBase as any, parsedInput: mockParsedInput })).rejects.toThrow("fail"); + await new Promise(setImmediate); + expect(handlerImpl).toHaveBeenCalled(); + expect(serviceLogAuditEventMockHandle).toHaveBeenCalled(); + const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0]; + expect(callArgs.action).toBe("created"); + expect(callArgs.status).toBe("failure"); + expect(callArgs.target.id).toBe("t1"); + }); + + test("does not log if AUDIT_LOG_ENABLED is false", async () => { + if (mutableConstants) mutableConstants.AUDIT_LOG_ENABLED = false; + const handlerImpl = vi.fn().mockResolvedValue("ok"); + const wrapped = OriginalHandler.withAuditLogging("created", "survey", handlerImpl); + await wrapped({ ctx: mockCtxBase as any, parsedInput: mockParsedInput }); + await new Promise(setImmediate); + expect(handlerImpl).toHaveBeenCalled(); + expect(serviceLogAuditEventMockHandle).not.toHaveBeenCalled(); + // Reset for other tests; clearAllMockHandles will also do this in the next beforeEach + if (mutableConstants) mutableConstants.AUDIT_LOG_ENABLED = true; + }); +}); diff --git a/apps/web/modules/ee/audit-logs/lib/handler.ts b/apps/web/modules/ee/audit-logs/lib/handler.ts new file mode 100644 index 000000000000..41b2a8502561 --- /dev/null +++ b/apps/web/modules/ee/audit-logs/lib/handler.ts @@ -0,0 +1,321 @@ +import { AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP } from "@/lib/constants"; +import { ActionClientCtx, AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { getClientIpFromHeaders } from "@/lib/utils/client-ip"; +import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper"; +import { deepDiff, redactPII } from "@/lib/utils/logger-helpers"; +import { logAuditEvent } from "@/modules/ee/audit-logs/lib/service"; +import { + TActor, + TAuditAction, + TAuditLogEvent, + TAuditStatus, + TAuditTarget, + UNKNOWN_DATA, +} from "@/modules/ee/audit-logs/types/audit-log"; +import { getIsAuditLogsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { logger } from "@formbricks/logger"; + +/** + * Builds an audit event and logs it. + * Redacts sensitive data from the old and new objects before logging. + */ +export const buildAndLogAuditEvent = async ({ + action, + targetType, + userId, + userType, + targetId, + organizationId, + ipAddress, + status, + oldObject, + newObject, + eventId, + apiUrl, +}: { + action: TAuditAction; + targetType: TAuditTarget; + userId: string; + userType: TActor; + targetId: string; + organizationId: string; + ipAddress: string; + status: TAuditStatus; + oldObject?: Record | null; + newObject?: Record | null; + eventId?: string; + apiUrl?: string; +}) => { + if (!AUDIT_LOG_ENABLED && !(await getIsAuditLogsEnabled())) { + return; + } + + try { + let changes; + + if (oldObject && newObject) { + changes = deepDiff(oldObject, newObject); + changes = redactPII(changes); + } else if (newObject) { + changes = redactPII(newObject); + } else if (oldObject) { + changes = redactPII(oldObject); + } + + const auditEvent: TAuditLogEvent = { + actor: { id: userId, type: userType }, + action, + target: { id: targetId, type: targetType }, + timestamp: new Date().toISOString(), + organizationId, + status, + ipAddress: AUDIT_LOG_GET_USER_IP ? ipAddress : UNKNOWN_DATA, + apiUrl, + ...(changes ? { changes } : {}), + ...(status === "failure" && eventId ? { eventId } : {}), + }; + + await logAuditEvent(auditEvent); + } catch (logError) { + logger.error(logError, "Failed to create audit log event"); + } +}; + +/** + * Logs an audit event. + * The audit logging runs in the background to avoid blocking the main request. + */ +export const queueAuditEventBackground = async ({ + action, + targetType, + userId, + userType, + targetId, + organizationId, + oldObject, + newObject, + status, + eventId, + apiUrl, +}: { + action: TAuditAction; + targetType: TAuditTarget; + userId: string; + userType: TActor; + targetId: string; + organizationId: string; + oldObject?: Record | null; + newObject?: Record | null; + status: TAuditStatus; + eventId?: string; + apiUrl?: string; +}) => { + setImmediate(async () => { + const ipAddress = await getClientIpFromHeaders(); + await buildAndLogAuditEvent({ + action, + targetType, + userId, + userType, + targetId, + organizationId, + ipAddress, + status, + oldObject, + newObject, + eventId, + apiUrl, + }); + }); +}; + +/** + * Logs an audit event. + * This function will block the main request. Use it only in edge runtime functions, like api routes. + */ +export const queueAuditEvent = async ({ + action, + targetType, + userId, + userType, + targetId, + organizationId, + oldObject, + newObject, + status, + eventId, + apiUrl, +}: { + action: TAuditAction; + targetType: TAuditTarget; + userId: string; + userType: TActor; + targetId: string; + organizationId: string; + oldObject?: Record | null; + newObject?: Record | null; + status: TAuditStatus; + eventId?: string; + apiUrl?: string; +}) => { + const ipAddress = await getClientIpFromHeaders(); + + await buildAndLogAuditEvent({ + action, + targetType, + userId, + userType, + targetId, + organizationId, + ipAddress, + status, + oldObject, + newObject, + eventId, + apiUrl, + }); +}; + +/** + * Wraps a handler function with audit logging. + * Logs audit events for server actions. Specifically for server actions that use next-server-action library middleware and its context. + * The audit logging runs in the background to avoid blocking the main request. + * + * @param action - The type of action to audit. + * @param targetType - The type of target (e.g., "segment", "survey"). + * @param handler - The handler function to wrap. It can be used with both authenticated and unauthenticated actions. + **/ +export const withAuditLogging = , TResult = unknown>( + action: TAuditAction, + targetType: TAuditTarget, + handler: (args: { + ctx: ActionClientCtx | AuthenticatedActionClientCtx; + parsedInput: TParsedInput; + }) => Promise +) => { + return async function wrappedAction(args: { + ctx: ActionClientCtx | AuthenticatedActionClientCtx; + parsedInput: TParsedInput; + }): Promise { + const { ctx, parsedInput } = args; + const { auditLoggingCtx } = ctx; + let result!: TResult; + let status: TAuditStatus = "success"; + let error: any = undefined; + + try { + result = await handler(args); + } catch (err) { + status = "failure"; + error = err; + } + + if (!AUDIT_LOG_ENABLED) { + if (status === "failure") throw error; + return result; + } + + if (!auditLoggingCtx) { + logger.error("No audit logging context found"); + return result; + } + + setImmediate(async () => { + try { + const userId: string = ctx?.user?.id ?? UNKNOWN_DATA; + let organizationId = + auditLoggingCtx?.organizationId || // NOSONAR // We want to use the organizationId from the auditLoggingCtx if it is present and not empty + (parsedInput as Record)?.organizationId || // NOSONAR // We want to use the organizationId from the parsedInput if it is present and not empty + UNKNOWN_DATA; + + if (!organizationId) { + const environmentId = (parsedInput as Record)?.environmentId; + if (environmentId && typeof environmentId === "string") { + try { + organizationId = await getOrganizationIdFromEnvironmentId(environmentId); + } catch (err) { + logger.error(err, "Failed to get organizationId from environmentId in audit logging"); + organizationId = UNKNOWN_DATA; + } + } else { + organizationId = UNKNOWN_DATA; + } + } + + let targetId: string | undefined; + switch (targetType) { + case "segment": + targetId = auditLoggingCtx.segmentId; + break; + case "survey": + targetId = auditLoggingCtx.surveyId; + break; + case "organization": + targetId = auditLoggingCtx.organizationId; + break; + case "tag": + targetId = auditLoggingCtx.tagId; + break; + case "webhook": + targetId = auditLoggingCtx.webhookId; + break; + case "user": + targetId = auditLoggingCtx.userId; + break; + case "project": + targetId = auditLoggingCtx.projectId; + break; + case "language": + targetId = auditLoggingCtx.languageId; + break; + case "invite": + targetId = auditLoggingCtx.inviteId; + break; + case "membership": + targetId = auditLoggingCtx.membershipId; + break; + case "actionClass": + targetId = auditLoggingCtx.actionClassId; + break; + case "contact": + targetId = auditLoggingCtx.contactId; + break; + case "apiKey": + targetId = auditLoggingCtx.apiKeyId; + break; + case "response": + targetId = auditLoggingCtx.responseId; + break; + + case "integration": + targetId = auditLoggingCtx.integrationId; + break; + default: + targetId = UNKNOWN_DATA; + break; + } + + targetId ??= UNKNOWN_DATA; + + await buildAndLogAuditEvent({ + action, + targetType, + userId, + userType: "user", + targetId, + organizationId, + ipAddress: AUDIT_LOG_GET_USER_IP ? auditLoggingCtx.ipAddress : UNKNOWN_DATA, + status, + oldObject: auditLoggingCtx.oldObject, + newObject: auditLoggingCtx.newObject, + eventId: auditLoggingCtx.eventId, + }); + } catch (logError) { + logger.error(logError, "Failed to create audit log event"); + } + }); + + if (status === "failure") throw error; + return result; + }; +}; diff --git a/apps/web/modules/ee/audit-logs/lib/service.test.ts b/apps/web/modules/ee/audit-logs/lib/service.test.ts new file mode 100644 index 000000000000..3f2d2eca6957 --- /dev/null +++ b/apps/web/modules/ee/audit-logs/lib/service.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { UNKNOWN_DATA } from "../types/audit-log"; +import { logAuditEvent } from "./service"; + +// Mocks +globalThis.console = { ...globalThis.console, error: vi.fn() }; + +vi.mock("../../../ee/license-check/lib/utils", () => ({ + getIsAuditLogsEnabled: vi.fn(), +})); +vi.mock("@formbricks/logger", () => ({ + logger: { audit: vi.fn(), error: vi.fn() }, +})); + +const validEvent = { + actor: { id: "user-1", type: "user" as const }, + action: "created" as const, + target: { id: "target-1", type: "user" as const }, + status: "success" as const, + timestamp: new Date().toISOString(), + organizationId: "org-1", +}; + +describe("logAuditEvent", () => { + let getIsAuditLogsEnabled: any; + let logger: any; + + beforeEach(async () => { + vi.clearAllMocks(); + getIsAuditLogsEnabled = (await import("@/modules/ee/license-check/lib/utils")).getIsAuditLogsEnabled; + logger = (await import("@formbricks/logger")).logger; + }); + + test("logs event if access is granted and event is valid", async () => { + getIsAuditLogsEnabled.mockResolvedValue(true); + await logAuditEvent(validEvent); + expect(logger.audit).toHaveBeenCalledWith(validEvent); + expect(logger.error).not.toHaveBeenCalled(); + }); + + test("throws and logs error for invalid event", async () => { + getIsAuditLogsEnabled.mockResolvedValue(true); + const invalidEvent = { ...validEvent, action: "invalid.action" }; + await logAuditEvent(invalidEvent as any); + expect(logger.audit).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + }); + + test("handles UNKNOWN_DATA organizationId", async () => { + getIsAuditLogsEnabled.mockResolvedValue(true); + const event = { ...validEvent, organizationId: UNKNOWN_DATA }; + await logAuditEvent(event); + expect(logger.audit).toHaveBeenCalledWith(event); + }); + + test("does not throw if logger.audit throws", async () => { + getIsAuditLogsEnabled.mockResolvedValue(true); + logger.audit.mockImplementation(() => { + throw new Error("fail"); + }); + await logAuditEvent(validEvent); + expect(logger.error).toHaveBeenCalled(); + }); +}); + +describe("logAuditEvent export", () => { + test("is a function and works as expected", async () => { + expect(typeof logAuditEvent).toBe("function"); + // Just check it calls the underlying logic and does not throw + await expect(logAuditEvent(validEvent)).resolves.not.toThrow(); + }); +}); diff --git a/apps/web/modules/ee/audit-logs/lib/service.ts b/apps/web/modules/ee/audit-logs/lib/service.ts new file mode 100644 index 000000000000..2af4d79f18fd --- /dev/null +++ b/apps/web/modules/ee/audit-logs/lib/service.ts @@ -0,0 +1,20 @@ +import { type TAuditLogEvent, ZAuditLogEventSchema } from "@/modules/ee/audit-logs/types/audit-log"; +import { logger } from "@formbricks/logger"; + +const validateEvent = (event: TAuditLogEvent): void => { + const result = ZAuditLogEventSchema.safeParse(event); + if (!result.success) { + throw new Error(`Invalid audit log event: ${result.error.message}`); + } +}; + +export const logAuditEvent = async (event: TAuditLogEvent): Promise => { + try { + validateEvent(event); + logger.audit(event); + } catch (error) { + // Log error to application logger but don't throw + // This ensures audit logging failures don't break the application + logger.error(error, "Failed to log audit event"); + } +}; diff --git a/apps/web/modules/ee/audit-logs/types/audit-log.ts b/apps/web/modules/ee/audit-logs/types/audit-log.ts new file mode 100644 index 000000000000..cb5c42d1848d --- /dev/null +++ b/apps/web/modules/ee/audit-logs/types/audit-log.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; + +export const UNKNOWN_DATA = "unknown"; + +// Define as const arrays +export const ZAuditTarget = z.enum([ + "segment", + "survey", + "webhook", + "user", + "contactAttributeKey", + "projectTeam", + "team", + "actionClass", + "response", + "contact", + "organization", + "tag", + "project", + "language", + "invite", + "membership", + "twoFactorAuth", + "apiKey", + + "integration", + "file", +]); +export const ZAuditAction = z.enum([ + "created", + "updated", + "deleted", + "signedIn", + "merged", + "verificationEmailSent", + "createdFromCSV", + "copiedToOtherEnvironment", + "addedToResponse", + "removedFromResponse", + "createdUpdated", + "subscriptionAccessed", + "subscriptionUpdated", + "twoFactorVerified", + "emailVerified", + "jwtTokenCreated", + "authenticationAttempted", + "authenticationSucceeded", + "passwordVerified", + "twoFactorAttempted", + "twoFactorRequired", + "emailVerificationAttempted", + "userSignedOut", + "passwordReset", + "bulkCreated", +]); +export const ZActor = z.enum(["user", "api", "system"]); +export const ZAuditStatus = z.enum(["success", "failure"]); + +// Use template literal for the type +export type TAuditTarget = z.infer; +export type TAuditAction = z.infer; +export type TActor = z.infer; +export type TAuditStatus = z.infer; + +export const ZAuditLogEventSchema = z.object({ + actor: z.object({ + id: z.string(), + type: ZActor, + }), + action: ZAuditAction, + target: z.object({ + id: z.string().or(z.undefined()), + type: ZAuditTarget, + }), + status: ZAuditStatus, + timestamp: z.string().datetime(), + organizationId: z.string(), + ipAddress: z.string().optional(), // Not using the .ip() here because if we don't enabled it we want to put UNKNOWN_DATA string, to keep the same pattern as the other fields + changes: z.record(z.any()).optional(), + eventId: z.string().optional(), + apiUrl: z.string().url().optional(), +}); + +export type TAuditLogEvent = z.infer; diff --git a/apps/web/modules/ee/auth/saml/lib/jackson.ts b/apps/web/modules/ee/auth/saml/lib/jackson.ts index 09a2e7caad15..2b883c931690 100644 --- a/apps/web/modules/ee/auth/saml/lib/jackson.ts +++ b/apps/web/modules/ee/auth/saml/lib/jackson.ts @@ -1,9 +1,9 @@ "use server"; +import { SAML_AUDIENCE, SAML_DATABASE_URL, SAML_PATH, WEBAPP_URL } from "@/lib/constants"; import { preloadConnection } from "@/modules/ee/auth/saml/lib/preload-connection"; import { getIsSamlSsoEnabled } from "@/modules/ee/license-check/lib/utils"; import type { IConnectionAPIController, IOAuthController, JacksonOption } from "@boxyhq/saml-jackson"; -import { SAML_AUDIENCE, SAML_DATABASE_URL, SAML_PATH, WEBAPP_URL } from "@formbricks/lib/constants"; const opts: JacksonOption = { externalUrl: WEBAPP_URL, diff --git a/apps/web/modules/ee/auth/saml/lib/preload-connection.ts b/apps/web/modules/ee/auth/saml/lib/preload-connection.ts index 5a140971a7e7..70a0a14d5bbf 100644 --- a/apps/web/modules/ee/auth/saml/lib/preload-connection.ts +++ b/apps/web/modules/ee/auth/saml/lib/preload-connection.ts @@ -1,8 +1,8 @@ +import { SAML_PRODUCT, SAML_TENANT, SAML_XML_DIR, WEBAPP_URL } from "@/lib/constants"; import { SAMLSSOConnectionWithEncodedMetadata, SAMLSSORecord } from "@boxyhq/saml-jackson"; import { ConnectionAPIController } from "@boxyhq/saml-jackson/dist/controller/api"; import fs from "fs/promises"; import path from "path"; -import { SAML_PRODUCT, SAML_TENANT, SAML_XML_DIR, WEBAPP_URL } from "@formbricks/lib/constants"; import { logger } from "@formbricks/logger"; const getPreloadedConnectionFile = async () => { diff --git a/apps/web/modules/ee/auth/saml/lib/tests/jackson.test.ts b/apps/web/modules/ee/auth/saml/lib/tests/jackson.test.ts index 3cbc857b0360..74bd151abde9 100644 --- a/apps/web/modules/ee/auth/saml/lib/tests/jackson.test.ts +++ b/apps/web/modules/ee/auth/saml/lib/tests/jackson.test.ts @@ -1,11 +1,11 @@ +import { SAML_AUDIENCE, SAML_DATABASE_URL, SAML_PATH, WEBAPP_URL } from "@/lib/constants"; import { preloadConnection } from "@/modules/ee/auth/saml/lib/preload-connection"; import { getIsSamlSsoEnabled } from "@/modules/ee/license-check/lib/utils"; import { controllers } from "@boxyhq/saml-jackson"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { SAML_AUDIENCE, SAML_DATABASE_URL, SAML_PATH, WEBAPP_URL } from "@formbricks/lib/constants"; import init from "../jackson"; -vi.mock("@formbricks/lib/constants", () => ({ +vi.mock("@/lib/constants", () => ({ SAML_AUDIENCE: "test-audience", SAML_DATABASE_URL: "test-db-url", SAML_PATH: "/test-path", diff --git a/apps/web/modules/ee/auth/saml/lib/tests/preload-connection.test.ts b/apps/web/modules/ee/auth/saml/lib/tests/preload-connection.test.ts index 5bb8c60f45d2..c122d57ec697 100644 --- a/apps/web/modules/ee/auth/saml/lib/tests/preload-connection.test.ts +++ b/apps/web/modules/ee/auth/saml/lib/tests/preload-connection.test.ts @@ -1,11 +1,11 @@ +import { SAML_PRODUCT, SAML_TENANT, SAML_XML_DIR, WEBAPP_URL } from "@/lib/constants"; import fs from "fs/promises"; import path from "path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { SAML_PRODUCT, SAML_TENANT, SAML_XML_DIR, WEBAPP_URL } from "@formbricks/lib/constants"; import { logger } from "@formbricks/logger"; import { preloadConnection } from "../preload-connection"; -vi.mock("@formbricks/lib/constants", () => ({ +vi.mock("@/lib/constants", () => ({ SAML_PRODUCT: "test-product", SAML_TENANT: "test-tenant", SAML_XML_DIR: "test-xml-dir", diff --git a/apps/web/modules/ee/billing/actions.ts b/apps/web/modules/ee/billing/actions.ts index ec62a483e06d..b27541dc3c73 100644 --- a/apps/web/modules/ee/billing/actions.ts +++ b/apps/web/modules/ee/billing/actions.ts @@ -1,15 +1,16 @@ "use server"; +import { STRIPE_PRICE_LOOKUP_KEYS, WEBAPP_URL } from "@/lib/constants"; +import { getOrganization } from "@/lib/organization/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"; +import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { createCustomerPortalSession } from "@/modules/ee/billing/api/lib/create-customer-portal-session"; import { createSubscription } from "@/modules/ee/billing/api/lib/create-subscription"; import { isSubscriptionCancelled } from "@/modules/ee/billing/api/lib/is-subscription-cancelled"; import { z } from "zod"; -import { STRIPE_PRICE_LOOKUP_KEYS } from "@formbricks/lib/constants"; -import { WEBAPP_URL } from "@formbricks/lib/constants"; -import { getOrganization } from "@formbricks/lib/organization/service"; import { ZId } from "@formbricks/types/common"; import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; @@ -18,58 +19,76 @@ const ZUpgradePlanAction = z.object({ priceLookupKey: z.nativeEnum(STRIPE_PRICE_LOOKUP_KEYS), }); -export const upgradePlanAction = authenticatedActionClient - .schema(ZUpgradePlanAction) - .action(async ({ ctx, parsedInput }) => { - const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId); +export const upgradePlanAction = authenticatedActionClient.schema(ZUpgradePlanAction).action( + withAuditLogging( + "subscriptionUpdated", + "organization", + async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => { + const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing"], - }, - ], - }); + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager", "billing"], + }, + ], + }); - return await createSubscription(organizationId, parsedInput.environmentId, parsedInput.priceLookupKey); - }); + ctx.auditLoggingCtx.organizationId = organizationId; + const result = await createSubscription( + organizationId, + parsedInput.environmentId, + parsedInput.priceLookupKey + ); + ctx.auditLoggingCtx.newObject = { priceLookupKey: parsedInput.priceLookupKey }; + return result; + } + ) +); const ZManageSubscriptionAction = z.object({ environmentId: ZId, }); -export const manageSubscriptionAction = authenticatedActionClient - .schema(ZManageSubscriptionAction) - .action(async ({ ctx, parsedInput }) => { - const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing"], - }, - ], - }); +export const manageSubscriptionAction = authenticatedActionClient.schema(ZManageSubscriptionAction).action( + withAuditLogging( + "subscriptionAccessed", + "organization", + async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record }) => { + const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId); + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager", "billing"], + }, + ], + }); - const organization = await getOrganization(organizationId); - if (!organization) { - throw new ResourceNotFoundError("organization", organizationId); - } + const organization = await getOrganization(organizationId); + if (!organization) { + throw new ResourceNotFoundError("organization", organizationId); + } - if (!organization.billing.stripeCustomerId) { - throw new AuthorizationError("You do not have an associated Stripe CustomerId"); - } + if (!organization.billing.stripeCustomerId) { + throw new AuthorizationError("You do not have an associated Stripe CustomerId"); + } - return await createCustomerPortalSession( - organization.billing.stripeCustomerId, - `${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing` - ); - }); + ctx.auditLoggingCtx.organizationId = organizationId; + const result = await createCustomerPortalSession( + organization.billing.stripeCustomerId, + `${WEBAPP_URL}/environments/${parsedInput.environmentId}/settings/billing` + ); + ctx.auditLoggingCtx.newObject = { portalSession: result }; + return result; + } + ) +); const ZIsSubscriptionCancelledAction = z.object({ organizationId: ZId, diff --git a/apps/web/modules/ee/billing/api/lib/checkout-session-completed.ts b/apps/web/modules/ee/billing/api/lib/checkout-session-completed.ts index 65d360bc5195..55da0a307cd5 100644 --- a/apps/web/modules/ee/billing/api/lib/checkout-session-completed.ts +++ b/apps/web/modules/ee/billing/api/lib/checkout-session-completed.ts @@ -1,7 +1,7 @@ +import { STRIPE_API_VERSION } from "@/lib/constants"; +import { env } from "@/lib/env"; +import { getOrganization } from "@/lib/organization/service"; import Stripe from "stripe"; -import { STRIPE_API_VERSION } from "@formbricks/lib/constants"; -import { env } from "@formbricks/lib/env"; -import { getOrganization } from "@formbricks/lib/organization/service"; import { ResourceNotFoundError } from "@formbricks/types/errors"; const stripe = new Stripe(env.STRIPE_SECRET_KEY!, { diff --git a/apps/web/modules/ee/billing/api/lib/constants.ts b/apps/web/modules/ee/billing/api/lib/constants.ts index 367e50976f0f..8c8e07b20a64 100644 --- a/apps/web/modules/ee/billing/api/lib/constants.ts +++ b/apps/web/modules/ee/billing/api/lib/constants.ts @@ -1,87 +1,86 @@ import { TFnType } from "@tolgee/react"; -export const getCloudPricingData = (t: TFnType) => { - return { - plans: [ - { - name: t("environments.settings.billing.free"), - id: "free", - featured: false, - description: t("environments.settings.billing.free_description"), - price: { monthly: "$0", yearly: "$0" }, - mainFeatures: [ - t("environments.settings.billing.unlimited_surveys"), - t("environments.settings.billing.unlimited_team_members"), - t("environments.settings.billing.3_projects"), - t("environments.settings.billing.1500_monthly_responses"), - t("environments.settings.billing.2000_monthly_identified_users"), - t("environments.settings.billing.website_surveys"), - t("environments.settings.billing.app_surveys"), - t("environments.settings.billing.unlimited_apps_websites"), - t("environments.settings.billing.link_surveys"), - t("environments.settings.billing.email_embedded_surveys"), - t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"), - t("environments.settings.billing.api_webhooks"), - t("environments.settings.billing.all_integrations"), - t("environments.settings.billing.all_surveying_features"), - ], - href: "https://app.formbricks.com/auth/signup?plan=free", - }, - { - name: t("environments.settings.billing.startup"), - id: "startup", - featured: false, - description: t("environments.settings.billing.startup_description"), - price: { monthly: "$39", yearly: "$390 " }, - mainFeatures: [ - t("environments.settings.billing.everything_in_free"), - t("environments.settings.billing.unlimited_surveys"), - t("environments.settings.billing.remove_branding"), - t("environments.settings.billing.email_support"), - t("environments.settings.billing.3_projects"), - t("environments.settings.billing.5000_monthly_responses"), - t("environments.settings.billing.7500_monthly_identified_users"), - ], - href: "https://app.formbricks.com/auth/signup?plan=startup", - }, - { - name: t("environments.settings.billing.scale"), - id: "scale", - featured: true, - description: t("environments.settings.billing.scale_description"), - price: { monthly: "$149", yearly: "$1,490" }, - mainFeatures: [ - t("environments.settings.billing.everything_in_startup"), - t("environments.settings.billing.team_access_roles"), - t("environments.settings.billing.multi_language_surveys"), - t("environments.settings.billing.advanced_targeting"), - t("environments.settings.billing.priority_support"), - t("environments.settings.billing.5_projects"), - t("environments.settings.billing.10000_monthly_responses"), - t("environments.settings.billing.30000_monthly_identified_users"), - ], - href: "https://app.formbricks.com/auth/signup?plan=scale", - }, - { - name: t("environments.settings.billing.enterprise"), - id: "enterprise", - featured: false, - description: t("environments.settings.billing.enterprise_description"), - price: { - monthly: t("environments.settings.billing.say_hi"), - yearly: t("environments.settings.billing.say_hi"), - }, - mainFeatures: [ - t("environments.settings.billing.everything_in_scale"), - t("environments.settings.billing.custom_project_limit"), - t("environments.settings.billing.custom_miu_limit"), - t("environments.settings.billing.premium_support_with_slas"), - t("environments.settings.billing.uptime_sla_99"), - t("environments.settings.billing.customer_success_manager"), - t("environments.settings.billing.technical_onboarding"), - ], - href: "https://cal.com/johannes/enterprise-cloud", - }, +export type TPricingPlan = { + id: string; + name: string; + featured: boolean; + CTA?: string; + description: string; + price: { + monthly: string; + yearly: string; + }; + mainFeatures: string[]; + href?: string; +}; + +export const getCloudPricingData = (t: TFnType): { plans: TPricingPlan[] } => { + const freePlan: TPricingPlan = { + id: "free", + name: t("environments.settings.billing.free"), + featured: false, + description: t("environments.settings.billing.free_description"), + price: { monthly: "$0", yearly: "$0" }, + mainFeatures: [ + t("environments.settings.billing.unlimited_surveys"), + t("environments.settings.billing.1000_monthly_responses"), + t("environments.settings.billing.2000_contacts"), + t("environments.settings.billing.1_project"), + t("environments.settings.billing.unlimited_team_members"), + t("environments.settings.billing.link_surveys"), + t("environments.settings.billing.website_surveys"), + t("environments.settings.billing.app_surveys"), + t("environments.settings.billing.ios_android_sdks"), + t("environments.settings.billing.email_embedded_surveys"), + t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"), + t("environments.settings.billing.api_webhooks"), + t("environments.settings.billing.all_integrations"), + t("environments.settings.billing.hosted_in_frankfurt") + " 🇪🇺", + ], + }; + + const startupPlan: TPricingPlan = { + id: "startup", + name: t("environments.settings.billing.startup"), + featured: true, + CTA: t("common.start_free_trial"), + description: t("environments.settings.billing.startup_description"), + price: { monthly: "$49", yearly: "$490" }, + mainFeatures: [ + t("environments.settings.billing.everything_in_free"), + t("environments.settings.billing.5000_monthly_responses"), + t("environments.settings.billing.7500_contacts"), + t("environments.settings.billing.3_projects"), + t("environments.settings.billing.remove_branding"), + t("environments.settings.billing.email_follow_ups"), + t("environments.settings.billing.attribute_based_targeting"), ], }; + + const customPlan: TPricingPlan = { + id: "enterprise", + name: t("environments.settings.billing.custom"), + featured: false, + CTA: t("common.request_pricing"), + description: t("environments.settings.billing.enterprise_description"), + price: { + monthly: t("environments.settings.billing.custom"), + yearly: t("environments.settings.billing.custom"), + }, + mainFeatures: [ + t("environments.settings.billing.everything_in_startup"), + t("environments.settings.billing.custom_response_limit"), + t("environments.settings.billing.custom_contacts_limit"), + t("environments.settings.billing.custom_project_limit"), + t("environments.settings.billing.team_access_roles"), + t("environments.project.languages.multi_language_surveys"), + t("environments.settings.billing.uptime_sla_99"), + t("environments.settings.billing.premium_support_with_slas"), + ], + href: "https://formbricks.com/custom-plan?source=billingView", + }; + + return { + plans: [freePlan, startupPlan, customPlan], + }; }; diff --git a/apps/web/modules/ee/billing/api/lib/create-customer-portal-session.ts b/apps/web/modules/ee/billing/api/lib/create-customer-portal-session.ts index 3ca89426908d..07466d33ef17 100644 --- a/apps/web/modules/ee/billing/api/lib/create-customer-portal-session.ts +++ b/apps/web/modules/ee/billing/api/lib/create-customer-portal-session.ts @@ -1,6 +1,6 @@ +import { STRIPE_API_VERSION } from "@/lib/constants"; +import { env } from "@/lib/env"; import Stripe from "stripe"; -import { STRIPE_API_VERSION } from "@formbricks/lib/constants"; -import { env } from "@formbricks/lib/env"; export const createCustomerPortalSession = async (stripeCustomerId: string, returnUrl: string) => { if (!env.STRIPE_SECRET_KEY) throw new Error("Stripe is not enabled; STRIPE_SECRET_KEY is not set."); diff --git a/apps/web/modules/ee/billing/api/lib/create-subscription.ts b/apps/web/modules/ee/billing/api/lib/create-subscription.ts index 4c33229ca022..cd581b16082a 100644 --- a/apps/web/modules/ee/billing/api/lib/create-subscription.ts +++ b/apps/web/modules/ee/billing/api/lib/create-subscription.ts @@ -1,8 +1,7 @@ +import { STRIPE_API_VERSION, STRIPE_PRICE_LOOKUP_KEYS, WEBAPP_URL } from "@/lib/constants"; +import { env } from "@/lib/env"; +import { getOrganization } from "@/lib/organization/service"; import Stripe from "stripe"; -import { STRIPE_API_VERSION, WEBAPP_URL } from "@formbricks/lib/constants"; -import { STRIPE_PRICE_LOOKUP_KEYS } from "@formbricks/lib/constants"; -import { env } from "@formbricks/lib/env"; -import { getOrganization } from "@formbricks/lib/organization/service"; import { logger } from "@formbricks/logger"; const stripe = new Stripe(env.STRIPE_SECRET_KEY!, { diff --git a/apps/web/modules/ee/billing/api/lib/invoice-finalized.ts b/apps/web/modules/ee/billing/api/lib/invoice-finalized.ts index 77b7cfd779f9..c829802c2f2d 100644 --- a/apps/web/modules/ee/billing/api/lib/invoice-finalized.ts +++ b/apps/web/modules/ee/billing/api/lib/invoice-finalized.ts @@ -1,5 +1,5 @@ +import { getOrganization, updateOrganization } from "@/lib/organization/service"; import Stripe from "stripe"; -import { getOrganization, updateOrganization } from "@formbricks/lib/organization/service"; export const handleInvoiceFinalized = async (event: Stripe.Event) => { const invoice = event.data.object as Stripe.Invoice; diff --git a/apps/web/modules/ee/billing/api/lib/is-subscription-cancelled.ts b/apps/web/modules/ee/billing/api/lib/is-subscription-cancelled.ts index 8f584ffb8171..4406d59da717 100644 --- a/apps/web/modules/ee/billing/api/lib/is-subscription-cancelled.ts +++ b/apps/web/modules/ee/billing/api/lib/is-subscription-cancelled.ts @@ -1,7 +1,7 @@ +import { STRIPE_API_VERSION } from "@/lib/constants"; +import { env } from "@/lib/env"; +import { getOrganization } from "@/lib/organization/service"; import Stripe from "stripe"; -import { STRIPE_API_VERSION } from "@formbricks/lib/constants"; -import { env } from "@formbricks/lib/env"; -import { getOrganization } from "@formbricks/lib/organization/service"; import { logger } from "@formbricks/logger"; const stripe = new Stripe(env.STRIPE_SECRET_KEY!, { diff --git a/apps/web/modules/ee/billing/api/lib/stripe-webhook.ts b/apps/web/modules/ee/billing/api/lib/stripe-webhook.ts index 8103599f584f..c93bb0ae88d8 100644 --- a/apps/web/modules/ee/billing/api/lib/stripe-webhook.ts +++ b/apps/web/modules/ee/billing/api/lib/stripe-webhook.ts @@ -1,10 +1,10 @@ +import { STRIPE_API_VERSION } from "@/lib/constants"; +import { env } from "@/lib/env"; import { handleCheckoutSessionCompleted } from "@/modules/ee/billing/api/lib/checkout-session-completed"; import { handleInvoiceFinalized } from "@/modules/ee/billing/api/lib/invoice-finalized"; import { handleSubscriptionCreatedOrUpdated } from "@/modules/ee/billing/api/lib/subscription-created-or-updated"; import { handleSubscriptionDeleted } from "@/modules/ee/billing/api/lib/subscription-deleted"; import Stripe from "stripe"; -import { STRIPE_API_VERSION } from "@formbricks/lib/constants"; -import { env } from "@formbricks/lib/env"; import { logger } from "@formbricks/logger"; const stripe = new Stripe(env.STRIPE_SECRET_KEY!, { diff --git a/apps/web/modules/ee/billing/api/lib/subscription-created-or-updated.ts b/apps/web/modules/ee/billing/api/lib/subscription-created-or-updated.ts index 11fd9c81f567..575fb26f5fb4 100644 --- a/apps/web/modules/ee/billing/api/lib/subscription-created-or-updated.ts +++ b/apps/web/modules/ee/billing/api/lib/subscription-created-or-updated.ts @@ -1,7 +1,7 @@ +import { PROJECT_FEATURE_KEYS, STRIPE_API_VERSION } from "@/lib/constants"; +import { env } from "@/lib/env"; +import { getOrganization, updateOrganization } from "@/lib/organization/service"; import Stripe from "stripe"; -import { PROJECT_FEATURE_KEYS, STRIPE_API_VERSION } from "@formbricks/lib/constants"; -import { env } from "@formbricks/lib/env"; -import { getOrganization, updateOrganization } from "@formbricks/lib/organization/service"; import { logger } from "@formbricks/logger"; import { ResourceNotFoundError } from "@formbricks/types/errors"; import { diff --git a/apps/web/modules/ee/billing/api/lib/subscription-deleted.ts b/apps/web/modules/ee/billing/api/lib/subscription-deleted.ts index 3b6af9e8080e..3ba799dd831c 100644 --- a/apps/web/modules/ee/billing/api/lib/subscription-deleted.ts +++ b/apps/web/modules/ee/billing/api/lib/subscription-deleted.ts @@ -1,6 +1,6 @@ +import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants"; +import { getOrganization, updateOrganization } from "@/lib/organization/service"; import Stripe from "stripe"; -import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@formbricks/lib/constants"; -import { getOrganization, updateOrganization } from "@formbricks/lib/organization/service"; import { logger } from "@formbricks/logger"; import { ResourceNotFoundError } from "@formbricks/types/errors"; diff --git a/apps/web/modules/ee/billing/api/route.ts b/apps/web/modules/ee/billing/api/route.ts index 823ecab216e4..5efefab5b33d 100644 --- a/apps/web/modules/ee/billing/api/route.ts +++ b/apps/web/modules/ee/billing/api/route.ts @@ -1,16 +1,32 @@ -import { responses } from "@/app/lib/api/response"; import { webhookHandler } from "@/modules/ee/billing/api/lib/stripe-webhook"; import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { logger } from "@formbricks/logger"; export const POST = async (request: Request) => { - const body = await request.text(); - const requestHeaders = await headers(); - const signature = requestHeaders.get("stripe-signature") as string; + try { + const body = await request.text(); + const requestHeaders = await headers(); // Corrected: headers() is async + const signature = requestHeaders.get("stripe-signature"); - const { status, message } = await webhookHandler(body, signature); + if (!signature) { + logger.warn("Stripe signature missing from request headers."); + return NextResponse.json({ message: "Stripe signature missing" }, { status: 400 }); + } - if (status != 200) { - return responses.badRequestResponse(message?.toString() || "Something went wrong"); + const result = await webhookHandler(body, signature); + + if (result.status !== 200) { + logger.error(`Webhook handler failed with status ${result.status}: ${result.message?.toString()}`); + return NextResponse.json( + { message: result.message?.toString() || "Webhook processing error" }, + { status: result.status } + ); + } + + return NextResponse.json(result.message || { received: true }, { status: 200 }); + } catch (error: any) { + logger.error(error, `Unhandled error in Stripe webhook POST handler: ${error.message}`); + return NextResponse.json({ message: "Internal server error" }, { status: 500 }); } - return responses.successResponse({ message }, true); }; diff --git a/apps/web/modules/ee/billing/components/billing-slider.tsx b/apps/web/modules/ee/billing/components/billing-slider.tsx index 44ee26bd58be..7f43bb53f784 100644 --- a/apps/web/modules/ee/billing/components/billing-slider.tsx +++ b/apps/web/modules/ee/billing/components/billing-slider.tsx @@ -1,9 +1,9 @@ "use client"; +import { cn } from "@/lib/cn"; import * as SliderPrimitive from "@radix-ui/react-slider"; import { useTranslate } from "@tolgee/react"; import * as React from "react"; -import { cn } from "@formbricks/lib/cn"; interface SliderProps { className?: string; diff --git a/apps/web/modules/ee/billing/components/pricing-card.tsx b/apps/web/modules/ee/billing/components/pricing-card.tsx index 5edcc3d297c6..350667f21fef 100644 --- a/apps/web/modules/ee/billing/components/pricing-card.tsx +++ b/apps/web/modules/ee/billing/components/pricing-card.tsx @@ -1,26 +1,17 @@ "use client"; +import { cn } from "@/lib/cn"; import { Badge } from "@/modules/ui/components/badge"; import { Button } from "@/modules/ui/components/button"; import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal"; import { useTranslate } from "@tolgee/react"; import { CheckIcon } from "lucide-react"; import { useMemo, useState } from "react"; -import { cn } from "@formbricks/lib/cn"; import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations"; +import { TPricingPlan } from "../api/lib/constants"; interface PricingCardProps { - plan: { - id: string; - name: string; - featured: boolean; - price: { - monthly: string; - yearly: string; - }; - mainFeatures: string[]; - href: string; - }; + plan: TPricingPlan; planPeriod: TOrganizationBillingPeriod; organization: TOrganization; onUpgrade: () => Promise; @@ -28,7 +19,6 @@ interface PricingCardProps { projectFeatureKeys: { FREE: string; STARTUP: string; - SCALE: string; ENTERPRISE: string; }; } @@ -72,18 +62,33 @@ export const PricingCard = ({ return null; } - if (plan.id !== projectFeatureKeys.ENTERPRISE && plan.id !== projectFeatureKeys.FREE) { + if (plan.id === projectFeatureKeys.ENTERPRISE) { + return ( + + ); + } + + if (plan.id === projectFeatureKeys.STARTUP) { if (organization.billing.plan === projectFeatureKeys.FREE) { return ( ); } @@ -100,15 +105,20 @@ export const PricingCard = ({ ); } - return <>; + return null; }, [ isCurrentPlan, loading, onUpgrade, organization.billing.plan, + plan.CTA, + plan.featured, + plan.href, plan.id, projectFeatureKeys.ENTERPRISE, projectFeatureKeys.FREE, + projectFeatureKeys.STARTUP, + t, ]); return ( @@ -128,7 +138,7 @@ export const PricingCard = ({ plan.featured ? "text-slate-900" : "text-slate-800", "text-sm font-semibold leading-6" )}> - {t(plan.name)} + {plan.name} {isCurrentPlan && ( @@ -145,9 +155,9 @@ export const PricingCard = ({ ? planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly - : t(plan.price.monthly)} + : plan.price.monthly}

        - {plan.name !== "Enterprise" && ( + {plan.id !== projectFeatureKeys.ENTERPRISE && (

        / {planPeriod === "monthly" ? "Month" : "Year"} @@ -171,16 +181,9 @@ export const PricingCard = ({ {t("environments.settings.billing.manage_subscription")} )} - - {organization.billing.plan !== plan.id && plan.id === projectFeatureKeys.ENTERPRISE && ( - - )}

        @@ -213,7 +215,7 @@ export const PricingCard = ({ open={upgradeModalOpen} setOpen={setUpgradeModalOpen} text={t("environments.settings.billing.switch_plan_confirmation_text", { - plan: t(plan.name), + plan: plan.name, price: planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly, period: planPeriod === "monthly" diff --git a/apps/web/modules/ee/billing/components/pricing-table.test.tsx b/apps/web/modules/ee/billing/components/pricing-table.test.tsx new file mode 100644 index 000000000000..78ecaac999fd --- /dev/null +++ b/apps/web/modules/ee/billing/components/pricing-table.test.tsx @@ -0,0 +1,174 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { TOrganizationBillingPeriod } from "@formbricks/types/organizations"; +import { PricingTable } from "./pricing-table"; + +// Mock the env module +vi.mock("@/lib/env", () => ({ + env: { + IS_FORMBRICKS_CLOUD: "0", + NODE_ENV: "test", + }, +})); + +// Mock the useRouter hook +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + }), +})); + +// Mock the actions module +vi.mock("@/modules/ee/billing/actions", () => { + const mockDate = new Date("2024-03-15T00:00:00.000Z"); + return { + isSubscriptionCancelledAction: vi.fn(() => Promise.resolve({ data: { date: mockDate } })), + manageSubscriptionAction: vi.fn(() => Promise.resolve({ data: null })), + upgradePlanAction: vi.fn(() => Promise.resolve({ data: null })), + }; +}); + +// Mock the useTranslate hook +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +describe("PricingTable", () => { + afterEach(() => { + cleanup(); + }); + + test("should display a 'Cancelling' badge with the correct date if the subscription is being cancelled", async () => { + const mockOrganization = { + id: "org-123", + name: "Test Organization", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + plan: "free", + period: "yearly", + periodStart: new Date(), + stripeCustomerId: null, + limits: { + monthly: { + responses: 100, + miu: 100, + }, + projects: 1, + }, + }, + isAIEnabled: false, + }; + + const mockStripePriceLookupKeys = { + STARTUP_MONTHLY: "startup_monthly", + STARTUP_YEARLY: "startup_yearly", + SCALE_MONTHLY: "scale_monthly", + SCALE_YEARLY: "scale_yearly", + }; + + const mockProjectFeatureKeys = { + FREE: "free", + STARTUP: "startup", + SCALE: "scale", + ENTERPRISE: "enterprise", + }; + + render( + + ); + + const expectedDate = new Date("2024-03-15T00:00:00.000Z").toLocaleDateString("en-US", { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC", + }); + const cancellingBadge = await screen.findByText(`Cancelling: ${expectedDate}`); + expect(cancellingBadge).toBeInTheDocument(); + }); + + test("billing period toggle buttons have correct aria-pressed attributes", async () => { + const MockPricingTable = () => { + const [planPeriod, setPlanPeriod] = useState("yearly"); + + const mockOrganization = { + id: "org-123", + name: "Test Organization", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + plan: "free", + period: "yearly", + periodStart: new Date(), + stripeCustomerId: null, + limits: { + monthly: { + responses: 100, + miu: 100, + }, + projects: 1, + }, + }, + isAIEnabled: false, + }; + + const mockStripePriceLookupKeys = { + STARTUP_MONTHLY: "startup_monthly", + STARTUP_YEARLY: "startup_yearly", + SCALE_MONTHLY: "scale_monthly", + SCALE_YEARLY: "scale_yearly", + }; + + const mockProjectFeatureKeys = { + FREE: "free", + STARTUP: "startup", + SCALE: "scale", + ENTERPRISE: "enterprise", + }; + + const handleMonthlyToggle = (period: TOrganizationBillingPeriod) => { + setPlanPeriod(period); + }; + + return ( + + ); + }; + + render(); + + const monthlyButton = screen.getByText("environments.settings.billing.monthly"); + const yearlyButton = screen.getByText("environments.settings.billing.annually"); + + expect(yearlyButton).toHaveAttribute("aria-pressed", "true"); + expect(monthlyButton).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(monthlyButton); + + expect(yearlyButton).toHaveAttribute("aria-pressed", "false"); + expect(monthlyButton).toHaveAttribute("aria-pressed", "true"); + }); +}); diff --git a/apps/web/modules/ee/billing/components/pricing-table.tsx b/apps/web/modules/ee/billing/components/pricing-table.tsx index 81041838b654..f7594b843127 100644 --- a/apps/web/modules/ee/billing/components/pricing-table.tsx +++ b/apps/web/modules/ee/billing/components/pricing-table.tsx @@ -1,13 +1,13 @@ "use client"; +import { cn } from "@/lib/cn"; +import { capitalizeFirstLetter } from "@/lib/utils/strings"; import { Badge } from "@/modules/ui/components/badge"; import { Button } from "@/modules/ui/components/button"; import { useTranslate } from "@tolgee/react"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; -import { cn } from "@formbricks/lib/cn"; -import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings"; import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations"; import { isSubscriptionCancelledAction, manageSubscriptionAction, upgradePlanAction } from "../actions"; import { getCloudPricingData } from "../api/lib/constants"; @@ -21,15 +21,12 @@ interface PricingTableProps { responseCount: number; projectCount: number; stripePriceLookupKeys: { - STARTUP_MONTHLY: string; - STARTUP_YEARLY: string; - SCALE_MONTHLY: string; - SCALE_YEARLY: string; + STARTUP_MAY25_MONTHLY: string; + STARTUP_MAY25_YEARLY: string; }; projectFeatureKeys: { FREE: string; STARTUP: string; - SCALE: string; ENTERPRISE: string; }; hasBillingRights: boolean; @@ -73,7 +70,7 @@ export const PricingTable = ({ const manageSubscriptionResponse = await manageSubscriptionAction({ environmentId, }); - if (manageSubscriptionResponse?.data) { + if (manageSubscriptionResponse?.data && typeof manageSubscriptionResponse.data === "string") { router.push(manageSubscriptionResponse.data); } }; @@ -102,35 +99,31 @@ export const PricingTable = ({ throw new Error(t("common.something_went_wrong_please_try_again")); } } catch (err) { - toast.error(t("environments.settings.billing.unable_to_upgrade_plan")); + if (err instanceof Error) { + toast.error(err.message); + } else { + toast.error(t("environments.settings.billing.unable_to_upgrade_plan")); + } } }; const onUpgrade = async (planId: string) => { - if (planId === "scale") { - await upgradePlan( - planPeriod === "monthly" ? stripePriceLookupKeys.SCALE_MONTHLY : stripePriceLookupKeys.SCALE_YEARLY - ); - return; - } - if (planId === "startup") { await upgradePlan( planPeriod === "monthly" - ? stripePriceLookupKeys.STARTUP_MONTHLY - : stripePriceLookupKeys.STARTUP_YEARLY + ? stripePriceLookupKeys.STARTUP_MAY25_MONTHLY + : stripePriceLookupKeys.STARTUP_MAY25_YEARLY ); return; } - if (planId === "enterprise") { - window.location.href = "https://cal.com/johannes/license"; + if (planId === "custom") { + window.location.href = "https://formbricks.com/custom-plan?source=billingView"; return; } if (planId === "free") { toast.error(t("environments.settings.billing.everybody_has_the_free_plan_by_default")); - return; } }; @@ -154,7 +147,17 @@ export const PricingTable = ({ className="mx-2" size="normal" type="warning" - text={`Cancelling: ${cancellingOn ? cancellingOn.toDateString() : ""}`} + text={`Cancelling: ${ + cancellingOn + ? cancellingOn.toLocaleDateString("en-US", { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC", + }) + : "" + }`} /> )} @@ -223,7 +226,7 @@ export const PricingTable = ({

        {t("common.projects")}

        @@ -252,14 +255,16 @@ export const PricingTable = ({
        -
        handleMonthlyToggle("monthly")}> {t("environments.settings.billing.monthly")} -
        -
        +
        +
        -
        +