Compare commits

...

17 Commits

Author SHA1 Message Date
Jonas Höbenreich 8da1bc71a6 fix: duplicate name survey copy issue (#3865)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-11 10:59:51 +02:00
Piyush Gupta 0e0259691c fix: recall in survey editor (#5938) 2025-06-11 05:33:52 +00:00
Dhruwang Jariwala ac7831fa3d fix: auth checks in storage management api (#5931) 2025-06-11 04:56:20 +00:00
Dhruwang Jariwala db32cb392f chore: added curosr rules for database schema (#5935) 2025-06-10 14:01:06 +00:00
Piyush Jain e5cb01bd88 chore(cache): remove old servers (#5950) 2025-06-10 06:44:19 +00:00
Piyush Gupta cbef4c2a69 fix: broken templates (#5955)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-06-10 05:04:05 +00:00
Harsh Bhat 86948b70de docs: add EE license activation docs (#5930)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-06-09 12:39:03 +00:00
Dhruwang Jariwala dfe955ca7c chore: purge cache after deployment (#5934)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-09 11:02:28 +00:00
Piyush Jain eb4b2dde05 chore(elasticache): add serverless redis (#5943) 2025-06-09 07:01:51 +00:00
victorvhs017 f2dae67813 chore: updated docs (#5940) 2025-06-06 11:54:24 +00:00
DivyanshuLohani 3ffc9bd290 fix: iframe url not being automatically populated (#5892)
Co-authored-by: Divyanshu Lohani <DivyanshuLohani@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-06-06 09:12:59 +02:00
victorvhs017 a9946737df feat: audit logs (#5866)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-06-05 19:31:39 +00:00
Archit Sehgal ece3d508a2 fix: back button in survey published modal closes modal instead of navigating (#5831)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-06-05 20:45:47 +02:00
Piyush Gupta 0d1d227e6a feat: add security headers for Referrer-Policy and Permissions-Policy (#5877) 2025-06-05 04:21:10 +00:00
Matti Nannt c0b8edfdf2 chore: Comprehensive Cache Optimization & Performance Enhancement (#5926)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-04 20:33:17 +02:00
Dhruwang Jariwala 45fec0e184 fix: language tweaks (#5933) 2025-06-04 11:27:19 +00:00
Dhruwang Jariwala 2c2ba919c6 fix: backspacing headline causing infinite loop (#5891)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-06-04 10:26:15 +00:00
504 changed files with 16765 additions and 17305 deletions
+414
View File
@@ -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<Response> => {
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)
+101
View File
@@ -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.
+152
View File
@@ -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
+45
View File
@@ -5,6 +5,51 @@ alwaysApply: false
--- ---
# Testing Patterns & Best Practices # 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 -- <file-pattern>` - 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 ## Test File Naming & Environment
### File Extensions ### File Extensions
+2 -1
View File
@@ -3,4 +3,5 @@ description: Whenever the user asks to write or update a test file for .tsx or .
globs: globs:
alwaysApply: false alwaysApply: false
--- ---
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md) 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.
+6 -1
View File
@@ -190,7 +190,7 @@ UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided) # 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 # You can also add more configuration to Redis using the redis.conf file in the root directory
# REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this) # 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: # REDIS_HTTP_URL:
@@ -216,3 +216,8 @@ UNKEY_ROOT_KEY=
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours) # Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400 # SESSION_MAX_AGE=86400
# Audit logs options. Requires REDIS_URL env varibale. 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
+50 -10
View File
@@ -4,16 +4,16 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
VERSION: VERSION:
description: 'The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0.' description: "The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0."
required: true required: true
type: string type: string
REPOSITORY: REPOSITORY:
description: 'The repository to use for the Docker image' description: "The repository to use for the Docker image"
required: false required: false
type: string type: string
default: 'ghcr.io/formbricks/formbricks' default: "ghcr.io/formbricks/formbricks"
ENVIRONMENT: ENVIRONMENT:
description: 'The environment to deploy to' description: "The environment to deploy to"
required: true required: true
type: choice type: choice
options: options:
@@ -22,16 +22,16 @@ on:
workflow_call: workflow_call:
inputs: inputs:
VERSION: VERSION:
description: 'The version of the Docker image to release' description: "The version of the Docker image to release"
required: true required: true
type: string type: string
REPOSITORY: REPOSITORY:
description: 'The repository to use for the Docker image' description: "The repository to use for the Docker image"
required: false required: false
type: string type: string
default: 'ghcr.io/formbricks/formbricks' default: "ghcr.io/formbricks/formbricks"
ENVIRONMENT: ENVIRONMENT:
description: 'The environment to deploy to' description: "The environment to deploy to"
required: true required: true
type: string type: string
@@ -75,7 +75,7 @@ jobs:
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }} FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }} FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
with: with:
helmfile-version: 'v1.0.0' helmfile-version: "v1.0.0"
helm-plugins: > helm-plugins: >
https://github.com/databus23/helm-diff, https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets https://github.com/jkroepke/helm-secrets
@@ -92,7 +92,7 @@ jobs:
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }} FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }} FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
with: with:
helmfile-version: 'v1.0.0' helmfile-version: "v1.0.0"
helm-plugins: > helm-plugins: >
https://github.com/databus23/helm-diff, https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets https://github.com/jkroepke/helm-secrets
@@ -100,3 +100,43 @@ jobs:
helmfile-auto-init: "false" helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm helmfile-workdirectory: infra/formbricks-cloud-helm
- name: Purge Cloudflare Cache
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
# Set hostname based on environment
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
PURGE_HOST="app.formbricks.com"
else
PURGE_HOST="stage.app.formbricks.com"
fi
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.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
+10
View File
@@ -45,6 +45,16 @@ jobs:
--health-interval=10s --health-interval=10s
--health-timeout=5s --health-timeout=5s
--health-retries=5 --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: steps:
- name: Harden the runner (Audit all outbound calls) - name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
@@ -86,6 +86,8 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256", OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
})); }));
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
@@ -12,20 +12,6 @@ vi.mock("@formbricks/database", () => ({
}, },
})); }));
vi.mock("@/lib/cache", () => ({
cache: (fn: any) => fn,
}));
vi.mock("@/lib/cache/team", () => ({
teamCache: {
tag: { byOrganizationId: vi.fn((id: string) => `organization-${id}-teams`) },
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
describe("getTeamsByOrganizationId", () => { describe("getTeamsByOrganizationId", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -1,8 +1,6 @@
"use server"; "use server";
import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding"; import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";
import { cache } from "@/lib/cache";
import { teamCache } from "@/lib/cache/team";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
@@ -11,38 +9,31 @@ import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
export const getTeamsByOrganizationId = reactCache( export const getTeamsByOrganizationId = reactCache(
async (organizationId: string): Promise<TOrganizationTeam[] | null> => async (organizationId: string): Promise<TOrganizationTeam[] | null> => {
cache( validateInputs([organizationId, ZId]);
async () => { try {
validateInputs([organizationId, ZId]); const teams = await prisma.team.findMany({
try { where: {
const teams = await prisma.team.findMany({ organizationId,
where: { },
organizationId, select: {
}, id: true,
select: { name: true,
id: true, },
name: true, });
},
});
const projectTeams = teams.map((team) => ({ const projectTeams = teams.map((team) => ({
id: team.id, id: team.id,
name: team.name, name: team.name,
})); }));
return projectTeams; return projectTeams;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
}
throw error;
}
},
[`getTeamsByOrganizationId-${organizationId}`],
{
tags: [teamCache.tag.byOrganizationId(organizationId)],
} }
)()
throw error;
}
}
); );
@@ -1,15 +1,33 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { signOut } from "next-auth/react";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { LandingSidebar } from "./landing-sidebar"; 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 // Module mocks must be declared before importing the component
vi.mock("@tolgee/react", () => ({ vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key, isLoading: false }), useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
})); }));
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
// 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("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({ vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
CreateOrganizationModal: ({ open }: { open: boolean }) => ( CreateOrganizationModal: ({ open }: { open: boolean }) => (
@@ -70,6 +88,12 @@ describe("LandingSidebar component", () => {
const logoutItem = await screen.findByText("common.logout"); const logoutItem = await screen.findByText("common.logout");
await userEvent.click(logoutItem); await userEvent.click(logoutItem);
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" }); expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "o1",
redirect: true,
callbackUrl: "/auth/login",
});
}); });
}); });
@@ -3,6 +3,7 @@
import FBLogo from "@/images/formbricks-wordmark.svg"; import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings"; import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal"; import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars"; import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { import {
@@ -20,7 +21,6 @@ import {
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react"; import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
import { signOut } from "next-auth/react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -44,6 +44,7 @@ export const LandingSidebar = ({
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false); const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
const { t } = useTranslate(); const { t } = useTranslate();
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const router = useRouter(); const router = useRouter();
@@ -123,7 +124,13 @@ export const LandingSidebar = ({
<DropdownMenuItem <DropdownMenuItem
onClick={async () => { onClick={async () => {
await signOut({ callbackUrl: "/auth/login" }); await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: true,
callbackUrl: "/auth/login",
});
}} }}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}> icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")} {t("common.logout")}
@@ -89,6 +89,8 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256", OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
})); }));
vi.mock("@/lib/environment/service"); vi.mock("@/lib/environment/service");
@@ -98,6 +98,8 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256", OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
})); }));
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({ vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
@@ -35,6 +35,8 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
})); }));
vi.mock("next-auth", () => ({ vi.mock("next-auth", () => ({
@@ -34,6 +34,8 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
})); }));
// Mock dependencies // Mock dependencies
@@ -26,6 +26,8 @@ vi.mock("@/lib/constants", () => ({
SMTP_PORT: "mock-smtp-port", SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true, IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
REDIS_URL: "redis://localhost:6379",
})); }));
describe("Contact Page Re-export", () => { describe("Contact Page Re-export", () => {
@@ -4,7 +4,9 @@ import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service"; import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service"; import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; 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 { import {
getOrganizationProjectsLimit, getOrganizationProjectsLimit,
getRoleManagementPermission, getRoleManagementPermission,
@@ -20,62 +22,69 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput, data: ZProjectUpdateInput,
}); });
export const createProjectAction = authenticatedActionClient export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
.schema(ZCreateProjectAction) withAuditLogging(
.action(async ({ parsedInput, ctx }) => { "created",
const { user } = ctx; "project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId; const organizationId = parsedInput.organizationId;
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: user.id, userId: user.id,
organizationId: parsedInput.organizationId, organizationId: parsedInput.organizationId,
access: [ access: [
{ {
data: parsedInput.data, data: parsedInput.data,
schema: ZProjectUpdateInput, schema: ZProjectUpdateInput,
type: "organization", type: "organization",
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
], ],
}); });
const organization = await getOrganization(organizationId); const organization = await getOrganization(organizationId);
if (!organization) { if (!organization) {
throw new Error("Organization not found"); throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
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 (!canDoRoleManagement) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
} }
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
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 (!canDoRoleManagement) {
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,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
} }
)
const project = await createProject(parsedInput.organizationId, parsedInput.data); );
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
return project;
});
@@ -1,11 +1,12 @@
"use server"; "use server";
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service"; import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { cache } from "@/lib/cache";
import { getSurveysByActionClassId } from "@/lib/survey/service"; import { getSurveysByActionClassId } from "@/lib/survey/service";
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client"; 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 { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod"; import { z } from "zod";
import { ZActionClassInput } from "@formbricks/types/action-classes"; import { ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
@@ -15,63 +16,80 @@ const ZDeleteActionClassAction = z.object({
actionClassId: ZId, actionClassId: ZId,
}); });
export const deleteActionClassAction = authenticatedActionClient export const deleteActionClassAction = authenticatedActionClient.schema(ZDeleteActionClassAction).action(
.schema(ZDeleteActionClassAction) withAuditLogging(
.action(async ({ ctx, parsedInput }) => { "deleted",
await checkAuthorizationUpdated({ "actionClass",
userId: ctx.user.id, async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId), const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId);
access: [ await checkAuthorizationUpdated({
{ userId: ctx.user.id,
type: "organization", organizationId,
roles: ["owner", "manager"], access: [
}, {
{ type: "organization",
type: "projectTeam", roles: ["owner", "manager"],
minPermission: "readWrite", },
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId), {
}, type: "projectTeam",
], minPermission: "readWrite",
}); projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
await deleteActionClass(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({ const ZUpdateActionClassAction = z.object({
actionClassId: ZId, actionClassId: ZId,
updatedAction: ZActionClassInput, updatedAction: ZActionClassInput,
}); });
export const updateActionClassAction = authenticatedActionClient export const updateActionClassAction = authenticatedActionClient.schema(ZUpdateActionClassAction).action(
.schema(ZUpdateActionClassAction) withAuditLogging(
.action(async ({ ctx, parsedInput }) => { "updated",
const actionClass = await getActionClass(parsedInput.actionClassId); "actionClass",
if (actionClass === null) { async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId); const actionClass = await getActionClass(parsedInput.actionClassId);
if (actionClass === null) {
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
}
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 = actionClass;
const result = await updateActionClass(
actionClass.environmentId,
parsedInput.actionClassId,
parsedInput.updatedAction
);
ctx.auditLoggingCtx.newObject = result;
return result;
} }
)
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),
},
],
});
return await updateActionClass(
actionClass.environmentId,
parsedInput.actionClassId,
parsedInput.updatedAction
);
});
const ZGetActiveInactiveSurveysAction = z.object({ const ZGetActiveInactiveSurveysAction = z.object({
actionClassId: ZId, actionClassId: ZId,
@@ -104,31 +122,24 @@ export const getActiveInactiveSurveysAction = authenticatedActionClient
return response; return response;
}); });
const getLatestStableFbRelease = async (): Promise<string | null> => const getLatestStableFbRelease = async (): Promise<string | null> => {
cache( try {
async () => { const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases");
try { const releases = await res.json();
const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases");
const releases = await res.json();
if (Array.isArray(releases)) { if (Array.isArray(releases)) {
const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0] const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0]
?.tag_name as string; ?.tag_name as string;
if (latestStableReleaseTag) { if (latestStableReleaseTag) {
return latestStableReleaseTag; return latestStableReleaseTag;
}
}
return null;
} catch (err) {
return null;
} }
},
["latest-fb-release"],
{
revalidate: 60 * 60 * 24, // 24 hours
} }
)();
return null;
} catch (err) {
return null;
}
};
export const getLatestStableFbReleaseAction = actionClient.action(async () => { export const getLatestStableFbReleaseAction = actionClient.action(async () => {
return await getLatestStableFbRelease(); return await getLatestStableFbRelease();
@@ -1,6 +1,6 @@
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { signOut } from "next-auth/react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
@@ -10,6 +10,17 @@ import { TUser } from "@formbricks/types/user";
import { getLatestStableFbReleaseAction } from "../actions/actions"; import { getLatestStableFbReleaseAction } from "../actions/actions";
import { MainNavigation } from "./MainNavigation"; 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 // Mock dependencies
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })), useRouter: vi.fn(() => ({ push: vi.fn() })),
@@ -18,6 +29,9 @@ vi.mock("next/navigation", () => ({
vi.mock("next-auth/react", () => ({ vi.mock("next-auth/react", () => ({
signOut: vi.fn(), 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", () => ({ vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
getLatestStableFbReleaseAction: vi.fn(), getLatestStableFbReleaseAction: vi.fn(),
})); }));
@@ -203,7 +217,9 @@ describe("MainNavigation", () => {
}); });
test("renders user dropdown and handles logout", async () => { test("renders user dropdown and handles logout", async () => {
vi.mocked(signOut).mockResolvedValue({ url: "/auth/login" }); const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
render(<MainNavigation {...defaultProps} />); render(<MainNavigation {...defaultProps} />);
// Find the avatar and get its parent div which acts as the trigger // Find the avatar and get its parent div which acts as the trigger
@@ -224,7 +240,13 @@ describe("MainNavigation", () => {
const logoutButton = screen.getByText("common.logout"); const logoutButton = screen.getByText("common.logout");
await userEvent.click(logoutButton); await userEvent.click(logoutButton);
expect(signOut).toHaveBeenCalledWith({ redirect: false, callbackUrl: "/auth/login" }); expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "org1",
redirect: false,
callbackUrl: "/auth/login",
});
await waitFor(() => { await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
}); });
@@ -6,6 +6,7 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings"; import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal"; import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher"; import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
import { ProfileAvatar } from "@/modules/ui/components/avatars"; import { ProfileAvatar } from "@/modules/ui/components/avatars";
@@ -42,7 +43,6 @@ import {
UserIcon, UserIcon,
UsersIcon, UsersIcon,
} from "lucide-react"; } from "lucide-react";
import { signOut } from "next-auth/react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
@@ -90,6 +90,7 @@ export const MainNavigation = ({
const [isCollapsed, setIsCollapsed] = useState(true); const [isCollapsed, setIsCollapsed] = useState(true);
const [isTextVisible, setIsTextVisible] = useState(true); const [isTextVisible, setIsTextVisible] = useState(true);
const [latestVersion, setLatestVersion] = useState(""); const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const project = projects.find((project) => project.id === environment.projectId); const project = projects.find((project) => project.id === environment.projectId);
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole); const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
@@ -389,8 +390,14 @@ export const MainNavigation = ({
<DropdownMenuItem <DropdownMenuItem
onClick={async () => { onClick={async () => {
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" }); const route = await signOutWithAudit({
router.push(route.url); reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: false,
callbackUrl: "/auth/login",
});
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
}} }}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}> icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")} {t("common.logout")}
@@ -2,13 +2,15 @@
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service"; import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; 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 { import {
getOrganizationIdFromEnvironmentId, getOrganizationIdFromEnvironmentId,
getOrganizationIdFromIntegrationId, getOrganizationIdFromIntegrationId,
getProjectIdFromEnvironmentId, getProjectIdFromEnvironmentId,
getProjectIdFromIntegrationId, getProjectIdFromIntegrationId,
} from "@/lib/utils/helper"; } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ZIntegrationInput } from "@formbricks/types/integration"; import { ZIntegrationInput } from "@formbricks/types/integration";
@@ -20,48 +22,79 @@ const ZCreateOrUpdateIntegrationAction = z.object({
export const createOrUpdateIntegrationAction = authenticatedActionClient export const createOrUpdateIntegrationAction = authenticatedActionClient
.schema(ZCreateOrUpdateIntegrationAction) .schema(ZCreateOrUpdateIntegrationAction)
.action(async ({ ctx, parsedInput }) => { .action(
await checkAuthorizationUpdated({ withAuditLogging(
userId: ctx.user.id, "createdUpdated",
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), "integration",
access: [ async ({
{ ctx,
type: "organization", parsedInput,
roles: ["owner", "manager"], }: {
}, ctx: AuthenticatedActionClientCtx;
{ parsedInput: Record<string, any>;
type: "projectTeam", }) => {
minPermission: "readWrite", const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
projectId: await getProjectIdFromEnvironmentId(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({ const ZDeleteIntegrationAction = z.object({
integrationId: ZId, integrationId: ZId,
}); });
export const deleteIntegrationAction = authenticatedActionClient export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action(
.schema(ZDeleteIntegrationAction) withAuditLogging(
.action(async ({ ctx, parsedInput }) => { "deleted",
await checkAuthorizationUpdated({ "integration",
userId: ctx.user.id, async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
organizationId: await getOrganizationIdFromIntegrationId(parsedInput.integrationId), const organizationId = await getOrganizationIdFromIntegrationId(parsedInput.integrationId);
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],
});
return await deleteIntegration(parsedInput.integrationId); await checkAuthorizationUpdated({
}); userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.integrationId = parsedInput.integrationId;
const result = await deleteIntegration(parsedInput.integrationId);
ctx.auditLoggingCtx.oldObject = result;
return result;
}
)
);
@@ -49,6 +49,8 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
})); }));
vi.mock("@/lib/integration/service"); vi.mock("@/lib/integration/service");
@@ -2,7 +2,7 @@
import { getSpreadsheetNameById } from "@/lib/googleSheet/service"; import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; 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 { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod"; import { z } from "zod";
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet"; import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
@@ -1,10 +1,8 @@
import { cache } from "@/lib/cache";
import { surveyCache } from "@/lib/survey/cache";
import { selectSurvey } from "@/lib/survey/service"; import { selectSurvey } from "@/lib/survey/service";
import { transformPrismaSurvey } from "@/lib/survey/utils"; import { transformPrismaSurvey } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
@@ -12,14 +10,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { getSurveys } from "./surveys"; import { getSurveys } from "./surveys";
// Mock dependencies // Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/survey/cache", () => ({
surveyCache: {
tag: {
byEnvironmentId: vi.fn((environmentId) => `survey_environment_${environmentId}`),
},
},
}));
vi.mock("@/lib/survey/service", () => ({ vi.mock("@/lib/survey/service", () => ({
selectSurvey: { id: true, name: true, status: true, updatedAt: true }, // Expanded mock based on usage selectSurvey: { id: true, name: true, status: true, updatedAt: true }, // Expanded mock based on usage
})); }));
@@ -46,11 +36,11 @@ vi.mock("react", async (importOriginal) => {
}); });
const environmentId = "test-environment-id"; const environmentId = "test-environment-id";
// Ensure mockPrismaSurveys includes all fields used in selectSurvey mock // Use 'as any' to bypass complex type matching for mock data
const mockPrismaSurveys = [ const mockPrismaSurveys = [
{ id: "survey1", name: "Survey 1", status: "inProgress", updatedAt: new Date() }, { id: "survey1", name: "Survey 1", status: "inProgress", updatedAt: new Date() },
{ id: "survey2", name: "Survey 2", status: "draft", 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[] = [ const mockTransformedSurveys: TSurvey[] = [
{ {
id: "survey1", id: "survey1",
@@ -99,14 +89,8 @@ const mockTransformedSurveys: TSurvey[] = [
]; ];
describe("getSurveys", () => { describe("getSurveys", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("should fetch and transform surveys successfully", async () => { test("should fetch and transform surveys successfully", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys); vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys as any);
vi.mocked(transformPrismaSurvey).mockImplementation((survey) => { vi.mocked(transformPrismaSurvey).mockImplementation((survey) => {
const found = mockTransformedSurveys.find((ts) => ts.id === survey.id); const found = mockTransformedSurveys.find((ts) => ts.id === survey.id);
if (!found) throw new Error("Survey not found in mock transformed data"); if (!found) throw new Error("Survey not found in mock transformed data");
@@ -134,39 +118,29 @@ describe("getSurveys", () => {
expect(transformPrismaSurvey).toHaveBeenCalledTimes(mockPrismaSurveys.length); expect(transformPrismaSurvey).toHaveBeenCalledTimes(mockPrismaSurveys.length);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[0]); expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[0]);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[1]); expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[1]);
// Check if the inner cache function was called with the correct arguments // React cache is already mocked globally - no need to check it here
expect(cache).toHaveBeenCalledWith(
expect.any(Function), // The async function passed to cache
[`getSurveys-${environmentId}`], // The cache key
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)], // Cache tags
}
);
// Remove the assertion for reactCache being called within the test execution
// expect(reactCache).toHaveBeenCalled(); // Removed this line
}); });
test("should throw DatabaseError on Prisma known request error", async () => { test("should throw DatabaseError on Prisma known request error", async () => {
// No need to mock cache here again as beforeEach handles it const prismaError = new Prisma.PrismaClientKnownRequestError("Database connection error", {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", { code: "P2002",
code: "P2025", clientVersion: "4.0.0",
clientVersion: "5.0.0",
meta: {}, // Added meta property
}); });
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(prismaError);
await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError); await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys"); expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys");
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called // React cache is already mocked globally - no need to check it here
}); });
test("should throw original error on other errors", async () => { test("should throw original error on other errors", async () => {
// No need to mock cache here again as beforeEach handles it const genericError = new Error("Some other error");
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError); vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(genericError);
await expect(getSurveys(environmentId)).rejects.toThrow(genericError); await expect(getSurveys(environmentId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled(); expect(logger.error).not.toHaveBeenCalled();
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called // React cache is already mocked globally - no need to check it here
}); });
}); });
@@ -1,6 +1,4 @@
import "server-only"; import "server-only";
import { cache } from "@/lib/cache";
import { surveyCache } from "@/lib/survey/cache";
import { selectSurvey } from "@/lib/survey/service"; import { selectSurvey } from "@/lib/survey/service";
import { transformPrismaSurvey } from "@/lib/survey/utils"; import { transformPrismaSurvey } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
@@ -12,38 +10,29 @@ import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
export const getSurveys = reactCache( export const getSurveys = reactCache(async (environmentId: string): Promise<TSurvey[]> => {
async (environmentId: string): Promise<TSurvey[]> => validateInputs([environmentId, ZId]);
cache(
async () => {
validateInputs([environmentId, ZId]);
try { try {
const surveysPrisma = await prisma.survey.findMany({ const surveysPrisma = await prisma.survey.findMany({
where: { where: {
environmentId, environmentId,
status: { status: {
not: "completed", not: "completed",
}, },
},
select: selectSurvey,
orderBy: {
updatedAt: "desc",
},
});
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error({ error }, "getSurveys: Could not fetch surveys");
throw new DatabaseError(error.message);
}
throw error;
}
}, },
[`getSurveys-${environmentId}`], select: selectSurvey,
{ orderBy: {
tags: [surveyCache.tag.byEnvironmentId(environmentId)], updatedAt: "desc",
} },
)() });
);
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error({ error }, "getSurveys: Could not fetch surveys");
throw new DatabaseError(error.message);
}
throw error;
}
});
@@ -1,21 +1,10 @@
import { cache } from "@/lib/cache";
import { webhookCache } from "@/lib/cache/webhook";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { getWebhookCountBySource } from "./webhook"; import { getWebhookCountBySource } from "./webhook";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/cache/webhook", () => ({
webhookCache: {
tag: {
byEnvironmentIdAndSource: vi.fn((envId, source) => `webhook_${envId}_${source ?? "all"}`),
},
},
}));
vi.mock("@/lib/utils/validate"); vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
prisma: { prisma: {
@@ -29,12 +18,6 @@ const environmentId = "test-environment-id";
const sourceZapier = "zapier"; const sourceZapier = "zapier";
describe("getWebhookCountBySource", () => { describe("getWebhookCountBySource", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => { afterEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
}); });
@@ -56,13 +39,6 @@ describe("getWebhookCountBySource", () => {
source: sourceZapier, source: sourceZapier,
}, },
}); });
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getWebhookCountBySource-${environmentId}-${sourceZapier}`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, sourceZapier)],
}
);
}); });
test("should return total webhook count when source is undefined", async () => { test("should return total webhook count when source is undefined", async () => {
@@ -82,13 +58,6 @@ describe("getWebhookCountBySource", () => {
source: undefined, source: undefined,
}, },
}); });
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getWebhookCountBySource-${environmentId}-undefined`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, undefined)],
}
);
}); });
test("should throw DatabaseError on Prisma known request error", async () => { test("should throw DatabaseError on Prisma known request error", async () => {
@@ -100,7 +69,6 @@ describe("getWebhookCountBySource", () => {
await expect(getWebhookCountBySource(environmentId, sourceZapier)).rejects.toThrow(DatabaseError); await expect(getWebhookCountBySource(environmentId, sourceZapier)).rejects.toThrow(DatabaseError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1); expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledTimes(1);
}); });
test("should throw original error on other errors", async () => { test("should throw original error on other errors", async () => {
@@ -109,6 +77,5 @@ describe("getWebhookCountBySource", () => {
await expect(getWebhookCountBySource(environmentId)).rejects.toThrow(genericError); await expect(getWebhookCountBySource(environmentId)).rejects.toThrow(genericError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1); expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -1,5 +1,3 @@
import { cache } from "@/lib/cache";
import { webhookCache } from "@/lib/cache/webhook";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { Prisma, Webhook } from "@prisma/client"; import { Prisma, Webhook } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
@@ -7,29 +5,25 @@ import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
export const getWebhookCountBySource = (environmentId: string, source?: Webhook["source"]): Promise<number> => export const getWebhookCountBySource = async (
cache( environmentId: string,
async () => { source?: Webhook["source"]
validateInputs([environmentId, ZId], [source, z.string().optional()]); ): Promise<number> => {
validateInputs([environmentId, ZId], [source, z.string().optional()]);
try { try {
const count = await prisma.webhook.count({ const count = await prisma.webhook.count({
where: { where: {
environmentId, environmentId,
source, source,
}, },
}); });
return count; return count;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
}
throw error;
}
},
[`getWebhookCountBySource-${environmentId}-${source}`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, source)],
} }
)();
throw error;
}
};
@@ -32,6 +32,8 @@ vi.mock("@/lib/constants", () => ({
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret", GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url", GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "mock-redis-url",
AUDIT_LOG_ENABLED: true,
})); }));
// Mock child components // Mock child components
@@ -2,7 +2,7 @@
import { getSlackChannels } from "@/lib/slack/service"; import { getSlackChannels } from "@/lib/slack/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; 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 { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
})); }));
describe("AppConnectionPage Re-export", () => { describe("AppConnectionPage Re-export", () => {
@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
})); }));
describe("GeneralSettingsPage re-export", () => { describe("GeneralSettingsPage re-export", () => {
@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
})); }));
describe("LanguagesPage re-export", () => { describe("LanguagesPage re-export", () => {
@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
})); }));
describe("ProjectLookSettingsPage re-export", () => { describe("ProjectLookSettingsPage re-export", () => {
@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
})); }));
describe("TagsPage re-export", () => { describe("TagsPage re-export", () => {
@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
})); }));
describe("ProjectTeams re-export", () => { describe("ProjectTeams re-export", () => {
@@ -41,6 +41,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
})); }));
const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId); const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId);
@@ -1,7 +1,9 @@
"use server"; "use server";
import { updateUser } from "@/lib/user/service"; import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; 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 { z } from "zod";
import { ZUserNotificationSettings } from "@formbricks/types/user"; import { ZUserNotificationSettings } from "@formbricks/types/user";
@@ -11,8 +13,25 @@ const ZUpdateNotificationSettingsAction = z.object({
export const updateNotificationSettingsAction = authenticatedActionClient export const updateNotificationSettingsAction = authenticatedActionClient
.schema(ZUpdateNotificationSettingsAction) .schema(ZUpdateNotificationSettingsAction)
.action(async ({ ctx, parsedInput }) => { .action(
await updateUser(ctx.user.id, { withAuditLogging(
notificationSettings: parsedInput.notificationSettings, "updated",
}); "user",
}); async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
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;
}
)
);
@@ -7,10 +7,12 @@ import {
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants"; import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { deleteFile } from "@/lib/storage/service"; import { deleteFile } from "@/lib/storage/service";
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils"; import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { updateUser } from "@/lib/user/service"; import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { rateLimit } from "@/lib/utils/rate-limit"; import { rateLimit } from "@/lib/utils/rate-limit";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationNewEmail } from "@/modules/email"; import { sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
@@ -27,93 +29,136 @@ const limiter = rateLimit({
allowedPerInterval: 3, // max 3 calls for email verification per hour allowedPerInterval: 3, // max 3 calls for email verification per hour
}); });
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
return {
...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }),
};
}
async function handleEmailUpdate({
ctx,
parsedInput,
payload,
}: {
ctx: any;
parsedInput: any;
payload: TUserUpdateInput;
}) {
const inputEmail = parsedInput.email?.trim().toLowerCase();
if (!inputEmail || ctx.user.email === inputEmail) return payload;
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
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 export const updateUserAction = authenticatedActionClient
.schema( .schema(
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({ ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
password: ZUserPassword.optional(), password: ZUserPassword.optional(),
}) })
) )
.action(async ({ parsedInput, ctx }) => { .action(
const inputEmail = parsedInput.email?.trim().toLowerCase(); withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
let payload: TUserUpdateInput = { // Only proceed with updateUser if we have actual changes to make
...(parsedInput.name && { name: parsedInput.name }), let newObject = oldObject;
...(parsedInput.locale && { locale: parsedInput.locale }), if (Object.keys(payload).length > 0) {
}; newObject = await updateUser(ctx.user.id, payload);
// Only process email update if a new email is provided and it's different from current email
if (inputEmail && ctx.user.email !== inputEmail) {
// Check rate limit
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
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");
}
// Check if the new email is unique, no user exists with the new email
const isEmailUnique = await getIsEmailUnique(inputEmail);
// If the new email is unique, proceed with the email update
if (isEmailUnique) {
if (EMAIL_VERIFICATION_DISABLED) {
payload.email = inputEmail;
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
} }
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
} }
} )
);
// Only proceed with updateUser if we have actual changes to make
if (Object.keys(payload).length > 0) {
await updateUser(ctx.user.id, payload);
}
return true;
});
const ZUpdateAvatarAction = z.object({ const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(), avatarUrl: z.string(),
}); });
export const updateAvatarAction = authenticatedActionClient export const updateAvatarAction = authenticatedActionClient.schema(ZUpdateAvatarAction).action(
.schema(ZUpdateAvatarAction) withAuditLogging(
.action(async ({ parsedInput, ctx }) => { "updated",
return await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl }); "user",
}); async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
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({ const ZRemoveAvatarAction = z.object({
environmentId: ZId, environmentId: ZId,
}); });
export const removeAvatarAction = authenticatedActionClient export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatarAction).action(
.schema(ZRemoveAvatarAction) withAuditLogging(
.action(async ({ parsedInput, ctx }) => { "updated",
const imageUrl = ctx.user.imageUrl; "user",
if (!imageUrl) { async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
throw new Error("Image not found"); const oldObject = await getUser(ctx.user.id);
} const imageUrl = ctx.user.imageUrl;
if (!imageUrl) {
throw new Error("Image not found");
}
const fileName = getFileNameWithIdFromUrl(imageUrl); const fileName = getFileNameWithIdFromUrl(imageUrl);
if (!fileName) { if (!fileName) {
throw new Error("Invalid filename"); throw new Error("Invalid filename");
} }
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName); const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
if (!deletionResult.success) { if (!deletionResult.success) {
throw new Error("Deletion failed"); 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;
} }
return await updateUser(ctx.user.id, { imageUrl: null }); )
}); );
@@ -3,6 +3,7 @@
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal"; import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages } from "@/lib/i18n/utils"; import { appLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -16,8 +17,6 @@ import { Input } from "@/modules/ui/components/input";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
import { signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@@ -39,7 +38,6 @@ export const EditProfileDetailsForm = ({
emailVerificationDisabled: boolean; emailVerificationDisabled: boolean;
}) => { }) => {
const { t } = useTranslate(); const { t } = useTranslate();
const router = useRouter();
const form = useForm<TEditProfileNameForm>({ const form = useForm<TEditProfileNameForm>({
defaultValues: { defaultValues: {
@@ -53,6 +51,7 @@ export const EditProfileDetailsForm = ({
const { isSubmitting, isDirty } = form.formState; const { isSubmitting, isDirty } = form.formState;
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const handleConfirmPassword = async (password: string) => { const handleConfirmPassword = async (password: string) => {
const values = form.getValues(); const values = form.getValues();
@@ -86,8 +85,12 @@ export const EditProfileDetailsForm = ({
toast.success(t("auth.verification-requested.new_email_verification_success")); toast.success(t("auth.verification-requested.new_email_verification_success"));
} else { } else {
toast.success(t("environments.settings.profile.email_change_initiated")); toast.success(t("environments.settings.profile.email_change_initiated"));
await signOut({ redirect: false }); await signOutWithAudit({
router.push(`/email-change-without-verification-success`); reason: "email_change",
redirectUrl: "/email-change-without-verification-success",
redirect: true,
callbackUrl: "/email-change-without-verification-success",
});
return; return;
} }
} else { } else {
@@ -4,16 +4,6 @@ import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getIsEmailUnique, verifyUserPassword } from "./user"; import { getIsEmailUnique, verifyUserPassword } from "./user";
// Mock dependencies
vi.mock("@/lib/user/cache", () => ({
userCache: {
tag: {
byId: vi.fn((id) => `user-${id}-tag`),
byEmail: vi.fn((email) => `user-email-${email}-tag`),
},
},
}));
vi.mock("@/modules/auth/lib/utils", () => ({ vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(), verifyPassword: vi.fn(),
})); }));
@@ -26,9 +16,6 @@ vi.mock("@formbricks/database", () => ({
}, },
})); }));
// reactCache (from "react") and unstable_cache (from "next/cache") are mocked in vitestSetup.ts
// to be pass-through, so the inner logic of cached functions is tested.
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique); const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported); const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
@@ -1,5 +1,3 @@
import { cache } from "@/lib/cache";
import { userCache } from "@/lib/user/cache";
import { verifyPassword } from "@/modules/auth/lib/utils"; import { verifyPassword } from "@/modules/auth/lib/utils";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
@@ -7,28 +5,21 @@ import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getUserById = reactCache( export const getUserById = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
cache( const user = await prisma.user.findUnique({
async () => { where: {
const user = await prisma.user.findUnique({ id: userId,
where: {
id: userId,
},
select: {
password: true,
identityProvider: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}, },
[`getUserById-${userId}`], select: {
{ password: true,
tags: [userCache.tag.byId(userId)], identityProvider: true,
} },
)() });
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}
); );
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => { export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
@@ -47,24 +38,15 @@ export const verifyUserPassword = async (userId: string, password: string): Prom
return true; return true;
}; };
export const getIsEmailUnique = reactCache( export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
async (email: string): Promise<boolean> => const user = await prisma.user.findUnique({
cache( where: {
async () => { email: email.toLowerCase(),
const user = await prisma.user.findUnique({ },
where: { select: {
email: email.toLowerCase(), id: true,
}, },
select: { });
id: true,
},
});
return !user; return !user;
}, });
[`getIsEmailUnique-${email}`],
{
tags: [userCache.tag.byEmail(email)],
}
)()
);
@@ -1,8 +1,10 @@
"use server"; "use server";
import { deleteOrganization, updateOrganization } from "@/lib/organization/service"; import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; 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 { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
@@ -16,43 +18,65 @@ const ZUpdateOrganizationNameAction = z.object({
export const updateOrganizationNameAction = authenticatedActionClient export const updateOrganizationNameAction = authenticatedActionClient
.schema(ZUpdateOrganizationNameAction) .schema(ZUpdateOrganizationNameAction)
.action(async ({ parsedInput, ctx }) => { .action(
await checkAuthorizationUpdated({ withAuditLogging(
userId: ctx.user.id, "updated",
organizationId: parsedInput.organizationId, "organization",
access: [ async ({
{ ctx,
type: "organization", parsedInput,
schema: ZOrganizationUpdateInput.pick({ name: true }), }: {
data: parsedInput.data, ctx: AuthenticatedActionClientCtx;
roles: ["owner"], parsedInput: Record<string, any>;
}, }) => {
], await checkAuthorizationUpdated({
}); userId: ctx.user.id,
organizationId: parsedInput.organizationId,
return await updateOrganization(parsedInput.organizationId, parsedInput.data); 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({ const ZDeleteOrganizationAction = z.object({
organizationId: ZId, organizationId: ZId,
}); });
export const deleteOrganizationAction = authenticatedActionClient export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
.schema(ZDeleteOrganizationAction) withAuditLogging(
.action(async ({ parsedInput, ctx }) => { "deleted",
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); "organization",
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled"); async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId: parsedInput.organizationId, organizationId: parsedInput.organizationId,
access: [ access: [
{ {
type: "organization", type: "organization",
roles: ["owner"], roles: ["owner"],
}, },
], ],
}); });
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
return await deleteOrganization(parsedInput.organizationId); const oldObject = await getOrganization(parsedInput.organizationId);
}); ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
}
)
);
@@ -30,6 +30,8 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user", SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password", SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
})); }));
describe("TeamsPage re-export", () => { describe("TeamsPage re-export", () => {
@@ -2,7 +2,7 @@
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service"; import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; 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 { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
@@ -45,6 +45,8 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user", SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password", SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
})); }));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"); vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
@@ -3,8 +3,10 @@
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate"; import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; 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 { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization"; import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email"; import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid"; import { customAlphabet } from "nanoid";
@@ -63,37 +65,55 @@ const ZGenerateResultShareUrlAction = z.object({
export const generateResultShareUrlAction = authenticatedActionClient export const generateResultShareUrlAction = authenticatedActionClient
.schema(ZGenerateResultShareUrlAction) .schema(ZGenerateResultShareUrlAction)
.action(async ({ ctx, parsedInput }) => { .action(
await checkAuthorizationUpdated({ withAuditLogging(
userId: ctx.user.id, "updated",
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), "survey",
access: [ async ({
{ ctx,
type: "organization", parsedInput,
roles: ["owner", "manager"], }: {
}, ctx: AuthenticatedActionClientCtx;
{ parsedInput: Record<string, any>;
type: "projectTeam", }) => {
minPermission: "readWrite", const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId), await checkAuthorizationUpdated({
}, userId: ctx.user.id,
], organizationId: organizationId,
}); access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurvey(parsedInput.surveyId); const survey = await getSurvey(parsedInput.surveyId);
if (!survey) { if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId); throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
} }
const resultShareKey = customAlphabet( const resultShareKey = customAlphabet(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
20 20
)(); )();
await updateSurvey({ ...survey, resultShareKey }); ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
return resultShareKey; const newSurvey = await updateSurvey({ ...survey, resultShareKey });
}); ctx.auditLoggingCtx.newObject = newSurvey;
return resultShareKey;
}
)
);
const ZGetResultShareUrlAction = z.object({ const ZGetResultShareUrlAction = z.object({
surveyId: ZId, surveyId: ZId,
@@ -132,30 +152,50 @@ const ZDeleteResultShareUrlAction = z.object({
export const deleteResultShareUrlAction = authenticatedActionClient export const deleteResultShareUrlAction = authenticatedActionClient
.schema(ZDeleteResultShareUrlAction) .schema(ZDeleteResultShareUrlAction)
.action(async ({ ctx, parsedInput }) => { .action(
await checkAuthorizationUpdated({ withAuditLogging(
userId: ctx.user.id, "updated",
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), "survey",
access: [ async ({
{ ctx,
type: "organization", parsedInput,
roles: ["owner", "manager"], }: {
}, ctx: AuthenticatedActionClientCtx;
{ parsedInput: Record<string, any>;
type: "projectTeam", }) => {
minPermission: "readWrite", const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId), await checkAuthorizationUpdated({
}, userId: ctx.user.id,
], organizationId: organizationId,
}); access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurvey(parsedInput.surveyId); const survey = await getSurvey(parsedInput.surveyId);
if (!survey) { if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId); throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
} }
return await updateSurvey({ ...survey, resultShareKey: null }); ctx.auditLoggingCtx.organizationId = organizationId;
}); ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
const newSurvey = await updateSurvey({ ...survey, resultShareKey: null });
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
}
)
);
const ZGetEmailHtmlAction = z.object({ const ZGetEmailHtmlAction = z.object({
surveyId: ZId, surveyId: ZId,
@@ -41,6 +41,36 @@ const mockSurveyWeb = {
styling: null, styling: null,
} as unknown as TSurvey; } as unknown as TSurvey;
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,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
IS_FORMBRICKS_CLOUD: false,
}));
const mockSurveyLink = { const mockSurveyLink = {
...mockSurveyWeb, ...mockSurveyWeb,
id: "survey2", id: "survey2",
@@ -174,20 +204,32 @@ describe("ShareEmbedSurvey", () => {
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument(); expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
}); });
test("calls setOpen(false) when handleInitialPageButton is triggered from EmbedView", async () => { test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="embed" />); render(<ShareEmbedSurvey {...defaultProps} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled(); expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
const embedViewButton = screen.getByText("EmbedViewMockContent"); const embedViewButton = screen.getByText("EmbedViewMockContent");
await userEvent.click(embedViewButton); await userEvent.click(embedViewButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
}); });
test("calls setOpen(false) when handleInitialPageButton is triggered from PanelInfoView", async () => { test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="panel" />); render(<ShareEmbedSurvey {...defaultProps} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled(); expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent"); const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
await userEvent.click(panelInfoViewButton); await userEvent.click(panelInfoViewButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("PanelInfoViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
}); });
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => { test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {
@@ -1,6 +1,7 @@
"use client"; "use client";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink"; import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/modules/ui/components/dialog"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
@@ -62,6 +63,20 @@ export const ShareEmbedSurvey = ({
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start"); const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
const [surveyUrl, setSurveyUrl] = useState(""); const [surveyUrl, setSurveyUrl] = useState("");
useEffect(() => {
const fetchSurveyUrl = async () => {
try {
const url = await getSurveyUrl(survey, surveyDomain, "default");
setSurveyUrl(url);
} catch (error) {
console.error("Failed to fetch survey URL:", error);
// Fallback to a default URL if fetching fails
setSurveyUrl(`${surveyDomain}/s/${survey.id}`);
}
};
fetchSurveyUrl();
}, [survey, surveyDomain]);
useEffect(() => { useEffect(() => {
if (survey.type !== "link") { if (survey.type !== "link") {
setActiveId(tabs[3].id); setActiveId(tabs[3].id);
@@ -86,7 +101,7 @@ export const ShareEmbedSurvey = ({
}; };
const handleInitialPageButton = () => { const handleInitialPageButton = () => {
setOpen(false); setShowView("start");
}; };
return ( return (
@@ -109,7 +109,7 @@ export const SummaryPage = ({
}; };
fetchSummary(); fetchSummary();
}, [selectedFilter, dateRange, survey.id, isSharingPage, sharingKey, surveyId, initialSurveySummary]); }, [selectedFilter, dateRange, survey, isSharingPage, sharingKey, surveyId, initialSurveySummary]);
const surveyMemoized = useMemo(() => { const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default"); return replaceHeadlineRecall(survey, "default");
@@ -7,6 +7,20 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { SurveyAnalysisCTA } from "./SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "./SurveyAnalysisCTA";
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/utils", () => ({
withAuditLogging: vi.fn((...args: any[]) => {
// Check if the last argument is a function and return it directly
if (typeof args[args.length - 1] === "function") {
return args[args.length - 1];
}
// Otherwise, return a new function that takes a function as an argument and returns it
return (fn: any) => fn;
}),
}));
// Mock constants // Mock constants
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false, IS_FORMBRICKS_CLOUD: false,
@@ -30,7 +44,9 @@ vi.mock("@/lib/constants", () => ({
SMTP_HOST: "mock-smtp-host", SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port", SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true, IS_POSTHOG_CONFIGURED: true,
AUDIT_LOG_ENABLED: true,
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "mock-url",
})); }));
// Create a spy for refreshSingleUseId so we can override it in tests // Create a spy for refreshSingleUseId so we can override it in tests
@@ -1,4 +1,3 @@
import { cache } from "@/lib/cache";
import { getDisplayCountBySurveyId } from "@/lib/display/service"; import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
@@ -26,23 +25,6 @@ import {
// Ensure this path is correct // Ensure this path is correct
import { convertFloatTo2Decimal } from "./utils"; import { convertFloatTo2Decimal } from "./utils";
// Mock dependencies
vi.mock("@/lib/cache", async () => {
const actual = await vi.importActual("@/lib/cache");
return {
...(actual as any),
cache: vi.fn((fn) => fn()), // Mock cache function to just execute the passed function
};
});
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn().mockImplementation((fn) => fn),
};
});
vi.mock("@/lib/display/service", () => ({ vi.mock("@/lib/display/service", () => ({
getDisplayCountBySurveyId: vi.fn(), getDisplayCountBySurveyId: vi.fn(),
})); }));
@@ -162,10 +144,6 @@ describe("getSurveySummaryMeta", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) => vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0 num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
); );
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
}); });
test("calculates meta correctly", () => { test("calculates meta correctly", () => {
@@ -226,9 +204,6 @@ describe("getSurveySummaryDropOff", () => {
requiredQuestionIds: [], requiredQuestionIds: [],
calculations: {}, calculations: {},
}); });
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
}); });
test("calculates dropOff correctly with welcome card disabled", () => { test("calculates dropOff correctly with welcome card disabled", () => {
@@ -367,9 +342,7 @@ describe("getQuestionSummary", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) => vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0 num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
); );
vi.mocked(cache).mockImplementation((fn) => async () => { // React cache is already mocked globally - no need to mock it again
return fn();
});
}); });
test("summarizes OpenText questions", async () => { test("summarizes OpenText questions", async () => {
@@ -746,9 +719,7 @@ describe("getSurveySummary", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) => vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0 num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
); );
vi.mocked(cache).mockImplementation((fn) => async () => { // React cache is already mocked globally - no need to mock it again
return fn();
});
}); });
test("returns survey summary successfully", async () => { test("returns survey summary successfully", async () => {
@@ -795,9 +766,7 @@ describe("getResponsesForSummary", () => {
vi.mocked(prisma.response.findMany).mockResolvedValue( vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
); );
vi.mocked(cache).mockImplementation((fn) => async () => { // React cache is already mocked globally - no need to mock it again
return fn();
});
}); });
test("fetches and transforms responses", async () => { test("fetches and transforms responses", async () => {
@@ -840,6 +809,16 @@ describe("getResponsesForSummary", () => {
language: "en", language: "en",
ttc: {}, ttc: {},
finished: true, 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(getSurvey).mockResolvedValue(mockSurvey);
@@ -873,6 +852,16 @@ describe("getResponsesForSummary", () => {
language: "en", language: "en",
ttc: {}, ttc: {},
finished: true, 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(getSurvey).mockResolvedValue(mockSurvey);
@@ -901,6 +890,16 @@ describe("getResponsesForSummary", () => {
language: "en", language: "en",
ttc: {}, ttc: {},
finished: true, 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(getSurvey).mockResolvedValue(mockSurvey);
@@ -1,12 +1,8 @@
import "server-only"; import "server-only";
import { cache } from "@/lib/cache";
import { RESPONSES_PER_PAGE } from "@/lib/constants"; import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { displayCache } from "@/lib/display/cache";
import { getDisplayCountBySurveyId } from "@/lib/display/service"; import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { responseCache } from "@/lib/response/cache";
import { buildWhereClause } from "@/lib/response/utils"; import { buildWhereClause } from "@/lib/response/utils";
import { surveyCache } from "@/lib/survey/cache";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils"; import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
@@ -905,68 +901,57 @@ export const getQuestionSummary = async (
}; };
export const getSurveySummary = reactCache( export const getSurveySummary = reactCache(
async (surveyId: string, filterCriteria?: TResponseFilterCriteria): Promise<TSurveySummary> => async (surveyId: string, filterCriteria?: TResponseFilterCriteria): Promise<TSurveySummary> => {
cache( validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]);
async () => {
validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]);
try { try {
const survey = await getSurvey(surveyId); const survey = await getSurvey(surveyId);
if (!survey) { if (!survey) {
throw new ResourceNotFoundError("Survey", surveyId); throw new ResourceNotFoundError("Survey", surveyId);
}
const batchSize = 5000;
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
// Use cursor-based pagination instead of count + offset to avoid expensive queries
const responses: TSurveySummaryResponse[] = [];
let cursor: string | undefined = undefined;
let hasMore = true;
while (hasMore) {
const batch = await getResponsesForSummary(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 responseIds = hasFilter ? responses.map((response) => response.id) : [];
const displayCount = await getDisplayCountBySurveyId(surveyId, {
createdAt: filterCriteria?.createdAt,
...(hasFilter && { responseIds }),
});
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
const [meta, questionWiseSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount),
getQuestionSummary(survey, responses, dropOff),
]);
return { meta, dropOff, summary: questionWiseSummary };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSurveySummary-${surveyId}-${JSON.stringify(filterCriteria)}`],
{
tags: [
surveyCache.tag.byId(surveyId),
responseCache.tag.bySurveyId(surveyId),
displayCache.tag.bySurveyId(surveyId),
],
} }
)()
const batchSize = 5000;
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
// Use cursor-based pagination instead of count + offset to avoid expensive queries
const responses: TSurveySummaryResponse[] = [];
let cursor: string | undefined = undefined;
let hasMore = true;
while (hasMore) {
const batch = await getResponsesForSummary(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 responseIds = hasFilter ? responses.map((response) => response.id) : [];
const displayCount = await getDisplayCountBySurveyId(surveyId, {
createdAt: filterCriteria?.createdAt,
...(hasFilter && { responseIds }),
});
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
const [meta, questionWiseSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount),
getQuestionSummary(survey, responses, dropOff),
]);
return { meta, dropOff, summary: questionWiseSummary };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
); );
export const getResponsesForSummary = reactCache( export const getResponsesForSummary = reactCache(
@@ -976,94 +961,85 @@ export const getResponsesForSummary = reactCache(
offset: number, offset: number,
filterCriteria?: TResponseFilterCriteria, filterCriteria?: TResponseFilterCriteria,
cursor?: string cursor?: string
): Promise<TSurveySummaryResponse[]> => ): Promise<TSurveySummaryResponse[]> => {
cache( validateInputs(
async () => { [surveyId, ZId],
validateInputs( [limit, ZOptionalNumber],
[surveyId, ZId], [offset, ZOptionalNumber],
[limit, ZOptionalNumber], [filterCriteria, ZResponseFilterCriteria.optional()],
[offset, ZOptionalNumber], [cursor, z.string().cuid2().optional()]
[filterCriteria, ZResponseFilterCriteria.optional()], );
[cursor, z.string().cuid2().optional()]
);
const queryLimit = limit ?? RESPONSES_PER_PAGE; const queryLimit = limit ?? RESPONSES_PER_PAGE;
const survey = await getSurvey(surveyId); const survey = await getSurvey(surveyId);
if (!survey) return []; if (!survey) return [];
try { try {
const whereClause: Prisma.ResponseWhereInput = { const whereClause: Prisma.ResponseWhereInput = {
surveyId, surveyId,
...buildWhereClause(survey, filterCriteria), ...buildWhereClause(survey, filterCriteria),
}; };
// Add cursor condition for cursor-based pagination // Add cursor condition for cursor-based pagination
if (cursor) { if (cursor) {
whereClause.id = { whereClause.id = {
lt: cursor, // Get responses with ID less than cursor (for desc order) lt: cursor, // Get responses with ID less than cursor (for desc order)
}; };
} }
const responses = await prisma.response.findMany({ const responses = await prisma.response.findMany({
where: whereClause, where: whereClause,
select: {
id: true,
data: true,
updatedAt: true,
contact: {
select: { select: {
id: true, id: true,
data: true, attributes: {
updatedAt: true, select: { attributeKey: true, value: true },
contact: {
select: {
id: true,
attributes: {
select: { attributeKey: true, value: true },
},
},
}, },
contactAttributes: true,
language: true,
ttc: true,
finished: true,
}, },
orderBy: [ },
{ contactAttributes: true,
createdAt: "desc", language: true,
}, ttc: true,
{ finished: true,
id: "desc", // Secondary sort by ID for consistent pagination },
}, orderBy: [
], {
take: queryLimit, createdAt: "desc",
skip: offset, },
}); {
id: "desc", // Secondary sort by ID for consistent pagination
},
],
take: queryLimit,
skip: offset,
});
const transformedResponses: TSurveySummaryResponse[] = await Promise.all( const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
responses.map((responsePrisma) => { responses.map((responsePrisma) => {
return { return {
...responsePrisma, ...responsePrisma,
contact: responsePrisma.contact contact: responsePrisma.contact
? { ? {
id: responsePrisma.contact.id as string, id: responsePrisma.contact.id as string,
userId: responsePrisma.contact.attributes.find( userId: responsePrisma.contact.attributes.find(
(attribute) => attribute.attributeKey.key === "userId" (attribute) => attribute.attributeKey.key === "userId"
)?.value as string, )?.value as string,
} }
: null, : null,
}; };
}) })
); );
return transformedResponses; return transformedResponses;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
}
throw error;
}
},
[
`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}-${cursor || ""}`,
],
{
tags: [responseCache.tag.bySurveyId(surveyId)],
} }
)()
throw error;
}
}
); );
@@ -68,7 +68,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
initialSurveySummary={initialSurveySummary} initialSurveySummary={initialSurveySummary}
/> />
<SettingsId title={t("common.survey_id")} id={surveyId}></SettingsId> <SettingsId title={t("common.survey_id")} id={surveyId} />
</PageContentWrapper> </PageContentWrapper>
); );
}; };
@@ -5,8 +5,10 @@ import { getResponseDownloadUrl, getResponseFilteringValues } from "@/lib/respon
import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; 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 { 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 { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils"; import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission"; import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
@@ -14,7 +16,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses"; 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({ const ZGetResponsesDownloadUrlAction = z.object({
surveyId: ZId, surveyId: ZId,
@@ -102,39 +104,54 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
} }
}; };
export const updateSurveyAction = authenticatedActionClient export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
.schema(ZSurvey) withAuditLogging(
.action(async ({ ctx, parsedInput }) => { "updated",
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id); "survey",
await checkAuthorizationUpdated({ async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurvey }) => {
userId: ctx.user.id, const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
organizationId, await checkAuthorizationUpdated({
access: [ userId: ctx.user?.id ?? "",
{ organizationId,
type: "organization", access: [
roles: ["owner", "manager"], {
}, type: "organization",
{ roles: ["owner", "manager"],
type: "projectTeam", },
projectId: await getProjectIdFromSurveyId(parsedInput.id), {
minPermission: "readWrite", type: "projectTeam",
}, projectId: await getProjectIdFromSurveyId(parsedInput.id),
], minPermission: "readWrite",
}); },
],
});
const { followUps } = parsedInput; const { followUps } = parsedInput;
if (parsedInput.recaptcha?.enabled) { const oldSurvey = await getSurvey(parsedInput.id);
await checkSpamProtectionPermission(organizationId);
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 (followUps?.length) { );
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
return await updateSurvey(parsedInput);
});
@@ -11,6 +11,7 @@ import {
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator"; import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
@@ -28,6 +29,7 @@ export const SurveyStatusDropdown = ({
survey, survey,
}: SurveyStatusDropdownProps) => { }: SurveyStatusDropdownProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
const router = useRouter();
const isCloseOnDateEnabled = survey.closeOnDate !== null; const isCloseOnDateEnabled = survey.closeOnDate !== null;
const closeOnDate = survey.closeOnDate ? new Date(survey.closeOnDate) : null; const closeOnDate = survey.closeOnDate ? new Date(survey.closeOnDate) : null;
const isStatusChangeDisabled = const isStatusChangeDisabled =
@@ -47,6 +49,8 @@ export const SurveyStatusDropdown = ({
? t("common.survey_completed") ? t("common.survey_completed")
: "" : ""
); );
router.refresh();
} else { } else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse); const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
toast.error(errorMessage); toast.error(errorMessage);
+2
View File
@@ -39,6 +39,8 @@ vi.mock("@/lib/constants", () => ({
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id", FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true, IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
})); }));
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({ vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
+40 -22
View File
@@ -1,14 +1,14 @@
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines"; import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { cache } from "@/lib/cache";
import { webhookCache } from "@/lib/cache/webhook";
import { CRON_SECRET } from "@/lib/constants"; import { CRON_SECRET } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service"; import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time"; 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 { sendResponseFinishedEmail } from "@/modules/email";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups"; import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up"; import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
@@ -51,22 +51,17 @@ export const POST = async (request: Request) => {
} }
// Fetch webhooks // Fetch webhooks
const getWebhooksForPipeline = cache( const getWebhooksForPipeline = async (environmentId: string, event: PipelineTriggers, surveyId: string) => {
async (environmentId: string, event: PipelineTriggers, surveyId: string) => { const webhooks = await prisma.webhook.findMany({
const webhooks = await prisma.webhook.findMany({ where: {
where: { environmentId,
environmentId, triggers: { has: event },
triggers: { has: event }, OR: [{ surveyIds: { has: surveyId } }, { surveyIds: { isEmpty: true } }],
OR: [{ surveyIds: { has: surveyId } }, { surveyIds: { isEmpty: true } }], },
}, });
}); return webhooks;
return webhooks; };
},
[`getWebhooksForPipeline-${environmentId}-${event}-${surveyId}`],
{
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
}
);
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId); const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
// Prepare webhook and email promises // Prepare webhook and email promises
@@ -186,10 +181,33 @@ export const POST = async (request: Request) => {
// Update survey status if necessary // Update survey status if necessary
if (survey.autoComplete && responseCount >= survey.autoComplete) { if (survey.autoComplete && responseCount >= survey.autoComplete) {
await updateSurvey({ let logStatus: TAuditStatus = "success";
...survey,
status: "completed", 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 // Await webhook and email promises with allSettled to prevent early rejection
+134 -2
View File
@@ -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 NextAuth from "next-auth";
import { logger } from "@formbricks/logger";
export const fetchCache = "force-no-store"; 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 }; export { handler as GET, handler as POST };
@@ -1,6 +1,5 @@
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { CRON_SECRET } from "@/lib/constants"; import { CRON_SECRET } from "@/lib/constants";
import { surveyCache } from "@/lib/survey/cache";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
@@ -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({ return responses.successResponse({
message: `Updated ${surveysToClose.length} surveys to completed and ${scheduledSurveys.length} surveys to inProgress.`, message: `Updated ${surveysToClose.length} surveys to completed and ${scheduledSurveys.length} surveys to inProgress.`,
}); });
@@ -6,7 +6,6 @@ import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getActionClasses } from "@/lib/actionClass/service"; import { getActionClasses } from "@/lib/actionClass/service";
import { contactCache } from "@/lib/cache/contact";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnvironment, updateEnvironment } from "@/lib/environment/service"; import { getEnvironment, updateEnvironment } from "@/lib/environment/service";
import { import {
@@ -133,14 +132,6 @@ export const GET = async (
attributes: { select: { attributeKey: { select: { key: true } }, value: 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) => { const contactAttributes = contact.attributes.reduce((acc, attribute) => {
@@ -1,6 +1,5 @@
import { cache } from "@/lib/cache";
import { TContact } from "@/modules/ee/contacts/types/contact"; import { TContact } from "@/modules/ee/contacts/types/contact";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { getContactByUserId } from "./contact"; import { getContactByUserId } from "./contact";
@@ -13,15 +12,6 @@ vi.mock("@formbricks/database", () => ({
}, },
})); }));
// Mock cache\
vi.mock("@/lib/cache", async () => {
const actual = await vi.importActual("@/lib/cache");
return {
...(actual as any),
cache: vi.fn((fn) => fn()), // Mock cache function to just execute the passed function
};
});
const environmentId = "test-environment-id"; const environmentId = "test-environment-id";
const userId = "test-user-id"; const userId = "test-user-id";
const contactId = "test-contact-id"; const contactId = "test-contact-id";
@@ -37,12 +27,6 @@ const contactMock: Partial<TContact> & {
}; };
describe("getContactByUserId", () => { describe("getContactByUserId", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => { afterEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
}); });
@@ -1,11 +1,9 @@
import "server-only"; import "server-only";
import { cache } from "@/lib/cache";
import { contactCache } from "@/lib/cache/contact";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
export const getContactByUserId = reactCache( export const getContactByUserId = reactCache(
( async (
environmentId: string, environmentId: string,
userId: string userId: string
): Promise<{ ): Promise<{
@@ -16,36 +14,29 @@ export const getContactByUserId = reactCache(
}; };
}[]; }[];
id: string; id: string;
} | null> => } | null> => {
cache( const contact = await prisma.contact.findFirst({
async () => { where: {
const contact = await prisma.contact.findFirst({ attributes: {
where: { some: {
attributes: { attributeKey: {
some: { key: "userId",
attributeKey: { environmentId,
key: "userId",
environmentId,
},
value: userId,
},
}, },
value: userId,
}, },
select: { },
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
if (!contact) {
return null;
}
return contact;
}, },
[`getContactByUserId-sync-api-${environmentId}-${userId}`], select: {
{ id: true,
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)], attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
} },
)() });
if (!contact) {
return null;
}
return contact;
}
); );
@@ -1,4 +1,3 @@
import { cache } from "@/lib/cache";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurveys } from "@/lib/survey/service"; import { getSurveys } from "@/lib/survey/service";
import { anySurveyHasFilters } from "@/lib/survey/utils"; import { anySurveyHasFilters } from "@/lib/survey/utils";
@@ -14,15 +13,6 @@ import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getSyncSurveys } from "./survey"; import { getSyncSurveys } from "./survey";
// Mock dependencies
vi.mock("@/lib/cache", async () => {
const actual = await vi.importActual("@/lib/cache");
return {
...(actual as any),
cache: vi.fn((fn) => fn()), // Mock cache function to just execute the passed function
};
});
vi.mock("@/lib/project/service", () => ({ vi.mock("@/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(), getProjectByEnvironmentId: vi.fn(),
})); }));
@@ -120,9 +110,6 @@ const baseSurvey: TSurvey = {
describe("getSyncSurveys", () => { describe("getSyncSurveys", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject); vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
vi.mocked(prisma.display.findMany).mockResolvedValue([]); vi.mocked(prisma.display.findMany).mockResolvedValue([]);
vi.mocked(prisma.response.findMany).mockResolvedValue([]); vi.mocked(prisma.response.findMany).mockResolvedValue([]);
@@ -1,11 +1,5 @@
import "server-only"; import "server-only";
import { cache } from "@/lib/cache";
import { contactCache } from "@/lib/cache/contact";
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
import { displayCache } from "@/lib/display/cache";
import { projectCache } from "@/lib/project/cache";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { surveyCache } from "@/lib/survey/cache";
import { getSurveys } from "@/lib/survey/service"; import { getSurveys } from "@/lib/survey/service";
import { anySurveyHasFilters } from "@/lib/survey/utils"; import { anySurveyHasFilters } from "@/lib/survey/utils";
import { diffInDays } from "@/lib/utils/datetime"; import { diffInDays } from "@/lib/utils/datetime";
@@ -20,154 +14,135 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
export const getSyncSurveys = reactCache( export const getSyncSurveys = reactCache(
( async (
environmentId: string, environmentId: string,
contactId: string, contactId: string,
contactAttributes: Record<string, string | number>, contactAttributes: Record<string, string | number>,
deviceType: "phone" | "desktop" = "desktop" deviceType: "phone" | "desktop" = "desktop"
): Promise<TSurvey[]> => ): Promise<TSurvey[]> => {
cache( validateInputs([environmentId, ZId]);
async () => { try {
validateInputs([environmentId, ZId]); const product = await getProjectByEnvironmentId(environmentId);
try {
const product = await getProjectByEnvironmentId(environmentId);
if (!product) { if (!product) {
throw new Error("Product not found"); throw new Error("Product not found");
}
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "app");
// if no surveys are left, return an empty array
if (surveys.length === 0) {
return [];
}
const displays = await prisma.display.findMany({
where: {
contactId,
},
});
const responses = await prisma.response.findMany({
where: {
contactId,
},
});
// filter surveys that meet the displayOption criteria
surveys = surveys.filter((survey) => {
switch (survey.displayOption) {
case "respondMultiple":
return true;
case "displayOnce":
return displays.filter((display) => display.surveyId === survey.id).length === 0;
case "displayMultiple":
if (!responses) return true;
else {
return responses.filter((response) => response.surveyId === survey.id).length === 0;
}
case "displaySome":
if (survey.displayLimit === null) {
return true;
}
if (
responses &&
responses.filter((response) => response.surveyId === survey.id).length !== 0
) {
return false;
}
return (
displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit
);
default:
throw Error("Invalid displayOption");
}
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = surveys.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
} else if (product.recontactDays !== null) {
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays;
} else {
return true;
}
});
// if no surveys are left, return an empty array
if (surveys.length === 0) {
return [];
}
// if no surveys have segment filters, return the surveys
if (!anySurveyHasFilters(surveys)) {
return surveys;
}
// the surveys now have segment filters, so we need to evaluate them
const surveyPromises = surveys.map(async (survey) => {
const { segment } = survey;
// if the survey has no segment, or the segment has no filters, we return the survey
if (!segment || !segment.filters?.length) {
return survey;
}
// Evaluate the segment filters
const result = await evaluateSegment(
{
attributes: contactAttributes ?? {},
deviceType,
environmentId,
contactId,
userId: String(contactAttributes.userId),
},
segment.filters
);
return result ? survey : null;
});
const resolvedSurveys = await Promise.all(surveyPromises);
surveys = resolvedSurveys.filter((survey) => !!survey) as TSurvey[];
if (!surveys) {
throw new ResourceNotFoundError("Survey", environmentId);
}
return surveys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
},
[`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),
],
} }
)()
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "app");
// if no surveys are left, return an empty array
if (surveys.length === 0) {
return [];
}
const displays = await prisma.display.findMany({
where: {
contactId,
},
});
const responses = await prisma.response.findMany({
where: {
contactId,
},
});
// filter surveys that meet the displayOption criteria
surveys = surveys.filter((survey) => {
switch (survey.displayOption) {
case "respondMultiple":
return true;
case "displayOnce":
return displays.filter((display) => display.surveyId === survey.id).length === 0;
case "displayMultiple":
if (!responses) return true;
else {
return responses.filter((response) => response.surveyId === survey.id).length === 0;
}
case "displaySome":
if (survey.displayLimit === null) {
return true;
}
if (responses && responses.filter((response) => response.surveyId === survey.id).length !== 0) {
return false;
}
return displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit;
default:
throw Error("Invalid displayOption");
}
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = surveys.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
} else if (product.recontactDays !== null) {
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays;
} else {
return true;
}
});
// if no surveys are left, return an empty array
if (surveys.length === 0) {
return [];
}
// if no surveys have segment filters, return the surveys
if (!anySurveyHasFilters(surveys)) {
return surveys;
}
// the surveys now have segment filters, so we need to evaluate them
const surveyPromises = surveys.map(async (survey) => {
const { segment } = survey;
// if the survey has no segment, or the segment has no filters, we return the survey
if (!segment || !segment.filters?.length) {
return survey;
}
// Evaluate the segment filters
const result = await evaluateSegment(
{
attributes: contactAttributes ?? {},
deviceType,
environmentId,
contactId,
userId: String(contactAttributes.userId),
},
segment.filters
);
return result ? survey : null;
});
const resolvedSurveys = await Promise.all(surveyPromises);
surveys = resolvedSurveys.filter((survey) => !!survey) as TSurvey[];
if (!surveys) {
throw new ResourceNotFoundError("Survey", environmentId);
}
return surveys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
}
); );
@@ -1,41 +1,32 @@
import { cache } from "@/lib/cache";
import { contactCache } from "@/lib/cache/contact";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
export const getContactByUserId = reactCache( export const getContactByUserId = reactCache(
( async (
environmentId: string, environmentId: string,
userId: string userId: string
): Promise<{ ): Promise<{
id: string; id: string;
} | null> => } | null> => {
cache( const contact = await prisma.contact.findFirst({
async () => { where: {
const contact = await prisma.contact.findFirst({ attributes: {
where: { some: {
attributes: { attributeKey: {
some: { key: "userId",
attributeKey: { environmentId,
key: "userId",
environmentId,
},
value: userId,
},
}, },
value: userId,
}, },
select: { id: true }, },
});
if (!contact) {
return null;
}
return contact;
}, },
[`getContactByUserIdForDisplaysApi-${environmentId}-${userId}`], select: { id: true },
{ });
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
} if (!contact) {
)() return null;
}
return contact;
}
); );
@@ -1,4 +1,3 @@
import { displayCache } from "@/lib/display/cache";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
@@ -51,14 +50,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
select: { id: true, contactId: true, surveyId: true }, select: { id: true, contactId: true, surveyId: true },
}); });
displayCache.revalidate({
id: display.id,
contactId: display.contactId,
surveyId: display.surveyId,
userId,
environmentId,
});
return display; return display;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -14,7 +14,13 @@ interface Context {
} }
export const OPTIONS = async (): Promise<Response> => { export const OPTIONS = async (): Promise<Response> => {
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<Response> => { export const POST = async (request: Request, context: Context): Promise<Response> => {
@@ -1,86 +0,0 @@
import { cache } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { TJsEnvironmentStateActionClass } from "@formbricks/types/js";
import { getActionClassesForEnvironmentState } from "./actionClass";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
actionClass: {
findMany: vi.fn(),
},
},
}));
const environmentId = "test-environment-id";
const mockActionClasses: TJsEnvironmentStateActionClass[] = [
{
id: "action1",
type: "code",
name: "Code Action",
key: "code-action",
noCodeConfig: null,
},
{
id: "action2",
type: "noCode",
name: "No Code Action",
key: null,
noCodeConfig: { type: "click" } as TActionClassNoCodeConfig,
},
];
describe("getActionClassesForEnvironmentState", () => {
test("should return action classes successfully", async () => {
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
const result = await getActionClassesForEnvironmentState(environmentId);
expect(result).toEqual(mockActionClasses);
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); // ZId is an object
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
where: { environmentId },
select: {
id: true,
type: true,
name: true,
key: true,
noCodeConfig: true,
},
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getActionClassesForEnvironmentState-${environmentId}`],
{ tags: [`environments-${environmentId}-actionClasses`] }
);
});
test("should throw DatabaseError on prisma error", async () => {
const mockError = new Error("Prisma error");
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(mockError);
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
await expect(getActionClassesForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError);
await expect(getActionClassesForEnvironmentState(environmentId)).rejects.toThrow(
`Database error when fetching actions for environment ${environmentId}`
);
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
expect(prisma.actionClass.findMany).toHaveBeenCalled();
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getActionClassesForEnvironmentState-${environmentId}`],
{ tags: [`environments-${environmentId}-actionClasses`] }
);
});
});
@@ -1,38 +0,0 @@
import { actionClassCache } from "@/lib/actionClass/cache";
import { cache } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
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<TJsEnvironmentStateActionClass[]> =>
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)],
}
)()
);
@@ -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<EnvironmentStateData> => {
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<TJsEnvironmentStateSurvey>(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;
}
};
@@ -1,34 +1,25 @@
import { cache } from "@/lib/cache"; import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { getEnvironment } from "@/lib/environment/service";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { import {
capturePosthogEnvironmentEvent, capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly, sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer"; } from "@/lib/posthogServer";
import { withCache } from "@/modules/cache/lib/withCache";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TActionClass } from "@formbricks/types/action-classes"; import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsEnvironmentState } from "@formbricks/types/js"; import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/types/js";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getActionClassesForEnvironmentState } from "./actionClass"; import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState"; import { getEnvironmentState } from "./environmentState";
import { getProjectForEnvironmentState } from "./project";
import { getSurveysForEnvironmentState } from "./survey";
// Mock dependencies // Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/environment/service");
vi.mock("@/lib/organization/service"); vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer"); vi.mock("@/lib/posthogServer");
vi.mock("@/modules/ee/license-check/lib/utils"); vi.mock("@/modules/cache/lib/withCache");
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
prisma: { prisma: {
environment: { environment: {
@@ -41,11 +32,9 @@ vi.mock("@formbricks/logger", () => ({
error: vi.fn(), error: vi.fn(),
}, },
})); }));
vi.mock("./actionClass"); vi.mock("./data");
vi.mock("./project");
vi.mock("./survey");
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true, // Default to false, override in specific tests IS_FORMBRICKS_CLOUD: true,
RECAPTCHA_SITE_KEY: "mock_recaptcha_site_key", RECAPTCHA_SITE_KEY: "mock_recaptcha_site_key",
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key", RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
IS_RECAPTCHA_CONFIGURED: true, IS_RECAPTCHA_CONFIGURED: true,
@@ -56,13 +45,16 @@ vi.mock("@/lib/constants", () => ({
const environmentId = "test-environment-id"; const environmentId = "test-environment-id";
const mockEnvironment: TEnvironment = { const mockProject: TJsEnvironmentStateProject = {
id: environmentId, id: "test-project-id",
createdAt: new Date(), recontactDays: 30,
updatedAt: new Date(), inAppSurveyBranding: true,
projectId: "test-project-id", placement: "bottomRight",
type: "production", clickOutsideClose: true,
appSetupCompleted: true, // Default to true darkOverlay: false,
styling: {
allowStyleOverwrite: false,
},
}; };
const mockOrganization: TOrganization = { const mockOrganization: TOrganization = {
@@ -77,7 +69,7 @@ const mockOrganization: TOrganization = {
limits: { limits: {
projects: 1, projects: 1,
monthly: { monthly: {
responses: 100, // Default limit responses: 100,
miu: 1000, miu: 1000,
}, },
}, },
@@ -86,29 +78,6 @@ const mockOrganization: TOrganization = {
isAIEnabled: false, isAIEnabled: false,
}; };
const mockProject: TProject = {
id: "test-project-id",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Project",
config: {
channel: "link",
industry: "eCommerce",
},
organizationId: mockOrganization.id,
styling: {
allowStyleOverwrite: false,
},
recontactDays: 30,
inAppSurveyBranding: true,
linkSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
environments: [],
languages: [],
};
const mockSurveys: TSurvey[] = [ const mockSurveys: TSurvey[] = [
{ {
id: "survey-app-inProgress", id: "survey-app-inProgress",
@@ -149,84 +118,6 @@ const mockSurveys: TSurvey[] = [
createdBy: null, createdBy: null,
recaptcha: { enabled: false, threshold: 0.5 }, recaptcha: { enabled: false, threshold: 0.5 },
}, },
{
id: "survey-app-paused",
createdAt: new Date(),
updatedAt: new Date(),
name: "App Survey Paused",
environmentId: environmentId,
displayLimit: null,
endings: [],
followUps: [],
isBackButtonHidden: false,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
runOnDate: null,
showLanguageSwitch: false,
type: "app",
status: "paused",
questions: [],
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
singleUse: null,
triggers: [],
languages: [],
pin: null,
resultShareKey: 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 },
},
{
id: "survey-web-inProgress",
createdAt: new Date(),
updatedAt: new Date(),
name: "Web Survey In Progress",
environmentId: environmentId,
type: "link",
displayLimit: null,
endings: [],
followUps: [],
isBackButtonHidden: false,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
runOnDate: null,
showLanguageSwitch: false,
status: "inProgress",
questions: [],
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
singleUse: null,
triggers: [],
languages: [],
pin: null,
resultShareKey: 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[] = [ const mockActionClasses: TActionClass[] = [
@@ -243,19 +134,30 @@ const mockActionClasses: TActionClass[] = [
}, },
]; ];
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", () => { describe("getEnvironmentState", () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
// Mock the cache implementation
vi.mocked(cache).mockImplementation((fn) => async () => { // Mock withCache to simply execute the function without caching for tests
return fn(); vi.mocked(withCache).mockImplementation((fn) => fn);
});
// Default mocks for successful retrieval // Default mocks for successful retrieval
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment); vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getProjectForEnvironmentState).mockResolvedValue(mockProject);
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue([mockSurveys[0]]); // Only return the app, inProgress survey
vi.mocked(getActionClassesForEnvironmentState).mockResolvedValue(mockActionClasses);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit
}); });
@@ -268,42 +170,45 @@ describe("getEnvironmentState", () => {
const expectedData: TJsEnvironmentState["data"] = { const expectedData: TJsEnvironmentState["data"] = {
recaptchaSiteKey: "mock_recaptcha_site_key", recaptchaSiteKey: "mock_recaptcha_site_key",
surveys: [mockSurveys[0]], // Only app, inProgress survey surveys: mockSurveys,
actionClasses: mockActionClasses, actionClasses: mockActionClasses,
project: mockProject, project: mockProject,
}; };
expect(result.data).toEqual(expectedData); expect(result.data).toEqual(expectedData);
expect(result.revalidateEnvironment).toBe(false); expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
expect(getEnvironment).toHaveBeenCalledWith(environmentId);
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(getProjectForEnvironmentState).toHaveBeenCalledWith(environmentId);
expect(getSurveysForEnvironmentState).toHaveBeenCalledWith(environmentId);
expect(getActionClassesForEnvironmentState).toHaveBeenCalledWith(environmentId);
expect(prisma.environment.update).not.toHaveBeenCalled(); expect(prisma.environment.update).not.toHaveBeenCalled();
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled(); expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalled(); // Not cloud expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled(); expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
}); });
test("should throw ResourceNotFoundError if environment not found", async () => { test("should throw ResourceNotFoundError if environment not found", async () => {
vi.mocked(getEnvironment).mockResolvedValue(null); vi.mocked(getEnvironmentStateData).mockRejectedValue(
new ResourceNotFoundError("environment", environmentId)
);
await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError); await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError);
}); });
test("should throw ResourceNotFoundError if organization not found", async () => { test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); vi.mocked(getEnvironmentStateData).mockRejectedValue(new ResourceNotFoundError("organization", null));
await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError); await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError);
}); });
test("should throw ResourceNotFoundError if project not found", async () => { test("should throw ResourceNotFoundError if project not found", async () => {
vi.mocked(getProjectForEnvironmentState).mockResolvedValue(null); vi.mocked(getEnvironmentStateData).mockRejectedValue(new ResourceNotFoundError("project", null));
await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError); await expect(getEnvironmentState(environmentId)).rejects.toThrow(ResourceNotFoundError);
}); });
test("should update environment and capture event if app setup not completed", async () => { test("should update environment and capture event if app setup not completed", async () => {
const incompleteEnv = { ...mockEnvironment, appSetupCompleted: false }; const incompleteEnvironmentData = {
vi.mocked(getEnvironment).mockResolvedValue(incompleteEnv); ...mockEnvironmentStateData,
environment: {
...mockEnvironmentStateData.environment,
appSetupCompleted: false,
},
};
vi.mocked(getEnvironmentStateData).mockResolvedValue(incompleteEnvironmentData);
const result = await getEnvironmentState(environmentId); const result = await getEnvironmentState(environmentId);
@@ -312,14 +217,14 @@ describe("getEnvironmentState", () => {
data: { appSetupCompleted: true }, data: { appSetupCompleted: true },
}); });
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed"); expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
expect(result.revalidateEnvironment).toBe(true); expect(result.data).toBeDefined();
}); });
test("should return empty surveys if monthly response limit reached (Cloud)", async () => { test("should return empty surveys if monthly response limit reached (Cloud)", async () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100); // Exactly at limit vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100); // Exactly at limit
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue(mockSurveys);
const result = await getEnvironmentState(environmentId); const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toEqual([]); expect(result.data.surveys).toEqual([]);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id); expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, { expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
@@ -339,7 +244,7 @@ describe("getEnvironmentState", () => {
const result = await getEnvironmentState(environmentId); const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toEqual([mockSurveys[0]]); expect(result.data.surveys).toEqual(mockSurveys);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id); expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled(); expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
}); });
@@ -364,9 +269,12 @@ describe("getEnvironmentState", () => {
expect(result.data.recaptchaSiteKey).toBe("mock_recaptcha_site_key"); expect(result.data.recaptchaSiteKey).toBe("mock_recaptcha_site_key");
}); });
test("should filter surveys correctly (only app type and inProgress status)", async () => { test("should use withCache for caching with correct cache key and TTL", () => {
const result = await getEnvironmentState(environmentId); getEnvironmentState(environmentId);
expect(result.data.surveys).toHaveLength(1);
expect(result.data.surveys[0].id).toBe("survey-app-inProgress"); expect(withCache).toHaveBeenCalledWith(expect.any(Function), {
key: `fb:env:${environmentId}:state`,
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
});
}); });
}); });
@@ -1,125 +1,93 @@
import { actionClassCache } from "@/lib/actionClass/cache"; import "server-only";
import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { environmentCache } from "@/lib/environment/cache"; import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import { getEnvironment } from "@/lib/environment/service";
import { organizationCache } from "@/lib/organization/cache";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { import {
capturePosthogEnvironmentEvent, capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly, sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer"; } from "@/lib/posthogServer";
import { projectCache } from "@/lib/project/cache"; import { createCacheKey } from "@/modules/cache/lib/cacheKeys";
import { surveyCache } from "@/lib/survey/cache"; import { withCache } from "@/modules/cache/lib/withCache";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsEnvironmentState } from "@formbricks/types/js"; import { TJsEnvironmentState } from "@formbricks/types/js";
import { getActionClassesForEnvironmentState } from "./actionClass"; import { getEnvironmentStateData } from "./data";
import { getProjectForEnvironmentState } from "./project";
import { getSurveysForEnvironmentState } from "./survey";
/** /**
* 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 * @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 ( export const getEnvironmentState = async (
environmentId: string environmentId: string
): Promise<{ data: TJsEnvironmentState["data"]; revalidateEnvironment?: boolean }> => ): Promise<{ data: TJsEnvironmentState["data"] }> => {
cache( // Use withCache for efficient Redis caching with automatic fallback
const getCachedEnvironmentState = withCache(
async () => { async () => {
let revalidateEnvironment = false; // Single optimized database call replacing multiple service calls
const [environment, organization, project] = await Promise.all([ const { environment, organization, surveys, actionClasses } =
getEnvironment(environmentId), await getEnvironmentStateData(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);
}
// Handle app setup completion update if needed
// This is a one-time setup flag that can tolerate TTL-based cache expiration
if (!environment.appSetupCompleted) { if (!environment.appSetupCompleted) {
await Promise.all([ await Promise.all([
prisma.environment.update({ prisma.environment.update({
where: { where: { id: environmentId },
id: environmentId,
},
data: { appSetupCompleted: true }, data: { appSetupCompleted: true },
}), }),
capturePosthogEnvironmentEvent(environmentId, "app setup completed"), capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
]); ]);
revalidateEnvironment = true;
} }
// check if MAU limit is reached // Check monthly response limits for Formbricks Cloud
let isMonthlyResponsesLimitReached = false; let isMonthlyResponsesLimitReached = false;
if (IS_FORMBRICKS_CLOUD) { if (IS_FORMBRICKS_CLOUD) {
const monthlyResponseLimit = organization.billing.limits.monthly.responses; const monthlyResponseLimit = organization.billing.limits.monthly.responses;
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id); const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
isMonthlyResponsesLimitReached = isMonthlyResponsesLimitReached =
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit; monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
}
if (isMonthlyResponsesLimitReached) { // Send plan limits event if needed
try { if (isMonthlyResponsesLimitReached) {
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, { try {
plan: organization.billing.plan, await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
limits: { plan: organization.billing.plan,
projects: null, limits: {
monthly: { projects: null,
miu: null, monthly: {
responses: organization.billing.limits.monthly.responses, miu: null,
responses: organization.billing.limits.monthly.responses,
},
}, },
}, });
}); } catch (err) {
} catch (err) { logger.error(err, "Error sending plan limits reached event to Posthog");
logger.error(err, "Error sending plan limits reached event to Posthog"); }
} }
} }
const [surveys, actionClasses] = await Promise.all([ // Build the response data
getSurveysForEnvironmentState(environmentId),
getActionClassesForEnvironmentState(environmentId),
]);
const data: TJsEnvironmentState["data"] = { const data: TJsEnvironmentState["data"] = {
surveys: !isMonthlyResponsesLimitReached ? surveys : [], surveys: !isMonthlyResponsesLimitReached ? surveys : [],
actionClasses, actionClasses,
project: project, project: environment.project,
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}), ...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),
}; };
return { return { data };
data,
revalidateEnvironment,
};
}, },
[`environmentState-${environmentId}`],
{ {
...(IS_FORMBRICKS_CLOUD && { revalidate: 24 * 60 * 60 }), // Use enterprise-grade cache key pattern
tags: [ key: createCacheKey.environment.state(environmentId),
environmentCache.tag.byId(environmentId), // 30 minutes TTL ensures fresh data for hourly SDK checks
organizationCache.tag.byEnvironmentId(environmentId), // Balances performance with freshness requirements
projectCache.tag.byEnvironmentId(environmentId), ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
surveyCache.tag.byEnvironmentId(environmentId),
actionClassCache.tag.byEnvironmentId(environmentId),
],
} }
)(); );
return getCachedEnvironmentState();
};
@@ -1,120 +0,0 @@
import { cache } from "@/lib/cache";
import { projectCache } from "@/lib/project/cache";
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 { TJsEnvironmentStateProject } from "@formbricks/types/js";
import { getProjectForEnvironmentState } from "./project";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/project/cache");
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate"); // Mock validateInputs if needed, though it's often tested elsewhere
const environmentId = "test-environment-id";
const mockProject: TJsEnvironmentStateProject = {
id: "test-project-id",
recontactDays: 30,
clickOutsideClose: true,
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
};
describe("getProjectForEnvironmentState", () => {
beforeEach(() => {
vi.resetAllMocks();
// Mock cache implementation
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
// Mock projectCache tags
vi.mocked(projectCache.tag.byEnvironmentId).mockReturnValue(`project-env-${environmentId}`);
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return project state successfully", async () => {
vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject);
const result = await getProjectForEnvironmentState(environmentId);
expect(result).toEqual(mockProject);
expect(prisma.project.findFirst).toHaveBeenCalledWith({
where: {
environments: {
some: {
id: environmentId,
},
},
},
select: {
id: true,
recontactDays: true,
clickOutsideClose: true,
darkOverlay: true,
placement: true,
inAppSurveyBranding: true,
styling: true,
},
});
expect(cache).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getProjectForEnvironmentState-${environmentId}`],
{
tags: [`project-env-${environmentId}`],
}
);
});
test("should return null if project not found", async () => {
vi.mocked(prisma.project.findFirst).mockResolvedValue(null);
const result = await getProjectForEnvironmentState(environmentId);
expect(result).toBeNull();
expect(prisma.project.findFirst).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledTimes(1);
});
test("should throw DatabaseError on PrismaClientKnownRequestError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2001",
clientVersion: "test",
});
vi.mocked(prisma.project.findFirst).mockRejectedValue(prismaError);
await expect(getProjectForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting project for environment state");
expect(cache).toHaveBeenCalledTimes(1);
});
test("should re-throw unknown errors", async () => {
const unknownError = new Error("Something went wrong");
vi.mocked(prisma.project.findFirst).mockRejectedValue(unknownError);
await expect(getProjectForEnvironmentState(environmentId)).rejects.toThrow(unknownError);
expect(logger.error).not.toHaveBeenCalled(); // Should not log unknown errors here
expect(cache).toHaveBeenCalledTimes(1);
});
});
@@ -1,50 +0,0 @@
import { cache } from "@/lib/cache";
import { projectCache } from "@/lib/project/cache";
import { validateInputs } from "@/lib/utils/validate";
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 } from "@formbricks/types/errors";
import { TJsEnvironmentStateProject } from "@formbricks/types/js";
export const getProjectForEnvironmentState = reactCache(
async (environmentId: string): Promise<TJsEnvironmentStateProject | null> =>
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)],
}
)()
);
@@ -1,155 +0,0 @@
import { cache } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate";
import { transformPrismaSurvey } from "@/modules/survey/lib/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 } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { getSurveysForEnvironmentState } from "./survey";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/utils/validate");
vi.mock("@/modules/survey/lib/utils");
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const environmentId = "test-environment-id";
const mockPrismaSurvey = {
id: "survey-1",
welcomeCard: { enabled: false },
name: "Test Survey",
questions: [],
variables: [],
type: "app",
showLanguageSwitch: false,
languages: [],
endings: [],
autoClose: null,
styling: null,
status: "inProgress",
recaptcha: null,
segment: null,
recontactDays: null,
displayLimit: null,
displayOption: "displayOnce",
hiddenFields: { enabled: false },
isBackButtonHidden: false,
triggers: [],
displayPercentage: null,
delay: 0,
projectOverwrites: null,
};
const mockTransformedSurvey: TJsEnvironmentStateSurvey = {
id: "survey-1",
welcomeCard: { enabled: false } as TJsEnvironmentStateSurvey["welcomeCard"],
name: "Test Survey",
questions: [],
variables: [],
type: "app",
showLanguageSwitch: false,
languages: [],
endings: [],
autoClose: null,
styling: null,
status: "inProgress",
recaptcha: null,
segment: null,
recontactDays: null,
displayLimit: null,
displayOption: "displayOnce",
hiddenFields: { enabled: false },
isBackButtonHidden: false,
triggers: [],
displayPercentage: null,
delay: 0,
projectOverwrites: null,
};
describe("getSurveysForEnvironmentState", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
vi.mocked(validateInputs).mockReturnValue([environmentId]); // Assume validation passes
vi.mocked(transformPrismaSurvey).mockReturnValue(mockTransformedSurvey);
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return transformed surveys on successful fetch", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([mockPrismaSurvey]);
const result = await getSurveysForEnvironmentState(environmentId);
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: {
environmentId,
type: "app",
status: "inProgress",
},
select: expect.any(Object), // Check if select is called, specific fields are in the original code
orderBy: { createdAt: "desc" },
take: 30,
});
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey);
expect(result).toEqual([mockTransformedSurvey]);
expect(logger.error).not.toHaveBeenCalled();
});
test("should return an empty array if no surveys are found", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([]);
const result = await getSurveysForEnvironmentState(environmentId);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: {
environmentId,
type: "app",
status: "inProgress",
},
select: expect.any(Object),
orderBy: { createdAt: "desc" },
take: 30,
});
expect(transformPrismaSurvey).not.toHaveBeenCalled();
expect(result).toEqual([]);
expect(logger.error).not.toHaveBeenCalled();
});
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.survey.findMany).mockRejectedValue(prismaError);
await expect(getSurveysForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys for environment state");
});
test("should rethrow unknown errors", async () => {
const unknownError = new Error("Something went wrong");
vi.mocked(prisma.survey.findMany).mockRejectedValue(unknownError);
await expect(getSurveysForEnvironmentState(environmentId)).rejects.toThrow(unknownError);
expect(logger.error).not.toHaveBeenCalled();
});
});
@@ -1,102 +0,0 @@
import { cache } from "@/lib/cache";
import { surveyCache } from "@/lib/survey/cache";
import { validateInputs } from "@/lib/utils/validate";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
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 } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
export const getSurveysForEnvironmentState = reactCache(
async (environmentId: string): Promise<TJsEnvironmentStateSurvey[]> =>
cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
type: "app",
status: "inProgress",
},
orderBy: {
createdAt: "desc",
},
take: 30,
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,
},
});
return surveysPrisma.map((survey) => transformPrismaSurvey<TJsEnvironmentStateSurvey>(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)],
}
)()
);
@@ -1,14 +1,19 @@
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState"; import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { environmentCache } from "@/lib/environment/cache";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZJsSyncInput } from "@formbricks/types/js";
export const OPTIONS = async (): Promise<Response> => { export const OPTIONS = async (): Promise<Response> => {
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 ( export const GET = async (
@@ -22,51 +27,49 @@ export const GET = async (
const params = await props.params; const params = await props.params;
try { try {
// validate using zod // Simple validation for environmentId (faster than Zod for high-frequency endpoint)
const inputValidation = ZJsSyncInput.safeParse({ if (!params.environmentId || typeof params.environmentId !== "string") {
environmentId: params.environmentId, return responses.badRequestResponse("Environment ID is required", undefined, true);
});
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
} }
try { // Use optimized environment state fetcher with new caching approach
const environmentState = await getEnvironmentState(params.environmentId); const environmentState = await getEnvironmentState(params.environmentId);
const { data, revalidateEnvironment } = environmentState; const { data } = environmentState;
if (revalidateEnvironment) { return responses.successResponse(
environmentCache.revalidate({ {
id: inputValidation.data.environmentId, data,
projectId: data.project.id, expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
}); },
} true,
// Optimized cache headers for Cloudflare CDN and browser caching
return responses.successResponse( // 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) {
logger.warn(
{ {
data, environmentId: params.environmentId,
expiresAt: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes resourceType: err.resourceType,
resourceId: err.resourceId,
}, },
true, "Resource not found in environment endpoint"
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
); );
} catch (err) { return responses.notFoundResponse(err.resourceType, err.resourceId);
if (err instanceof ResourceNotFoundError) {
return responses.notFoundResponse(err.resourceType, err.resourceId);
}
logger.error(
{ error: err, url: request.url },
"Error in GET /api/v1/client/[environmentId]/environment"
);
return responses.internalServerErrorResponse(err.message, true);
} }
} catch (error) {
logger.error({ error, url: request.url }, "Error in GET /api/v1/client/[environmentId]/environment"); logger.error(
return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true); {
error: err,
url: request.url,
environmentId: params.environmentId,
},
"Error in GET /api/v1/client/[environmentId]/environment"
);
return responses.internalServerErrorResponse(err.message, true);
} }
}; };
@@ -1,6 +1,5 @@
import { cache } from "@/lib/cache";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { getContact, getContactByUserId } from "./contact"; import { getContact, getContactByUserId } from "./contact";
@@ -15,9 +14,6 @@ vi.mock("@formbricks/database", () => ({
}, },
})); }));
// Mock cache module
vi.mock("@/lib/cache");
// Mock react cache // Mock react cache
vi.mock("react", async () => { vi.mock("react", async () => {
const actual = await vi.importActual("react"); const actual = await vi.importActual("react");
@@ -32,12 +28,6 @@ const mockEnvironmentId = "test-env-id";
const mockUserId = "test-user-id"; const mockUserId = "test-user-id";
describe("Contact API Lib", () => { describe("Contact API Lib", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => { afterEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
}); });
@@ -1,84 +1,67 @@
import { cache } from "@/lib/cache";
import { contactCache } from "@/lib/cache/contact";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute"; import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
export const getContact = reactCache((contactId: string) => export const getContact = reactCache(async (contactId: string) => {
cache( try {
async () => { const contact = await prisma.contact.findUnique({
try { where: { id: contactId },
const contact = await prisma.contact.findUnique({ select: { id: true },
where: { id: contactId }, });
select: { id: true },
});
return contact; return contact;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
}
}
},
[`getContact-responses-api-${contactId}`],
{
tags: [contactCache.tag.byId(contactId)],
} }
)() }
); });
export const getContactByUserId = reactCache( export const getContactByUserId = reactCache(
( async (
environmentId: string, environmentId: string,
userId: string userId: string
): Promise<{ ): Promise<{
id: string; id: string;
attributes: TContactAttributes; attributes: TContactAttributes;
} | null> => } | null> => {
cache( const contact = await prisma.contact.findFirst({
async () => { where: {
const contact = await prisma.contact.findFirst({ attributes: {
where: { some: {
attributes: { attributeKey: {
some: { key: "userId",
attributeKey: { environmentId,
key: "userId",
environmentId,
},
value: userId,
},
}, },
value: userId,
}, },
select: { },
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
if (!contact) {
return null;
}
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}`], select: {
{ id: true,
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)], attributes: {
} select: {
)() attributeKey: { select: { key: true } },
value: true,
},
},
},
});
if (!contact) {
return null;
}
const contactAttributes = contact.attributes.reduce((acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
}, {}) as TContactAttributes;
return {
id: contact.id,
attributes: contactAttributes,
};
}
); );
@@ -29,22 +29,10 @@ vi.mock("@/lib/posthogServer", () => ({
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(), sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
})); }));
vi.mock("@/lib/response/cache", () => ({
responseCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/response/utils", () => ({ vi.mock("@/lib/response/utils", () => ({
calculateTtcTotal: vi.fn((ttc) => ttc), calculateTtcTotal: vi.fn((ttc) => ttc),
})); }));
vi.mock("@/lib/responseNote/cache", () => ({
responseNoteCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/telemetry", () => ({ vi.mock("@/lib/telemetry", () => ({
captureTelemetry: vi.fn(), captureTelemetry: vi.fn(),
})); }));
@@ -5,9 +5,7 @@ import {
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer"; import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { responseCache } from "@/lib/response/cache";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { responseNoteCache } from "@/lib/responseNote/cache";
import { captureTelemetry } from "@/lib/telemetry"; import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
@@ -149,19 +147,6 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => 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) { if (IS_FORMBRICKS_CLOUD) {
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id); const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
const responsesLimit = organization.billing.limits.monthly.responses; const responsesLimit = organization.billing.limits.monthly.responses;
@@ -20,7 +20,13 @@ interface Context {
} }
export const OPTIONS = async (): Promise<Response> => { export const OPTIONS = async (): Promise<Response> => {
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<Response> => { export const POST = async (request: Request, context: Context): Promise<Response> => {
@@ -19,15 +19,12 @@ interface Context {
} }
export const OPTIONS = async (): Promise<Response> => { export const OPTIONS = async (): Promise<Response> => {
return Response.json( return responses.successResponse(
{}, {},
{ true,
headers: { // Cache CORS preflight responses for 1 hour (conservative approach)
"Access-Control-Allow-Origin": "*", // Balances performance gains with flexibility for CORS policy changes
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "public, s-maxage=3600, max-age=3600"
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
}
); );
}; };
@@ -15,7 +15,13 @@ interface Context {
} }
export const OPTIONS = async (): Promise<Response> => { export const OPTIONS = async (): Promise<Response> => {
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 // api endpoint for uploading private files
@@ -1,6 +1,7 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service"; import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
@@ -44,63 +45,104 @@ export const GET = async (
} }
}; };
export const PUT = async ( export const PUT = withApiLogging(
request: Request, async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
props: { params: Promise<{ actionClassId: string }> } const params = await props.params;
): Promise<Response> => {
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 { try {
actionClassUpdate = await request.json(); const authentication = await authenticateRequest(request);
} catch (error) { if (!authentication) {
logger.error({ error, url: request.url }, "Error parsing JSON"); return {
return responses.badRequestResponse("Malformed JSON input, please check your request body"); response: responses.notAuthenticatedResponse(),
} };
}
auditLog.userId = authentication.apiKeyId;
const inputValidation = ZActionClassInput.safeParse(actionClassUpdate); const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
if (!inputValidation.success) { if (!actionClass) {
return responses.badRequestResponse( return {
"Fields are missing or incorrectly formatted", response: responses.notFoundResponse("Action Class", params.actionClassId),
transformErrorToDetails(inputValidation.error) };
}
auditLog.oldObject = actionClass;
auditLog.organizationId = authentication.organizationId;
let actionClassUpdate;
try {
actionClassUpdate = await request.json();
} catch (error) {
logger.error({ error, url: request.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, "updated",
params.actionClassId, "actionClass"
inputValidation.data );
);
if (updatedActionClass) {
return responses.successResponse(updatedActionClass);
}
return responses.internalServerErrorResponse("Some error ocured while updating action");
} catch (error) {
return handleErrorResponse(error);
}
};
export const DELETE = async ( export const DELETE = withApiLogging(
request: Request, async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
props: { params: Promise<{ actionClassId: string }> } const params = await props.params;
): Promise<Response> => { auditLog.targetId = params.actionClassId;
const params = await props.params;
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) {
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE"); return {
if (!actionClass) { response: responses.notAuthenticatedResponse(),
return responses.notFoundResponse("Action Class", params.actionClassId); };
}
auditLog.userId = authentication.apiKeyId;
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
if (!actionClass) {
return {
response: responses.notFoundResponse("Action Class", params.actionClassId),
};
}
auditLog.oldObject = actionClass;
auditLog.organizationId = authentication.organizationId;
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); "deleted",
} catch (error) { "actionClass"
return handleErrorResponse(error); );
}
};
@@ -1,8 +1,6 @@
"use server"; "use server";
import "server-only"; import "server-only";
import { actionClassCache } from "@/lib/actionClass/cache";
import { cache } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
@@ -23,29 +21,20 @@ const selectActionClass = {
environmentId: true, environmentId: true,
} satisfies Prisma.ActionClassSelect; } satisfies Prisma.ActionClassSelect;
export const getActionClasses = reactCache( export const getActionClasses = reactCache(async (environmentIds: string[]): Promise<TActionClass[]> => {
async (environmentIds: string[]): Promise<TActionClass[]> => validateInputs([environmentIds, ZId.array()]);
cache(
async () => {
validateInputs([environmentIds, ZId.array()]);
try { try {
return await prisma.actionClass.findMany({ return await prisma.actionClass.findMany({
where: { where: {
environmentId: { in: environmentIds }, environmentId: { in: environmentIds },
},
select: selectActionClass,
orderBy: {
createdAt: "asc",
},
});
} catch (error) {
throw new DatabaseError(`Database error when fetching actions for environment ${environmentIds}`);
}
}, },
environmentIds.map((environmentId) => `getActionClasses-management-api-${environmentId}`), select: selectActionClass,
{ orderBy: {
tags: environmentIds.map((environmentId) => actionClassCache.tag.byEnvironmentId(environmentId)), createdAt: "asc",
} },
)() });
); } catch (error) {
throw new DatabaseError(`Database error when fetching actions for environment ${environmentIds}`);
}
});
@@ -1,6 +1,7 @@
import { authenticateRequest } from "@/app/api/v1/auth"; import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { createActionClass } from "@/lib/actionClass/service"; import { createActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
@@ -28,41 +29,62 @@ export const GET = async (request: Request) => {
} }
}; };
export const POST = async (request: Request): Promise<Response> => { export const POST = withApiLogging(
try { async (request: Request, _, auditLog: ApiAuditLog) => {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
let actionClassInput;
try { try {
actionClassInput = await request.json(); const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
let actionClassInput;
try {
actionClassInput = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
const environmentId = actionClassInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
auditLog.targetId = actionClass.id;
auditLog.newObject = actionClass;
return {
response: responses.successResponse(actionClass),
};
} catch (error) { } catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input"); if (error instanceof DatabaseError) {
return responses.badRequestResponse("Malformed JSON input, please check your request body"); return {
response: responses.badRequestResponse(error.message),
};
}
throw error;
} }
},
const inputValidation = ZActionClassInput.safeParse(actionClassInput); "created",
"actionClass"
const environmentId = actionClassInput.environmentId; );
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
return responses.successResponse(actionClass);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
@@ -1,6 +1,7 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation"; import { validateFileUploads } from "@/lib/fileValidation";
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service"; import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
@@ -48,58 +49,101 @@ export const GET = async (
} }
}; };
export const DELETE = async ( export const DELETE = withApiLogging(
request: Request, async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
props: { params: Promise<{ responseId: string }> } const params = await props.params;
): Promise<Response> => { auditLog.targetId = params.responseId;
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
if (result.error) return result.error;
const deletedResponse = await deleteResponse(params.responseId);
return responses.successResponse(deletedResponse);
} catch (error) {
return handleErrorResponse(error);
}
};
export const PUT = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (result.error) return result.error;
let responseUpdate;
try { try {
responseUpdate = await request.json(); const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
if (result.error) {
return {
response: result.error,
};
}
auditLog.oldObject = result.response;
const deletedResponse = await deleteResponse(params.responseId);
return {
response: responses.successResponse(deletedResponse),
};
} catch (error) { } catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON"); return {
return responses.badRequestResponse("Malformed JSON input, please check your request body"); response: handleErrorResponse(error),
};
} }
},
"deleted",
"response"
);
if (!validateFileUploads(responseUpdate.data, result.survey.questions)) { export const PUT = withApiLogging(
return responses.badRequestResponse("Invalid file upload response"); async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
} const params = await props.params;
auditLog.targetId = params.responseId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate); const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (!inputValidation.success) { if (result.error) {
return responses.badRequestResponse( return {
"Fields are missing or incorrectly formatted", response: result.error,
transformErrorToDetails(inputValidation.error) };
); }
auditLog.oldObject = result.response;
let responseUpdate;
try {
responseUpdate = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
if (!validateFileUploads(responseUpdate.data, result.survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
};
}
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
),
};
}
const updated = await updateResponse(params.responseId, inputValidation.data);
auditLog.newObject = updated;
return {
response: responses.successResponse(updated),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
} }
return responses.successResponse(await updateResponse(params.responseId, inputValidation.data)); },
} catch (error) { "updated",
return handleErrorResponse(error); "response"
} );
};
@@ -1,6 +1,4 @@
import { cache } from "@/lib/cache"; import { describe, expect, test, vi } from "vitest";
import { contactCache } from "@/lib/cache/contact";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute"; import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { getContactByUserId } from "./contact"; import { getContactByUserId } from "./contact";
@@ -14,8 +12,6 @@ vi.mock("@formbricks/database", () => ({
}, },
})); }));
vi.mock("@/lib/cache");
const environmentId = "test-env-id"; const environmentId = "test-env-id";
const userId = "test-user-id"; const userId = "test-user-id";
const contactId = "test-contact-id"; const contactId = "test-contact-id";
@@ -36,12 +32,6 @@ const expectedContactAttributes: TContactAttributes = {
}; };
describe("getContactByUserId", () => { describe("getContactByUserId", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("should return contact with attributes when found", async () => { test("should return contact with attributes when found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactDbData); vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactDbData);
@@ -73,13 +63,6 @@ describe("getContactByUserId", () => {
id: contactId, id: contactId,
attributes: expectedContactAttributes, attributes: expectedContactAttributes,
}); });
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getContactByUserIdForResponsesApi-${environmentId}-${userId}`],
{
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
}
);
}); });
test("should return null when contact is not found", async () => { test("should return null when contact is not found", async () => {
@@ -110,12 +93,5 @@ describe("getContactByUserId", () => {
}, },
}); });
expect(contact).toBeNull(); expect(contact).toBeNull();
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getContactByUserIdForResponsesApi-${environmentId}-${userId}`],
{
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
}
);
}); });
}); });
@@ -1,60 +1,51 @@
import "server-only"; import "server-only";
import { cache } from "@/lib/cache";
import { contactCache } from "@/lib/cache/contact";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute"; import { TContactAttributes } from "@formbricks/types/contact-attribute";
export const getContactByUserId = reactCache( export const getContactByUserId = reactCache(
( async (
environmentId: string, environmentId: string,
userId: string userId: string
): Promise<{ ): Promise<{
id: string; id: string;
attributes: TContactAttributes; attributes: TContactAttributes;
} | null> => } | null> => {
cache( const contact = await prisma.contact.findFirst({
async () => { where: {
const contact = await prisma.contact.findFirst({ attributes: {
where: { some: {
attributes: { attributeKey: {
some: { key: "userId",
attributeKey: { environmentId,
key: "userId",
environmentId,
},
value: userId,
},
}, },
value: userId,
}, },
select: { },
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
if (!contact) {
return null;
}
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}`], select: {
{ id: true,
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)], attributes: {
} select: {
)() attributeKey: { select: { key: true } },
value: true,
},
},
},
});
if (!contact) {
return null;
}
const contactAttributes = contact.attributes.reduce((acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
}, {}) as TContactAttributes;
return {
id: contact.id,
attributes: contactAttributes,
};
}
); );
@@ -1,13 +1,10 @@
import { cache } from "@/lib/cache";
import { import {
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer"; import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { responseCache } from "@/lib/response/cache";
import { getResponseContact } from "@/lib/response/service"; import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { responseNoteCache } from "@/lib/responseNote/cache";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { Organization, Prisma, Response as ResponsePrisma } from "@prisma/client"; import { Organization, Prisma, Response as ResponsePrisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
@@ -99,7 +96,6 @@ const mockResponsesPrisma = [mockResponsePrisma, { ...mockResponsePrisma, id: "r
const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response-2" }]; const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response-2" }];
// Mock dependencies // Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true, IS_FORMBRICKS_CLOUD: true,
POSTHOG_API_KEY: "mock-posthog-api-key", POSTHOG_API_KEY: "mock-posthog-api-key",
@@ -125,10 +121,8 @@ vi.mock("@/lib/constants", () => ({
})); }));
vi.mock("@/lib/organization/service"); vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer"); vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/cache");
vi.mock("@/lib/response/service"); vi.mock("@/lib/response/service");
vi.mock("@/lib/response/utils"); vi.mock("@/lib/response/utils");
vi.mock("@/lib/responseNote/cache");
vi.mock("@/lib/telemetry"); vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate"); vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
@@ -145,10 +139,6 @@ vi.mock("./contact");
describe("Response Lib Tests", () => { describe("Response Lib Tests", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// No need to mock IS_FORMBRICKS_CLOUD here anymore unless specifically changing it from the default
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
}); });
describe("createResponse", () => { describe("createResponse", () => {
@@ -174,13 +164,6 @@ describe("Response Lib Tests", () => {
}), }),
}) })
); );
expect(responseCache.revalidate).toHaveBeenCalledWith(
expect.objectContaining({
contactId: mockContact.id,
userId: mockUserId,
})
);
expect(responseNoteCache.revalidate).toHaveBeenCalled();
expect(response.contact).toEqual({ id: mockContact.id, userId: mockUserId }); expect(response.contact).toEqual({ id: mockContact.id, userId: mockUserId });
}); });
@@ -296,7 +279,6 @@ describe("Response Lib Tests", () => {
); );
expect(getResponseContact).toHaveBeenCalledTimes(mockResponsesPrisma.length); expect(getResponseContact).toHaveBeenCalledTimes(mockResponsesPrisma.length);
expect(responses).toEqual(mockTransformedResponses); expect(responses).toEqual(mockTransformedResponses);
expect(cache).toHaveBeenCalled();
}); });
test("should return responses with limit and offset", async () => { test("should return responses with limit and offset", async () => {
@@ -311,7 +293,6 @@ describe("Response Lib Tests", () => {
skip: mockOffset, skip: mockOffset,
}) })
); );
expect(cache).toHaveBeenCalled();
}); });
test("should return empty array if no responses found", async () => { test("should return empty array if no responses found", async () => {
@@ -322,7 +303,6 @@ describe("Response Lib Tests", () => {
expect(responses).toEqual([]); expect(responses).toEqual([]);
expect(prisma.response.findMany).toHaveBeenCalled(); expect(prisma.response.findMany).toHaveBeenCalled();
expect(getResponseContact).not.toHaveBeenCalled(); expect(getResponseContact).not.toHaveBeenCalled();
expect(cache).toHaveBeenCalled();
}); });
test("should handle PrismaClientKnownRequestError", async () => { test("should handle PrismaClientKnownRequestError", async () => {
@@ -333,7 +313,6 @@ describe("Response Lib Tests", () => {
vi.mocked(prisma.response.findMany).mockRejectedValue(prismaError); vi.mocked(prisma.response.findMany).mockRejectedValue(prismaError);
await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(DatabaseError); await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(DatabaseError);
expect(cache).toHaveBeenCalled();
}); });
test("should handle generic errors", async () => { test("should handle generic errors", async () => {
@@ -341,7 +320,6 @@ describe("Response Lib Tests", () => {
vi.mocked(prisma.response.findMany).mockRejectedValue(genericError); vi.mocked(prisma.response.findMany).mockRejectedValue(genericError);
await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(genericError); await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(genericError);
expect(cache).toHaveBeenCalled();
}); });
}); });
}); });
@@ -1,15 +1,12 @@
import "server-only"; import "server-only";
import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { import {
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer"; import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { responseCache } from "@/lib/response/cache";
import { getResponseContact } from "@/lib/response/service"; import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils"; import { calculateTtcTotal } from "@/lib/response/utils";
import { responseNoteCache } from "@/lib/responseNote/cache";
import { captureTelemetry } from "@/lib/telemetry"; import { captureTelemetry } from "@/lib/telemetry";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
@@ -153,19 +150,6 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => 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) { if (IS_FORMBRICKS_CLOUD) {
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id); const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
const responsesLimit = organization.billing.limits.monthly.responses; const responsesLimit = organization.billing.limits.monthly.responses;
@@ -200,51 +184,42 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
}; };
export const getResponsesByEnvironmentIds = reactCache( export const getResponsesByEnvironmentIds = reactCache(
async (environmentIds: string[], limit?: number, offset?: number): Promise<TResponse[]> => async (environmentIds: string[], limit?: number, offset?: number): Promise<TResponse[]> => {
cache( validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
async () => { try {
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]); const responses = await prisma.response.findMany({
try { where: {
const responses = await prisma.response.findMany({ survey: {
where: { environmentId: { in: environmentIds },
survey: { },
environmentId: { in: environmentIds }, },
}, select: responseSelection,
}, orderBy: [
select: responseSelection, {
orderBy: [ createdAt: "desc",
{ },
createdAt: "desc", ],
}, take: limit ? limit : undefined,
], skip: offset ? offset : undefined,
take: limit ? limit : undefined, });
skip: offset ? offset : undefined,
});
const transformedResponses: TResponse[] = await Promise.all( const transformedResponses: TResponse[] = await Promise.all(
responses.map((responsePrisma) => { responses.map((responsePrisma) => {
return { return {
...responsePrisma, ...responsePrisma,
contact: getResponseContact(responsePrisma), contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
}; };
}) })
); );
return transformedResponses; return transformedResponses;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
}
throw error;
}
},
environmentIds.map(
(environmentId) => `getResponses-management-api-${environmentId}-${limit}-${offset}`
),
{
tags: environmentIds.map((environmentId) => responseCache.tag.byEnvironmentId(environmentId)),
} }
)()
throw error;
}
}
); );
@@ -1,6 +1,7 @@
import { authenticateRequest } from "@/app/api/v1/auth"; import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation"; import { validateFileUploads } from "@/lib/fileValidation";
import { getResponses } from "@/lib/response/service"; import { getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
@@ -91,46 +92,78 @@ const validateSurvey = async (responseInput: TResponseInput, environmentId: stri
return { survey }; return { survey };
}; };
export const POST = async (request: Request): Promise<Response> => { export const POST = withApiLogging(
try { async (request: Request, _, auditLog: ApiAuditLog) => {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const inputResult = await validateInput(request);
if (inputResult.error) return inputResult.error;
const responseInput = inputResult.data;
const environmentId = responseInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
const surveyResult = await validateSurvey(responseInput, environmentId);
if (surveyResult.error) return surveyResult.error;
if (!validateFileUploads(responseInput.data, surveyResult.survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
if (responseInput.createdAt && !responseInput.updatedAt) {
responseInput.updatedAt = responseInput.createdAt;
}
try { try {
const response = await createResponse(responseInput); const authentication = await authenticateRequest(request);
return responses.successResponse(response, true); if (!authentication) {
} catch (error) { return {
if (error instanceof InvalidInputError) { response: responses.notAuthenticatedResponse(),
return responses.badRequestResponse(error.message); };
} }
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses"); auditLog.userId = authentication.apiKeyId;
return responses.internalServerErrorResponse(error.message); auditLog.organizationId = authentication.organizationId;
const inputResult = await validateInput(request);
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,
};
}
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) {
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return {
response: responses.internalServerErrorResponse(error.message),
};
}
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
throw error;
} }
} catch (error) { },
if (error instanceof DatabaseError) { "created",
return responses.badRequestResponse(error.message); "response"
} );
throw error;
}
};
@@ -0,0 +1,204 @@
import { checkForRequiredFields } from "./utils";
import { describe, test, expect } from "vitest";
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { Session } from "next-auth";
import { vi } from "vitest";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "@/app/api/v1/auth";
import { checkAuth } 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("@/app/api/v1/auth", () => ({
authenticateRequest: vi.fn(),
}));
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";
const mockRequest = new NextRequest("http://localhost:3000/api/test");
test("returns notAuthenticatedResponse when no session and no authentication", async () => {
vi.mocked(authenticateRequest).mockResolvedValue(null);
const result = await checkAuth(null, environmentId, mockRequest);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
});
test("returns unauthorizedResponse when no session and 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(authenticateRequest).mockResolvedValue(mockAuthentication);
vi.mocked(hasPermission).mockReturnValue(false);
const result = await checkAuth(null, environmentId, mockRequest);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(hasPermission).toHaveBeenCalledWith(mockAuthentication.environmentPermissions, environmentId, "POST");
expect(responses.unauthorizedResponse).toHaveBeenCalled();
expect(result).toBe(mockUnauthorizedResponse);
});
test("returns undefined when no session and 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(authenticateRequest).mockResolvedValue(mockAuthentication);
vi.mocked(hasPermission).mockReturnValue(true);
const result = await checkAuth(null, environmentId, mockRequest);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
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, mockRequest);
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, mockRequest);
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(result).toBeUndefined();
});
test("does not call authenticateRequest when session exists", async () => {
const mockSession: Session = {
user: {
id: "user-123",
},
expires: "2024-12-31T23:59:59.999Z",
};
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
await checkAuth(mockSession, environmentId, mockRequest);
expect(authenticateRequest).not.toHaveBeenCalled();
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
});
});
@@ -0,0 +1,38 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { NextRequest } from "next/server";
import { Session } from "next-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 (session: Session | null, environmentId: string, request: NextRequest) => {
if (!session) {
//check whether its using API key
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
} else {
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
}
};
@@ -4,13 +4,13 @@
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants"; import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
import { validateLocalSignedUrl } from "@/lib/crypto"; import { validateLocalSignedUrl } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { validateFile } from "@/lib/fileValidation"; import { validateFile } from "@/lib/fileValidation";
import { putFileToLocalStorage } from "@/lib/storage/service"; import { putFileToLocalStorage } from "@/lib/storage/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
export const POST = async (req: NextRequest): Promise<Response> => { export const POST = async (req: NextRequest): Promise<Response> => {
if (!ENCRYPTION_KEY) { if (!ENCRYPTION_KEY) {
@@ -27,41 +27,17 @@ export const POST = async (req: NextRequest): Promise<Response> => {
const signedTimestamp = jsonInput.timestamp as string; const signedTimestamp = jsonInput.timestamp as string;
const environmentId = jsonInput.environmentId as string; const environmentId = jsonInput.environmentId as string;
if (!environmentId) { const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, encodedFileName);
return responses.badRequestResponse("environmentId is required"); if (requiredFieldResponse) return requiredFieldResponse;
}
if (!fileType) { if (!signedSignature || !signedUuid || !signedTimestamp) {
return responses.badRequestResponse("contentType is required");
}
if (!encodedFileName) {
return responses.badRequestResponse("fileName is required");
}
if (!signedSignature) {
return responses.unauthorizedResponse();
}
if (!signedUuid) {
return responses.unauthorizedResponse();
}
if (!signedTimestamp) {
return responses.unauthorizedResponse(); return responses.unauthorizedResponse();
} }
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || !session.user) { const authResponse = await checkAuth(session, environmentId, req);
return responses.notAuthenticatedResponse(); if (authResponse) return authResponse;
}
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
const fileName = decodeURIComponent(encodedFileName); const fileName = decodeURIComponent(encodedFileName);
+11 -26
View File
@@ -1,11 +1,12 @@
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { validateFile } from "@/lib/fileValidation"; import { validateFile } from "@/lib/fileValidation";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { getSignedUrlForPublicFile } from "./lib/getSignedUrl"; import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
// api endpoint for uploading public files // api endpoint for uploading public files
// uploaded files will be public, anyone can access the file // uploaded files will be public, anyone can access the file
@@ -13,29 +14,26 @@ import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
// use this to upload files for a specific resource, e.g. a user profile picture or a survey // 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 // 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<Response> => {
export const POST = async (request: NextRequest): Promise<Response> => {
let storageInput; let storageInput;
try { try {
storageInput = await req.json(); storageInput = await request.json();
} catch (error) { } catch (error) {
logger.error({ error, url: req.url }, "Error parsing JSON input"); logger.error({ error, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body"); return responses.badRequestResponse("Malformed JSON input, please check your request body");
} }
const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput; const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput;
if (!fileName) { const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName);
return responses.badRequestResponse("fileName is required"); if (requiredFieldResponse) return requiredFieldResponse;
} const session = await getServerSession(authOptions);
if (!fileType) { const authResponse = await checkAuth(session, environmentId, request);
return responses.badRequestResponse("fileType is required"); if (authResponse) return authResponse;
}
if (!environmentId) {
return responses.badRequestResponse("environmentId is required");
}
// Perform server-side file validation first to block dangerous file types // Perform server-side file validation first to block dangerous file types
const fileValidation = validateFile(fileName, fileType); const fileValidation = validateFile(fileName, fileType);
@@ -53,18 +51,5 @@ export const POST = async (req: NextRequest): Promise<Response> => {
} }
} }
// auth and upload private file
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();
}
return await getSignedUrlForPublicFile(fileName, environmentId, fileType); return await getSignedUrlForPublicFile(fileName, environmentId, fileType);
}; };
@@ -1,6 +1,3 @@
import { segmentCache } from "@/lib/cache/segment";
import { responseCache } from "@/lib/response/cache";
import { surveyCache } from "@/lib/survey/cache";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -9,22 +6,6 @@ import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { deleteSurvey } from "./surveys"; import { deleteSurvey } from "./surveys";
// Mock dependencies
vi.mock("@/lib/cache/segment", () => ({
segmentCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/response/cache", () => ({
responseCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/survey/cache", () => ({
surveyCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({ vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(), validateInputs: vi.fn(),
})); }));
@@ -91,14 +72,7 @@ describe("deleteSurvey", () => {
}, },
}); });
expect(prisma.segment.delete).not.toHaveBeenCalled(); expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(segmentCache.revalidate).not.toHaveBeenCalled(); // No segment to revalidate
expect(responseCache.revalidate).toHaveBeenCalledWith({ surveyId, environmentId });
expect(surveyCache.revalidate).toHaveBeenCalledTimes(1); // Only for surveyId
expect(surveyCache.revalidate).toHaveBeenCalledWith({
id: surveyId,
environmentId,
resultShareKey: undefined,
});
expect(deletedSurvey).toEqual(mockDeletedSurveyLink); expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
}); });
@@ -112,9 +86,6 @@ describe("deleteSurvey", () => {
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError); await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey"); expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).not.toHaveBeenCalled(); expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(segmentCache.revalidate).not.toHaveBeenCalled();
expect(responseCache.revalidate).not.toHaveBeenCalled();
expect(surveyCache.revalidate).not.toHaveBeenCalled();
}); });
test("should handle PrismaClientKnownRequestError during segment deletion", async () => { test("should handle PrismaClientKnownRequestError during segment deletion", async () => {
@@ -128,7 +99,6 @@ describe("deleteSurvey", () => {
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError); await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey"); expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } }); expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } });
// Caches might have been partially revalidated before the error
}); });
test("should handle generic errors during deletion", async () => { test("should handle generic errors during deletion", async () => {
@@ -136,7 +106,7 @@ describe("deleteSurvey", () => {
vi.mocked(prisma.survey.delete).mockRejectedValue(genericError); vi.mocked(prisma.survey.delete).mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError); await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled(); // Should not log generic errors here expect(logger.error).not.toHaveBeenCalled();
expect(prisma.segment.delete).not.toHaveBeenCalled(); expect(prisma.segment.delete).not.toHaveBeenCalled();
}); });

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