mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
chore: Comprehensive Cache Optimization & Performance Enhancement (#5926)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
414
.cursor/rules/cache-optimization.mdc
Normal file
414
.cursor/rules/cache-optimization.mdc
Normal 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)
|
||||
152
.cursor/rules/eks-alb-optimization.mdc
Normal file
152
.cursor/rules/eks-alb-optimization.mdc
Normal 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
|
||||
@@ -5,6 +5,51 @@ alwaysApply: false
|
||||
---
|
||||
# Testing Patterns & Best Practices
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Test Commands
|
||||
From the **root directory** (formbricks/):
|
||||
- `npm test` - Run all tests across all packages (recommended for CI/full testing)
|
||||
- `npm run test:coverage` - Run all tests with coverage reports
|
||||
- `npm run test:e2e` - Run end-to-end tests with Playwright
|
||||
|
||||
From the **apps/web directory** (apps/web/):
|
||||
- `npm run test` - Run only web app tests (fastest for development)
|
||||
- `npm run test:coverage` - Run web app tests with coverage
|
||||
- `npm run test -- <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
|
||||
|
||||
### File Extensions
|
||||
|
||||
@@ -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", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use server";
|
||||
|
||||
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 { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
@@ -11,38 +9,31 @@ import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const getTeamsByOrganizationId = reactCache(
|
||||
async (organizationId: string): Promise<TOrganizationTeam[] | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([organizationId, ZId]);
|
||||
try {
|
||||
const teams = await prisma.team.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
async (organizationId: string): Promise<TOrganizationTeam[] | null> => {
|
||||
validateInputs([organizationId, ZId]);
|
||||
try {
|
||||
const teams = await prisma.team.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const projectTeams = teams.map((team) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
}));
|
||||
const projectTeams = teams.map((team) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
}));
|
||||
|
||||
return projectTeams;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getTeamsByOrganizationId-${organizationId}`],
|
||||
{
|
||||
tags: [teamCache.tag.byOrganizationId(organizationId)],
|
||||
return projectTeams;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
)()
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { getSurveysByActionClassId } from "@/lib/survey/service";
|
||||
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
@@ -104,31 +103,24 @@ export const getActiveInactiveSurveysAction = authenticatedActionClient
|
||||
return response;
|
||||
});
|
||||
|
||||
const getLatestStableFbRelease = async (): Promise<string | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
try {
|
||||
const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases");
|
||||
const releases = await res.json();
|
||||
const getLatestStableFbRelease = async (): Promise<string | null> => {
|
||||
try {
|
||||
const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases");
|
||||
const releases = await res.json();
|
||||
|
||||
if (Array.isArray(releases)) {
|
||||
const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0]
|
||||
?.tag_name as string;
|
||||
if (latestStableReleaseTag) {
|
||||
return latestStableReleaseTag;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
if (Array.isArray(releases)) {
|
||||
const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0]
|
||||
?.tag_name as string;
|
||||
if (latestStableReleaseTag) {
|
||||
return latestStableReleaseTag;
|
||||
}
|
||||
},
|
||||
["latest-fb-release"],
|
||||
{
|
||||
revalidate: 60 * 60 * 24, // 24 hours
|
||||
}
|
||||
)();
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getLatestStableFbReleaseAction = actionClient.action(async () => {
|
||||
return await getLatestStableFbRelease();
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { selectSurvey } from "@/lib/survey/service";
|
||||
import { transformPrismaSurvey } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
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 { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
@@ -12,14 +10,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getSurveys } from "./surveys";
|
||||
|
||||
// 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", () => ({
|
||||
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";
|
||||
// Ensure mockPrismaSurveys includes all fields used in selectSurvey mock
|
||||
// Use 'as any' to bypass complex type matching for mock data
|
||||
const mockPrismaSurveys = [
|
||||
{ id: "survey1", name: "Survey 1", status: "inProgress", updatedAt: new Date() },
|
||||
{ id: "survey2", name: "Survey 2", status: "draft", updatedAt: new Date() },
|
||||
];
|
||||
] as any; // Use 'as any' to bypass complex type matching
|
||||
const mockTransformedSurveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
@@ -99,14 +89,8 @@ const mockTransformedSurveys: TSurvey[] = [
|
||||
];
|
||||
|
||||
describe("getSurveys", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
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) => {
|
||||
const found = mockTransformedSurveys.find((ts) => ts.id === survey.id);
|
||||
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).toHaveBeenCalledWith(mockPrismaSurveys[0]);
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[1]);
|
||||
// Check if the inner cache function was called with the correct arguments
|
||||
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
|
||||
// React cache is already mocked globally - no need to check it here
|
||||
});
|
||||
|
||||
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("Test error", {
|
||||
code: "P2025",
|
||||
clientVersion: "5.0.0",
|
||||
meta: {}, // Added meta property
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database connection error", {
|
||||
code: "P2002",
|
||||
clientVersion: "4.0.0",
|
||||
});
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys");
|
||||
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 () => {
|
||||
// No need to mock cache here again as beforeEach handles it
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError);
|
||||
const genericError = new Error("Some other error");
|
||||
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(genericError);
|
||||
|
||||
await expect(getSurveys(environmentId)).rejects.toThrow(genericError);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
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 { cache } from "@/lib/cache";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { selectSurvey } from "@/lib/survey/service";
|
||||
import { transformPrismaSurvey } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
@@ -12,38 +10,29 @@ import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const getSurveys = reactCache(
|
||||
async (environmentId: string): Promise<TSurvey[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
export const getSurveys = reactCache(async (environmentId: string): Promise<TSurvey[]> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
|
||||
try {
|
||||
const surveysPrisma = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
status: {
|
||||
not: "completed",
|
||||
},
|
||||
},
|
||||
select: selectSurvey,
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error({ error }, "getSurveys: Could not fetch surveys");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const surveysPrisma = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
status: {
|
||||
not: "completed",
|
||||
},
|
||||
},
|
||||
[`getSurveys-${environmentId}`],
|
||||
{
|
||||
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
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 { DatabaseError } from "@formbricks/types/errors";
|
||||
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("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -29,12 +18,6 @@ const environmentId = "test-environment-id";
|
||||
const sourceZapier = "zapier";
|
||||
|
||||
describe("getWebhookCountBySource", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
@@ -56,13 +39,6 @@ describe("getWebhookCountBySource", () => {
|
||||
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 () => {
|
||||
@@ -82,13 +58,6 @@ describe("getWebhookCountBySource", () => {
|
||||
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 () => {
|
||||
@@ -100,7 +69,6 @@ describe("getWebhookCountBySource", () => {
|
||||
|
||||
await expect(getWebhookCountBySource(environmentId, sourceZapier)).rejects.toThrow(DatabaseError);
|
||||
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should throw original error on other errors", async () => {
|
||||
@@ -109,6 +77,5 @@ describe("getWebhookCountBySource", () => {
|
||||
|
||||
await expect(getWebhookCountBySource(environmentId)).rejects.toThrow(genericError);
|
||||
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 { Prisma, Webhook } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
@@ -7,29 +5,25 @@ import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const getWebhookCountBySource = (environmentId: string, source?: Webhook["source"]): Promise<number> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [source, z.string().optional()]);
|
||||
export const getWebhookCountBySource = async (
|
||||
environmentId: string,
|
||||
source?: Webhook["source"]
|
||||
): Promise<number> => {
|
||||
validateInputs([environmentId, ZId], [source, z.string().optional()]);
|
||||
|
||||
try {
|
||||
const count = await prisma.webhook.count({
|
||||
where: {
|
||||
environmentId,
|
||||
source,
|
||||
},
|
||||
});
|
||||
return count;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getWebhookCountBySource-${environmentId}-${source}`],
|
||||
{
|
||||
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, source)],
|
||||
try {
|
||||
const count = await prisma.webhook.count({
|
||||
where: {
|
||||
environmentId,
|
||||
source,
|
||||
},
|
||||
});
|
||||
return count;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
)();
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,16 +4,6 @@ import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
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", () => ({
|
||||
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 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 { User } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
@@ -7,28 +5,21 @@ import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const getUserById = reactCache(
|
||||
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> =>
|
||||
cache(
|
||||
async () => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", userId);
|
||||
}
|
||||
return user;
|
||||
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
[`getUserById-${userId}`],
|
||||
{
|
||||
tags: [userCache.tag.byId(userId)],
|
||||
}
|
||||
)()
|
||||
select: {
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", userId);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
);
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const getIsEmailUnique = reactCache(
|
||||
async (email: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !user;
|
||||
},
|
||||
[`getIsEmailUnique-${email}`],
|
||||
{
|
||||
tags: [userCache.tag.byEmail(email)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
return !user;
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ export const SummaryPage = ({
|
||||
};
|
||||
|
||||
fetchSummary();
|
||||
}, [selectedFilter, dateRange, survey.id, isSharingPage, sharingKey, surveyId, initialSurveySummary]);
|
||||
}, [selectedFilter, dateRange, survey, isSharingPage, sharingKey, surveyId, initialSurveySummary]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default");
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
@@ -26,23 +25,6 @@ import {
|
||||
// Ensure this path is correct
|
||||
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", () => ({
|
||||
getDisplayCountBySurveyId: vi.fn(),
|
||||
}));
|
||||
@@ -162,10 +144,6 @@ describe("getSurveySummaryMeta", () => {
|
||||
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
|
||||
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
|
||||
);
|
||||
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
test("calculates meta correctly", () => {
|
||||
@@ -226,9 +204,6 @@ describe("getSurveySummaryDropOff", () => {
|
||||
requiredQuestionIds: [],
|
||||
calculations: {},
|
||||
});
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
test("calculates dropOff correctly with welcome card disabled", () => {
|
||||
@@ -367,9 +342,7 @@ describe("getQuestionSummary", () => {
|
||||
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
|
||||
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
|
||||
);
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
// React cache is already mocked globally - no need to mock it again
|
||||
});
|
||||
|
||||
test("summarizes OpenText questions", async () => {
|
||||
@@ -746,9 +719,7 @@ describe("getSurveySummary", () => {
|
||||
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
|
||||
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
|
||||
);
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
// React cache is already mocked globally - no need to mock it again
|
||||
});
|
||||
|
||||
test("returns survey summary successfully", async () => {
|
||||
@@ -795,9 +766,7 @@ describe("getResponsesForSummary", () => {
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue(
|
||||
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
|
||||
);
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
// React cache is already mocked globally - no need to mock it again
|
||||
});
|
||||
|
||||
test("fetches and transforms responses", async () => {
|
||||
@@ -840,6 +809,16 @@ describe("getResponsesForSummary", () => {
|
||||
language: "en",
|
||||
ttc: {},
|
||||
finished: true,
|
||||
createdAt: new Date(),
|
||||
meta: {},
|
||||
variables: {},
|
||||
surveyId: "survey-1",
|
||||
contactId: null,
|
||||
personAttributes: {},
|
||||
singleUseId: null,
|
||||
isFinished: true,
|
||||
displayId: "display-1",
|
||||
endingId: null,
|
||||
};
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
||||
@@ -873,6 +852,16 @@ describe("getResponsesForSummary", () => {
|
||||
language: "en",
|
||||
ttc: {},
|
||||
finished: true,
|
||||
createdAt: new Date(),
|
||||
meta: {},
|
||||
variables: {},
|
||||
surveyId: "survey-1",
|
||||
contactId: "contact-1",
|
||||
personAttributes: {},
|
||||
singleUseId: null,
|
||||
isFinished: true,
|
||||
displayId: "display-1",
|
||||
endingId: null,
|
||||
};
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
||||
@@ -901,6 +890,16 @@ describe("getResponsesForSummary", () => {
|
||||
language: "en",
|
||||
ttc: {},
|
||||
finished: true,
|
||||
createdAt: new Date(),
|
||||
meta: {},
|
||||
variables: {},
|
||||
surveyId: "survey-1",
|
||||
contactId: "contact-1",
|
||||
personAttributes: {},
|
||||
singleUseId: null,
|
||||
isFinished: true,
|
||||
displayId: "display-1",
|
||||
endingId: null,
|
||||
};
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { displayCache } from "@/lib/display/cache";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { buildWhereClause } from "@/lib/response/utils";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
@@ -905,68 +901,57 @@ export const getQuestionSummary = async (
|
||||
};
|
||||
|
||||
export const getSurveySummary = reactCache(
|
||||
async (surveyId: string, filterCriteria?: TResponseFilterCriteria): Promise<TSurveySummary> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]);
|
||||
async (surveyId: string, filterCriteria?: TResponseFilterCriteria): Promise<TSurveySummary> => {
|
||||
validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]);
|
||||
|
||||
try {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
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),
|
||||
],
|
||||
try {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getResponsesForSummary = reactCache(
|
||||
@@ -976,94 +961,85 @@ export const getResponsesForSummary = reactCache(
|
||||
offset: number,
|
||||
filterCriteria?: TResponseFilterCriteria,
|
||||
cursor?: string
|
||||
): Promise<TSurveySummaryResponse[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs(
|
||||
[surveyId, ZId],
|
||||
[limit, ZOptionalNumber],
|
||||
[offset, ZOptionalNumber],
|
||||
[filterCriteria, ZResponseFilterCriteria.optional()],
|
||||
[cursor, z.string().cuid2().optional()]
|
||||
);
|
||||
): Promise<TSurveySummaryResponse[]> => {
|
||||
validateInputs(
|
||||
[surveyId, ZId],
|
||||
[limit, ZOptionalNumber],
|
||||
[offset, ZOptionalNumber],
|
||||
[filterCriteria, ZResponseFilterCriteria.optional()],
|
||||
[cursor, z.string().cuid2().optional()]
|
||||
);
|
||||
|
||||
const queryLimit = limit ?? RESPONSES_PER_PAGE;
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) return [];
|
||||
try {
|
||||
const whereClause: Prisma.ResponseWhereInput = {
|
||||
surveyId,
|
||||
...buildWhereClause(survey, filterCriteria),
|
||||
};
|
||||
const queryLimit = limit ?? RESPONSES_PER_PAGE;
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) return [];
|
||||
try {
|
||||
const whereClause: Prisma.ResponseWhereInput = {
|
||||
surveyId,
|
||||
...buildWhereClause(survey, filterCriteria),
|
||||
};
|
||||
|
||||
// Add cursor condition for cursor-based pagination
|
||||
if (cursor) {
|
||||
whereClause.id = {
|
||||
lt: cursor, // Get responses with ID less than cursor (for desc order)
|
||||
};
|
||||
}
|
||||
// Add cursor condition for cursor-based pagination
|
||||
if (cursor) {
|
||||
whereClause.id = {
|
||||
lt: cursor, // Get responses with ID less than cursor (for desc order)
|
||||
};
|
||||
}
|
||||
|
||||
const responses = await prisma.response.findMany({
|
||||
where: whereClause,
|
||||
const responses = await prisma.response.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
data: true,
|
||||
updatedAt: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
data: true,
|
||||
updatedAt: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: { attributeKey: true, value: true },
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
select: { attributeKey: true, value: true },
|
||||
},
|
||||
contactAttributes: true,
|
||||
language: true,
|
||||
ttc: true,
|
||||
finished: true,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
{
|
||||
id: "desc", // Secondary sort by ID for consistent pagination
|
||||
},
|
||||
],
|
||||
take: queryLimit,
|
||||
skip: offset,
|
||||
});
|
||||
},
|
||||
contactAttributes: true,
|
||||
language: true,
|
||||
ttc: true,
|
||||
finished: true,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
{
|
||||
id: "desc", // Secondary sort by ID for consistent pagination
|
||||
},
|
||||
],
|
||||
take: queryLimit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
|
||||
responses.map((responsePrisma) => {
|
||||
return {
|
||||
...responsePrisma,
|
||||
contact: responsePrisma.contact
|
||||
? {
|
||||
id: responsePrisma.contact.id as string,
|
||||
userId: responsePrisma.contact.attributes.find(
|
||||
(attribute) => attribute.attributeKey.key === "userId"
|
||||
)?.value as string,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
|
||||
responses.map((responsePrisma) => {
|
||||
return {
|
||||
...responsePrisma,
|
||||
contact: responsePrisma.contact
|
||||
? {
|
||||
id: responsePrisma.contact.id as string,
|
||||
userId: responsePrisma.contact.attributes.find(
|
||||
(attribute) => attribute.attributeKey.key === "userId"
|
||||
)?.value as string,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return transformedResponses;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[
|
||||
`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}-${cursor || ""}`,
|
||||
],
|
||||
{
|
||||
tags: [responseCache.tag.bySurveyId(surveyId)],
|
||||
return transformedResponses;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
)()
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -28,6 +29,7 @@ export const SurveyStatusDropdown = ({
|
||||
survey,
|
||||
}: SurveyStatusDropdownProps) => {
|
||||
const { t } = useTranslate();
|
||||
const router = useRouter();
|
||||
const isCloseOnDateEnabled = survey.closeOnDate !== null;
|
||||
const closeOnDate = survey.closeOnDate ? new Date(survey.closeOnDate) : null;
|
||||
const isStatusChangeDisabled =
|
||||
@@ -47,6 +49,8 @@ export const SurveyStatusDropdown = ({
|
||||
? t("common.survey_completed")
|
||||
: ""
|
||||
);
|
||||
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
|
||||
toast.error(errorMessage);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
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 { getIntegrations } from "@/lib/integration/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
@@ -51,22 +49,17 @@ export const POST = async (request: Request) => {
|
||||
}
|
||||
|
||||
// Fetch webhooks
|
||||
const getWebhooksForPipeline = cache(
|
||||
async (environmentId: string, event: PipelineTriggers, surveyId: string) => {
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
triggers: { has: event },
|
||||
OR: [{ surveyIds: { has: surveyId } }, { surveyIds: { isEmpty: true } }],
|
||||
},
|
||||
});
|
||||
return webhooks;
|
||||
},
|
||||
[`getWebhooksForPipeline-${environmentId}-${event}-${surveyId}`],
|
||||
{
|
||||
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
);
|
||||
const getWebhooksForPipeline = async (environmentId: string, event: PipelineTriggers, surveyId: string) => {
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
triggers: { has: event },
|
||||
OR: [{ surveyIds: { has: surveyId } }, { surveyIds: { isEmpty: true } }],
|
||||
},
|
||||
});
|
||||
return webhooks;
|
||||
};
|
||||
|
||||
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
|
||||
// Prepare webhook and email promises
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { CRON_SECRET } from "@/lib/constants";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { headers } from "next/headers";
|
||||
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({
|
||||
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 { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getActionClasses } from "@/lib/actionClass/service";
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnvironment, updateEnvironment } from "@/lib/environment/service";
|
||||
import {
|
||||
@@ -133,14 +132,6 @@ export const GET = async (
|
||||
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) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
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 { 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 userId = "test-user-id";
|
||||
const contactId = "test-contact-id";
|
||||
@@ -37,12 +27,6 @@ const contactMock: Partial<TContact> & {
|
||||
};
|
||||
|
||||
describe("getContactByUserId", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export const getContactByUserId = reactCache(
|
||||
(
|
||||
async (
|
||||
environmentId: string,
|
||||
userId: string
|
||||
): Promise<{
|
||||
@@ -16,36 +14,29 @@ export const getContactByUserId = reactCache(
|
||||
};
|
||||
}[];
|
||||
id: string;
|
||||
} | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
} | null> => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return contact;
|
||||
},
|
||||
},
|
||||
[`getContactByUserId-sync-api-${environmentId}-${userId}`],
|
||||
{
|
||||
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
|
||||
}
|
||||
)()
|
||||
select: {
|
||||
id: true,
|
||||
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 { getSurveys } from "@/lib/survey/service";
|
||||
import { anySurveyHasFilters } from "@/lib/survey/utils";
|
||||
@@ -14,15 +13,6 @@ import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
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", () => ({
|
||||
getProjectByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
@@ -120,9 +110,6 @@ const baseSurvey: TSurvey = {
|
||||
|
||||
describe("getSyncSurveys", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
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 { surveyCache } from "@/lib/survey/cache";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { anySurveyHasFilters } from "@/lib/survey/utils";
|
||||
import { diffInDays } from "@/lib/utils/datetime";
|
||||
@@ -20,154 +14,135 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const getSyncSurveys = reactCache(
|
||||
(
|
||||
async (
|
||||
environmentId: string,
|
||||
contactId: string,
|
||||
contactAttributes: Record<string, string | number>,
|
||||
deviceType: "phone" | "desktop" = "desktop"
|
||||
): Promise<TSurvey[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
try {
|
||||
const product = await getProjectByEnvironmentId(environmentId);
|
||||
): Promise<TSurvey[]> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
try {
|
||||
const product = await getProjectByEnvironmentId(environmentId);
|
||||
|
||||
if (!product) {
|
||||
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),
|
||||
],
|
||||
if (!product) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,41 +1,32 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export const getContactByUserId = reactCache(
|
||||
(
|
||||
async (
|
||||
environmentId: string,
|
||||
userId: string
|
||||
): Promise<{
|
||||
id: string;
|
||||
} | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
} | null> => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return contact;
|
||||
},
|
||||
},
|
||||
[`getContactByUserIdForDisplaysApi-${environmentId}-${userId}`],
|
||||
{
|
||||
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
|
||||
}
|
||||
)()
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { displayCache } from "@/lib/display/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -51,14 +50,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
|
||||
select: { id: true, contactId: true, surveyId: true },
|
||||
});
|
||||
|
||||
displayCache.revalidate({
|
||||
id: display.id,
|
||||
contactId: display.contactId,
|
||||
surveyId: display.surveyId,
|
||||
userId,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
return display;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -14,7 +14,13 @@ interface Context {
|
||||
}
|
||||
|
||||
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> => {
|
||||
|
||||
@@ -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 { getEnvironment } from "@/lib/environment/service";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@/lib/posthogServer";
|
||||
import { withCache } from "@/modules/cache/lib/withCache";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
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 { TProject } from "@formbricks/types/project";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getActionClassesForEnvironmentState } from "./actionClass";
|
||||
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
||||
import { getEnvironmentState } from "./environmentState";
|
||||
import { getProjectForEnvironmentState } from "./project";
|
||||
import { getSurveysForEnvironmentState } from "./survey";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache");
|
||||
vi.mock("@/lib/environment/service");
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/posthogServer");
|
||||
vi.mock("@/modules/ee/license-check/lib/utils");
|
||||
vi.mock("@/modules/cache/lib/withCache");
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
environment: {
|
||||
@@ -41,11 +32,9 @@ vi.mock("@formbricks/logger", () => ({
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("./actionClass");
|
||||
vi.mock("./project");
|
||||
vi.mock("./survey");
|
||||
vi.mock("./data");
|
||||
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_SECRET_KEY: "mock_recaptcha_secret_key",
|
||||
IS_RECAPTCHA_CONFIGURED: true,
|
||||
@@ -56,13 +45,16 @@ vi.mock("@/lib/constants", () => ({
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: environmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "test-project-id",
|
||||
type: "production",
|
||||
appSetupCompleted: true, // Default to true
|
||||
const mockProject: TJsEnvironmentStateProject = {
|
||||
id: "test-project-id",
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
styling: {
|
||||
allowStyleOverwrite: false,
|
||||
},
|
||||
};
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
@@ -77,7 +69,7 @@ const mockOrganization: TOrganization = {
|
||||
limits: {
|
||||
projects: 1,
|
||||
monthly: {
|
||||
responses: 100, // Default limit
|
||||
responses: 100,
|
||||
miu: 1000,
|
||||
},
|
||||
},
|
||||
@@ -86,29 +78,6 @@ const mockOrganization: TOrganization = {
|
||||
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[] = [
|
||||
{
|
||||
id: "survey-app-inProgress",
|
||||
@@ -149,84 +118,6 @@ const mockSurveys: TSurvey[] = [
|
||||
createdBy: null,
|
||||
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[] = [
|
||||
@@ -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", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Mock the cache implementation
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
|
||||
// Mock withCache to simply execute the function without caching for tests
|
||||
vi.mocked(withCache).mockImplementation((fn) => fn);
|
||||
|
||||
// Default mocks for successful retrieval
|
||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
|
||||
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(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit
|
||||
});
|
||||
|
||||
@@ -268,42 +170,45 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
const expectedData: TJsEnvironmentState["data"] = {
|
||||
recaptchaSiteKey: "mock_recaptcha_site_key",
|
||||
surveys: [mockSurveys[0]], // Only app, inProgress survey
|
||||
surveys: mockSurveys,
|
||||
actionClasses: mockActionClasses,
|
||||
project: mockProject,
|
||||
};
|
||||
|
||||
expect(result.data).toEqual(expectedData);
|
||||
expect(result.revalidateEnvironment).toBe(false);
|
||||
expect(getEnvironment).toHaveBeenCalledWith(environmentId);
|
||||
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
|
||||
expect(getProjectForEnvironmentState).toHaveBeenCalledWith(environmentId);
|
||||
expect(getSurveysForEnvironmentState).toHaveBeenCalledWith(environmentId);
|
||||
expect(getActionClassesForEnvironmentState).toHaveBeenCalledWith(environmentId);
|
||||
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
|
||||
expect(prisma.environment.update).not.toHaveBeenCalled();
|
||||
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalled(); // Not cloud
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test("should update environment and capture event if app setup not completed", async () => {
|
||||
const incompleteEnv = { ...mockEnvironment, appSetupCompleted: false };
|
||||
vi.mocked(getEnvironment).mockResolvedValue(incompleteEnv);
|
||||
const incompleteEnvironmentData = {
|
||||
...mockEnvironmentStateData,
|
||||
environment: {
|
||||
...mockEnvironmentStateData.environment,
|
||||
appSetupCompleted: false,
|
||||
},
|
||||
};
|
||||
vi.mocked(getEnvironmentStateData).mockResolvedValue(incompleteEnvironmentData);
|
||||
|
||||
const result = await getEnvironmentState(environmentId);
|
||||
|
||||
@@ -312,14 +217,14 @@ describe("getEnvironmentState", () => {
|
||||
data: { appSetupCompleted: true },
|
||||
});
|
||||
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 () => {
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100); // Exactly at limit
|
||||
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue(mockSurveys);
|
||||
|
||||
const result = await getEnvironmentState(environmentId);
|
||||
|
||||
expect(result.data.surveys).toEqual([]);
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
||||
@@ -339,7 +244,7 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
const result = await getEnvironmentState(environmentId);
|
||||
|
||||
expect(result.data.surveys).toEqual([mockSurveys[0]]);
|
||||
expect(result.data.surveys).toEqual(mockSurveys);
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -364,9 +269,12 @@ describe("getEnvironmentState", () => {
|
||||
expect(result.data.recaptchaSiteKey).toBe("mock_recaptcha_site_key");
|
||||
});
|
||||
|
||||
test("should filter surveys correctly (only app type and inProgress status)", async () => {
|
||||
const result = await getEnvironmentState(environmentId);
|
||||
expect(result.data.surveys).toHaveLength(1);
|
||||
expect(result.data.surveys[0].id).toBe("survey-app-inProgress");
|
||||
test("should use withCache for caching with correct cache key and TTL", () => {
|
||||
getEnvironmentState(environmentId);
|
||||
|
||||
expect(withCache).toHaveBeenCalledWith(expect.any(Function), {
|
||||
key: `fb:env:${environmentId}:state`,
|
||||
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,125 +1,93 @@
|
||||
import { actionClassCache } from "@/lib/actionClass/cache";
|
||||
import { cache } from "@/lib/cache";
|
||||
import "server-only";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
||||
import { environmentCache } from "@/lib/environment/cache";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { organizationCache } from "@/lib/organization/cache";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@/lib/posthogServer";
|
||||
import { projectCache } from "@/lib/project/cache";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { createCacheKey } from "@/modules/cache/lib/cacheKeys";
|
||||
import { withCache } from "@/modules/cache/lib/withCache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TJsEnvironmentState } from "@formbricks/types/js";
|
||||
import { getActionClassesForEnvironmentState } from "./actionClass";
|
||||
import { getProjectForEnvironmentState } from "./project";
|
||||
import { getSurveysForEnvironmentState } from "./survey";
|
||||
import { getEnvironmentStateData } from "./data";
|
||||
|
||||
/**
|
||||
* Optimized environment state fetcher using new caching approach
|
||||
* Uses withCache for Redis-backed caching with graceful fallback
|
||||
* Single database query via optimized data service
|
||||
*
|
||||
* @param environmentId
|
||||
* @param environmentId - The environment ID to fetch state for
|
||||
* @returns The environment state
|
||||
* @throws ResourceNotFoundError if the environment or organization does not exist
|
||||
* @throws ResourceNotFoundError if environment, organization, or project not found
|
||||
*/
|
||||
export const getEnvironmentState = async (
|
||||
environmentId: string
|
||||
): Promise<{ data: TJsEnvironmentState["data"]; revalidateEnvironment?: boolean }> =>
|
||||
cache(
|
||||
): Promise<{ data: TJsEnvironmentState["data"] }> => {
|
||||
// Use withCache for efficient Redis caching with automatic fallback
|
||||
const getCachedEnvironmentState = withCache(
|
||||
async () => {
|
||||
let revalidateEnvironment = false;
|
||||
const [environment, organization, project] = await Promise.all([
|
||||
getEnvironment(environmentId),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
getProjectForEnvironmentState(environmentId),
|
||||
]);
|
||||
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError("environment", environmentId);
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("organization", null);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new ResourceNotFoundError("project", null);
|
||||
}
|
||||
// Single optimized database call replacing multiple service calls
|
||||
const { environment, organization, surveys, actionClasses } =
|
||||
await getEnvironmentStateData(environmentId);
|
||||
|
||||
// Handle app setup completion update if needed
|
||||
// This is a one-time setup flag that can tolerate TTL-based cache expiration
|
||||
if (!environment.appSetupCompleted) {
|
||||
await Promise.all([
|
||||
prisma.environment.update({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
where: { id: environmentId },
|
||||
data: { appSetupCompleted: true },
|
||||
}),
|
||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
||||
]);
|
||||
|
||||
revalidateEnvironment = true;
|
||||
}
|
||||
|
||||
// check if MAU limit is reached
|
||||
// Check monthly response limits for Formbricks Cloud
|
||||
let isMonthlyResponsesLimitReached = false;
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
isMonthlyResponsesLimitReached =
|
||||
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
}
|
||||
|
||||
if (isMonthlyResponsesLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
miu: null,
|
||||
responses: organization.billing.limits.monthly.responses,
|
||||
// Send plan limits event if needed
|
||||
if (isMonthlyResponsesLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
miu: null,
|
||||
responses: organization.billing.limits.monthly.responses,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [surveys, actionClasses] = await Promise.all([
|
||||
getSurveysForEnvironmentState(environmentId),
|
||||
getActionClassesForEnvironmentState(environmentId),
|
||||
]);
|
||||
|
||||
// Build the response data
|
||||
const data: TJsEnvironmentState["data"] = {
|
||||
surveys: !isMonthlyResponsesLimitReached ? surveys : [],
|
||||
actionClasses,
|
||||
project: project,
|
||||
project: environment.project,
|
||||
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
revalidateEnvironment,
|
||||
};
|
||||
return { data };
|
||||
},
|
||||
[`environmentState-${environmentId}`],
|
||||
{
|
||||
...(IS_FORMBRICKS_CLOUD && { revalidate: 24 * 60 * 60 }),
|
||||
tags: [
|
||||
environmentCache.tag.byId(environmentId),
|
||||
organizationCache.tag.byEnvironmentId(environmentId),
|
||||
projectCache.tag.byEnvironmentId(environmentId),
|
||||
surveyCache.tag.byEnvironmentId(environmentId),
|
||||
actionClassCache.tag.byEnvironmentId(environmentId),
|
||||
],
|
||||
// Use enterprise-grade cache key pattern
|
||||
key: createCacheKey.environment.state(environmentId),
|
||||
// 30 minutes TTL ensures fresh data for hourly SDK checks
|
||||
// Balances performance with freshness requirements
|
||||
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
|
||||
}
|
||||
)();
|
||||
);
|
||||
|
||||
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 { 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 { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZJsSyncInput } from "@formbricks/types/js";
|
||||
|
||||
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 (
|
||||
@@ -22,51 +27,49 @@ export const GET = async (
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
// validate using zod
|
||||
const inputValidation = ZJsSyncInput.safeParse({
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
|
||||
if (!params.environmentId || typeof params.environmentId !== "string") {
|
||||
return responses.badRequestResponse("Environment ID is required", undefined, true);
|
||||
}
|
||||
|
||||
try {
|
||||
const environmentState = await getEnvironmentState(params.environmentId);
|
||||
const { data, revalidateEnvironment } = environmentState;
|
||||
// Use optimized environment state fetcher with new caching approach
|
||||
const environmentState = await getEnvironmentState(params.environmentId);
|
||||
const { data } = environmentState;
|
||||
|
||||
if (revalidateEnvironment) {
|
||||
environmentCache.revalidate({
|
||||
id: inputValidation.data.environmentId,
|
||||
projectId: data.project.id,
|
||||
});
|
||||
}
|
||||
|
||||
return responses.successResponse(
|
||||
return responses.successResponse(
|
||||
{
|
||||
data,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
|
||||
},
|
||||
true,
|
||||
// Optimized cache headers for Cloudflare CDN and browser caching
|
||||
// max-age=3600: 1hr browser cache (per guidelines)
|
||||
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
|
||||
// stale-while-revalidate=1800: 30min stale serving during revalidation
|
||||
// stale-if-error=3600: 1hr stale serving on origin errors
|
||||
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
logger.warn(
|
||||
{
|
||||
data,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes
|
||||
environmentId: params.environmentId,
|
||||
resourceType: err.resourceType,
|
||||
resourceId: err.resourceId,
|
||||
},
|
||||
true,
|
||||
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
|
||||
"Resource not found in environment endpoint"
|
||||
);
|
||||
} catch (err) {
|
||||
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);
|
||||
return responses.notFoundResponse(err.resourceType, err.resourceId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error, url: request.url }, "Error in GET /api/v1/client/[environmentId]/environment");
|
||||
return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true);
|
||||
|
||||
logger.error(
|
||||
{
|
||||
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 { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getContact, getContactByUserId } from "./contact";
|
||||
@@ -15,9 +14,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock cache module
|
||||
vi.mock("@/lib/cache");
|
||||
|
||||
// Mock react cache
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
@@ -32,12 +28,6 @@ const mockEnvironmentId = "test-env-id";
|
||||
const mockUserId = "test-user-id";
|
||||
|
||||
describe("Contact API Lib", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
@@ -1,84 +1,67 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const getContact = reactCache((contactId: string) =>
|
||||
cache(
|
||||
async () => {
|
||||
try {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
select: { id: true },
|
||||
});
|
||||
export const getContact = reactCache(async (contactId: string) => {
|
||||
try {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return contact;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
[`getContact-responses-api-${contactId}`],
|
||||
{
|
||||
tags: [contactCache.tag.byId(contactId)],
|
||||
return contact;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
)()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export const getContactByUserId = reactCache(
|
||||
(
|
||||
async (
|
||||
environmentId: string,
|
||||
userId: string
|
||||
): Promise<{
|
||||
id: string;
|
||||
attributes: TContactAttributes;
|
||||
} | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
} | null> => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
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}`],
|
||||
{
|
||||
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, 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,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -29,22 +29,10 @@ vi.mock("@/lib/posthogServer", () => ({
|
||||
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/response/cache", () => ({
|
||||
responseCache: {
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/response/utils", () => ({
|
||||
calculateTtcTotal: vi.fn((ttc) => ttc),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/responseNote/cache", () => ({
|
||||
responseNoteCache: {
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/telemetry", () => ({
|
||||
captureTelemetry: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,9 +5,7 @@ import {
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { responseNoteCache } from "@/lib/responseNote/cache";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
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),
|
||||
};
|
||||
|
||||
responseCache.revalidate({
|
||||
environmentId,
|
||||
id: response.id,
|
||||
contactId: contact?.id,
|
||||
...(singleUseId && { singleUseId }),
|
||||
userId: userId ?? undefined,
|
||||
surveyId,
|
||||
});
|
||||
|
||||
responseNoteCache.revalidate({
|
||||
responseId: response.id,
|
||||
});
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const responsesLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
@@ -20,7 +20,13 @@ interface Context {
|
||||
}
|
||||
|
||||
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> => {
|
||||
|
||||
@@ -19,15 +19,12 @@ interface Context {
|
||||
}
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return Response.json(
|
||||
return responses.successResponse(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
}
|
||||
true,
|
||||
// Cache CORS preflight responses for 1 hour (conservative approach)
|
||||
// Balances performance gains with flexibility for CORS policy changes
|
||||
"public, s-maxage=3600, max-age=3600"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,13 @@ interface Context {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { actionClassCache } from "@/lib/actionClass/cache";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
@@ -23,29 +21,20 @@ const selectActionClass = {
|
||||
environmentId: true,
|
||||
} satisfies Prisma.ActionClassSelect;
|
||||
|
||||
export const getActionClasses = reactCache(
|
||||
async (environmentIds: string[]): Promise<TActionClass[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentIds, ZId.array()]);
|
||||
export const getActionClasses = reactCache(async (environmentIds: string[]): Promise<TActionClass[]> => {
|
||||
validateInputs([environmentIds, ZId.array()]);
|
||||
|
||||
try {
|
||||
return await prisma.actionClass.findMany({
|
||||
where: {
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
select: selectActionClass,
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseError(`Database error when fetching actions for environment ${environmentIds}`);
|
||||
}
|
||||
try {
|
||||
return await prisma.actionClass.findMany({
|
||||
where: {
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
environmentIds.map((environmentId) => `getActionClasses-management-api-${environmentId}`),
|
||||
{
|
||||
tags: environmentIds.map((environmentId) => actionClassCache.tag.byEnvironmentId(environmentId)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
select: selectActionClass,
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseError(`Database error when fetching actions for environment ${environmentIds}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { getContactByUserId } from "./contact";
|
||||
@@ -14,8 +12,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache");
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const userId = "test-user-id";
|
||||
const contactId = "test-contact-id";
|
||||
@@ -36,12 +32,6 @@ const expectedContactAttributes: TContactAttributes = {
|
||||
};
|
||||
|
||||
describe("getContactByUserId", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
test("should return contact with attributes when found", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactDbData);
|
||||
|
||||
@@ -73,13 +63,6 @@ describe("getContactByUserId", () => {
|
||||
id: contactId,
|
||||
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 () => {
|
||||
@@ -110,12 +93,5 @@ describe("getContactByUserId", () => {
|
||||
},
|
||||
});
|
||||
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 { cache } from "@/lib/cache";
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
|
||||
export const getContactByUserId = reactCache(
|
||||
(
|
||||
async (
|
||||
environmentId: string,
|
||||
userId: string
|
||||
): Promise<{
|
||||
id: string;
|
||||
attributes: TContactAttributes;
|
||||
} | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
} | null> => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
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}`],
|
||||
{
|
||||
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, 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,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { getResponseContact } from "@/lib/response/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { responseNoteCache } from "@/lib/responseNote/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Organization, Prisma, Response as ResponsePrisma } from "@prisma/client";
|
||||
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" }];
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache");
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
@@ -125,10 +121,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/posthogServer");
|
||||
vi.mock("@/lib/response/cache");
|
||||
vi.mock("@/lib/response/service");
|
||||
vi.mock("@/lib/response/utils");
|
||||
vi.mock("@/lib/responseNote/cache");
|
||||
vi.mock("@/lib/telemetry");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -145,10 +139,6 @@ vi.mock("./contact");
|
||||
describe("Response Lib Tests", () => {
|
||||
beforeEach(() => {
|
||||
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", () => {
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -296,7 +279,6 @@ describe("Response Lib Tests", () => {
|
||||
);
|
||||
expect(getResponseContact).toHaveBeenCalledTimes(mockResponsesPrisma.length);
|
||||
expect(responses).toEqual(mockTransformedResponses);
|
||||
expect(cache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return responses with limit and offset", async () => {
|
||||
@@ -311,7 +293,6 @@ describe("Response Lib Tests", () => {
|
||||
skip: mockOffset,
|
||||
})
|
||||
);
|
||||
expect(cache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return empty array if no responses found", async () => {
|
||||
@@ -322,7 +303,6 @@ describe("Response Lib Tests", () => {
|
||||
expect(responses).toEqual([]);
|
||||
expect(prisma.response.findMany).toHaveBeenCalled();
|
||||
expect(getResponseContact).not.toHaveBeenCalled();
|
||||
expect(cache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle PrismaClientKnownRequestError", async () => {
|
||||
@@ -333,7 +313,6 @@ describe("Response Lib Tests", () => {
|
||||
vi.mocked(prisma.response.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(DatabaseError);
|
||||
expect(cache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle generic errors", async () => {
|
||||
@@ -341,7 +320,6 @@ describe("Response Lib Tests", () => {
|
||||
vi.mocked(prisma.response.findMany).mockRejectedValue(genericError);
|
||||
|
||||
await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(genericError);
|
||||
expect(cache).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { getResponseContact } from "@/lib/response/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { responseNoteCache } from "@/lib/responseNote/cache";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
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),
|
||||
};
|
||||
|
||||
responseCache.revalidate({
|
||||
environmentId,
|
||||
id: response.id,
|
||||
contactId: contact?.id,
|
||||
...(singleUseId && { singleUseId }),
|
||||
userId: userId ?? undefined,
|
||||
surveyId,
|
||||
});
|
||||
|
||||
responseNoteCache.revalidate({
|
||||
responseId: response.id,
|
||||
});
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const responsesLimit = organization.billing.limits.monthly.responses;
|
||||
@@ -200,51 +184,42 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
||||
};
|
||||
|
||||
export const getResponsesByEnvironmentIds = reactCache(
|
||||
async (environmentIds: string[], limit?: number, offset?: number): Promise<TResponse[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||
try {
|
||||
const responses = await prisma.response.findMany({
|
||||
where: {
|
||||
survey: {
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
},
|
||||
select: responseSelection,
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
});
|
||||
async (environmentIds: string[], limit?: number, offset?: number): Promise<TResponse[]> => {
|
||||
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||
try {
|
||||
const responses = await prisma.response.findMany({
|
||||
where: {
|
||||
survey: {
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
},
|
||||
select: responseSelection,
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
});
|
||||
|
||||
const transformedResponses: TResponse[] = await Promise.all(
|
||||
responses.map((responsePrisma) => {
|
||||
return {
|
||||
...responsePrisma,
|
||||
contact: getResponseContact(responsePrisma),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
})
|
||||
);
|
||||
const transformedResponses: TResponse[] = await Promise.all(
|
||||
responses.map((responsePrisma) => {
|
||||
return {
|
||||
...responsePrisma,
|
||||
contact: getResponseContact(responsePrisma),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return transformedResponses;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
environmentIds.map(
|
||||
(environmentId) => `getResponses-management-api-${environmentId}-${limit}-${offset}`
|
||||
),
|
||||
{
|
||||
tags: environmentIds.map((environmentId) => responseCache.tag.byEnvironmentId(environmentId)),
|
||||
return transformedResponses;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
)()
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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 { Prisma } from "@prisma/client";
|
||||
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 { 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", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
@@ -91,14 +72,7 @@ describe("deleteSurvey", () => {
|
||||
},
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -112,9 +86,6 @@ describe("deleteSurvey", () => {
|
||||
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
|
||||
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 () => {
|
||||
@@ -128,7 +99,6 @@ describe("deleteSurvey", () => {
|
||||
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
|
||||
expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } });
|
||||
// Caches might have been partially revalidated before the error
|
||||
});
|
||||
|
||||
test("should handle generic errors during deletion", async () => {
|
||||
@@ -136,7 +106,7 @@ describe("deleteSurvey", () => {
|
||||
vi.mocked(prisma.survey.delete).mockRejectedValue(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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
@@ -27,44 +24,13 @@ export const deleteSurvey = async (surveyId: string) => {
|
||||
});
|
||||
|
||||
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
|
||||
const deletedSegment = await prisma.segment.delete({
|
||||
await prisma.segment.delete({
|
||||
where: {
|
||||
id: deletedSurvey.segment.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (deletedSegment) {
|
||||
segmentCache.revalidate({
|
||||
id: deletedSegment.id,
|
||||
environmentId: deletedSurvey.environmentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
responseCache.revalidate({
|
||||
surveyId,
|
||||
environmentId: deletedSurvey.environmentId,
|
||||
});
|
||||
surveyCache.revalidate({
|
||||
id: deletedSurvey.id,
|
||||
environmentId: deletedSurvey.environmentId,
|
||||
resultShareKey: deletedSurvey.resultShareKey ?? undefined,
|
||||
});
|
||||
|
||||
if (deletedSurvey.segment?.id) {
|
||||
segmentCache.revalidate({
|
||||
id: deletedSurvey.segment.id,
|
||||
environmentId: deletedSurvey.environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
// Revalidate public triggers by actionClassId
|
||||
deletedSurvey.triggers.forEach((trigger) => {
|
||||
surveyCache.revalidate({
|
||||
actionClassId: trigger.actionClass.id,
|
||||
});
|
||||
});
|
||||
|
||||
return deletedSurvey;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { selectSurvey } from "@/lib/survey/service";
|
||||
import { transformPrismaSurvey } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
@@ -11,8 +10,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getSurveys } from "./surveys";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache");
|
||||
vi.mock("@/lib/survey/cache");
|
||||
vi.mock("@/lib/survey/utils");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -75,10 +72,6 @@ const mockSurveyTransformed3: TSurvey = {
|
||||
describe("getSurveys (Management API)", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Mock the cache function to simply execute the underlying function
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
vi.mocked(transformPrismaSurvey).mockImplementation((survey) => ({
|
||||
...survey,
|
||||
displayPercentage: null,
|
||||
@@ -112,7 +105,6 @@ describe("getSurveys (Management API)", () => {
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledTimes(1);
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockSurveyPrisma2);
|
||||
expect(surveys).toEqual([mockSurveyTransformed2]);
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should return surveys for multiple environment IDs without limit and offset", async () => {
|
||||
@@ -138,7 +130,6 @@ describe("getSurveys (Management API)", () => {
|
||||
});
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledTimes(3);
|
||||
expect(surveys).toEqual([mockSurveyTransformed1, mockSurveyTransformed2, mockSurveyTransformed3]);
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should return an empty array if no surveys are found", async () => {
|
||||
@@ -149,7 +140,6 @@ describe("getSurveys (Management API)", () => {
|
||||
expect(prisma.survey.findMany).toHaveBeenCalled();
|
||||
expect(transformPrismaSurvey).not.toHaveBeenCalled();
|
||||
expect(surveys).toEqual([]);
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should handle PrismaClientKnownRequestError", async () => {
|
||||
@@ -161,7 +151,6 @@ describe("getSurveys (Management API)", () => {
|
||||
|
||||
await expect(getSurveys([environmentId1])).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys");
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should handle generic errors", async () => {
|
||||
@@ -170,7 +159,6 @@ describe("getSurveys (Management API)", () => {
|
||||
|
||||
await expect(getSurveys([environmentId1])).rejects.toThrow(genericError);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should throw validation error for invalid input", async () => {
|
||||
@@ -182,6 +170,5 @@ describe("getSurveys (Management API)", () => {
|
||||
|
||||
await expect(getSurveys([invalidEnvId])).rejects.toThrow(validationError);
|
||||
expect(prisma.survey.findMany).not.toHaveBeenCalled();
|
||||
expect(cache).toHaveBeenCalledTimes(1); // Cache wrapper is still called
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { selectSurvey } from "@/lib/survey/service";
|
||||
import { transformPrismaSurvey } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
@@ -8,41 +6,33 @@ import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const getSurveys = reactCache(
|
||||
async (environmentIds: string[], limit?: number, offset?: number): Promise<TSurvey[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||
async (environmentIds: string[], limit?: number, offset?: number): Promise<TSurvey[]> => {
|
||||
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const surveysPrisma = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
select: selectSurvey,
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting surveys");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
environmentIds.map((environmentId) => `getSurveys-management-api-${environmentId}-${limit}-${offset}`),
|
||||
{
|
||||
tags: environmentIds.map((environmentId) => surveyCache.tag.byEnvironmentId(environmentId)),
|
||||
try {
|
||||
const surveysPrisma = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
select: selectSurvey,
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting surveys");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
)()
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -15,15 +14,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/webhook", () => ({
|
||||
webhookCache: {
|
||||
tag: {
|
||||
byId: () => "mockTag",
|
||||
},
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
ValidationError: class ValidationError extends Error {
|
||||
@@ -34,11 +24,6 @@ vi.mock("@/lib/utils/validate", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
// Accept any function and return the exact same generic Fn – keeps typings intact
|
||||
cache: <T extends (...args: any[]) => any>(fn: T): T => fn,
|
||||
}));
|
||||
|
||||
describe("deleteWebhook", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -68,10 +53,9 @@ describe("deleteWebhook", () => {
|
||||
id: "test-webhook-id",
|
||||
},
|
||||
});
|
||||
expect(webhookCache.revalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should delete the webhook and call webhookCache.revalidate with correct parameters", async () => {
|
||||
test("should delete the webhook", async () => {
|
||||
const mockedWebhook: Webhook = {
|
||||
id: "test-webhook-id",
|
||||
url: "https://example.com",
|
||||
@@ -94,11 +78,6 @@ describe("deleteWebhook", () => {
|
||||
id: "test-webhook-id",
|
||||
},
|
||||
});
|
||||
expect(webhookCache.revalidate).toHaveBeenCalledWith({
|
||||
id: mockedWebhook.id,
|
||||
environmentId: mockedWebhook.environmentId,
|
||||
source: mockedWebhook.source,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw an error when called with an invalid webhook ID format", async () => {
|
||||
@@ -110,7 +89,6 @@ describe("deleteWebhook", () => {
|
||||
await expect(deleteWebhook("invalid-id")).rejects.toThrow(ValidationError);
|
||||
|
||||
expect(prisma.webhook.delete).not.toHaveBeenCalled();
|
||||
expect(webhookCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when webhook does not exist", async () => {
|
||||
@@ -122,7 +100,6 @@ describe("deleteWebhook", () => {
|
||||
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(deleteWebhook("non-existent-id")).rejects.toThrow(ResourceNotFoundError);
|
||||
expect(webhookCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when database operation fails", async () => {
|
||||
@@ -134,14 +111,12 @@ describe("deleteWebhook", () => {
|
||||
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(deleteWebhook("test-webhook-id")).rejects.toThrow(DatabaseError);
|
||||
expect(webhookCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when an unknown error occurs", async () => {
|
||||
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(new Error("Unknown error"));
|
||||
|
||||
await expect(deleteWebhook("test-webhook-id")).rejects.toThrow(DatabaseError);
|
||||
expect(webhookCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const deleteWebhook = async (id: string): Promise<Webhook> => {
|
||||
validateInputs([id, ZId]);
|
||||
@@ -18,12 +15,6 @@ export const deleteWebhook = async (id: string): Promise<Webhook> => {
|
||||
},
|
||||
});
|
||||
|
||||
webhookCache.revalidate({
|
||||
id: deletedWebhook.id,
|
||||
environmentId: deletedWebhook.environmentId,
|
||||
source: deletedWebhook.source,
|
||||
});
|
||||
|
||||
return deletedWebhook;
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -36,28 +27,21 @@ export const deleteWebhook = async (id: string): Promise<Webhook> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getWebhook = async (id: string): Promise<Webhook | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([id, ZId]);
|
||||
export const getWebhook = async (id: string): Promise<Webhook | null> => {
|
||||
validateInputs([id, ZId]);
|
||||
|
||||
try {
|
||||
const webhook = await prisma.webhook.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
return webhook;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getWebhook-${id}`],
|
||||
{
|
||||
tags: [webhookCache.tag.byId(id)],
|
||||
try {
|
||||
const webhook = await prisma.webhook.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
return webhook;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
)();
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook";
|
||||
import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma, WebhookSource } from "@prisma/client";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
@@ -16,12 +15,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/webhook", () => ({
|
||||
webhookCache: {
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
@@ -31,7 +24,7 @@ describe("createWebhook", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("should create a webhook and revalidate the cache when provided with valid input data", async () => {
|
||||
test("should create a webhook", async () => {
|
||||
const webhookInput: TWebhookInput = {
|
||||
environmentId: "test-env-id",
|
||||
name: "Test Webhook",
|
||||
@@ -74,12 +67,6 @@ describe("createWebhook", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(webhookCache.revalidate).toHaveBeenCalledWith({
|
||||
id: createdWebhook.id,
|
||||
environmentId: createdWebhook.environmentId,
|
||||
source: createdWebhook.source,
|
||||
});
|
||||
|
||||
expect(result).toEqual(createdWebhook);
|
||||
});
|
||||
|
||||
@@ -120,39 +107,6 @@ describe("createWebhook", () => {
|
||||
await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError);
|
||||
});
|
||||
|
||||
test("should call webhookCache.revalidate with the correct parameters after successfully creating a webhook", async () => {
|
||||
const webhookInput: TWebhookInput = {
|
||||
environmentId: "env-id",
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com",
|
||||
source: "user",
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: ["survey1"],
|
||||
};
|
||||
|
||||
const createdWebhook = {
|
||||
id: "webhook123",
|
||||
environmentId: "env-id",
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com",
|
||||
source: "user",
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: ["survey1"],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any;
|
||||
|
||||
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
|
||||
|
||||
await createWebhook(webhookInput);
|
||||
|
||||
expect(webhookCache.revalidate).toHaveBeenCalledWith({
|
||||
id: createdWebhook.id,
|
||||
environmentId: createdWebhook.environmentId,
|
||||
source: createdWebhook.source,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw a DatabaseError when provided with invalid surveyIds", async () => {
|
||||
const webhookInput: TWebhookInput = {
|
||||
environmentId: "test-env-id",
|
||||
@@ -197,7 +151,5 @@ describe("createWebhook", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(webhookCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
@@ -27,12 +25,6 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
|
||||
},
|
||||
});
|
||||
|
||||
webhookCache.revalidate({
|
||||
id: createdWebhook.id,
|
||||
environmentId: createdWebhook.environmentId,
|
||||
source: createdWebhook.source,
|
||||
});
|
||||
|
||||
return createdWebhook;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -49,30 +41,23 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
|
||||
}
|
||||
};
|
||||
|
||||
export const getWebhooks = (environmentIds: string[], page?: number): Promise<Webhook[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentIds, ZId.array()], [page, ZOptionalNumber]);
|
||||
export const getWebhooks = async (environmentIds: string[], page?: number): Promise<Webhook[]> => {
|
||||
validateInputs([environmentIds, ZId.array()], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
});
|
||||
return webhooks;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
environmentIds.map((environmentId) => `getWebhooks-${environmentId}-${page}`),
|
||||
{
|
||||
tags: environmentIds.map((environmentId) => webhookCache.tag.byEnvironmentId(environmentId)),
|
||||
try {
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
});
|
||||
return webhooks;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
)();
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { doesContactExist } from "./contact";
|
||||
@@ -13,16 +11,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock cache module
|
||||
vi.mock("@/lib/cache");
|
||||
vi.mock("@/lib/cache/contact", () => ({
|
||||
contactCache: {
|
||||
tag: {
|
||||
byId: vi.fn((id) => `contact-${id}`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react cache
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
@@ -35,12 +23,6 @@ vi.mock("react", async () => {
|
||||
const contactId = "test-contact-id";
|
||||
|
||||
describe("doesContactExist", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
@@ -55,9 +37,6 @@ describe("doesContactExist", () => {
|
||||
where: { id: contactId },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`doesContactExistDisplaysApiV2-${contactId}`], {
|
||||
tags: [contactCache.tag.byId(contactId)],
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false if contact does not exist", async () => {
|
||||
@@ -70,8 +49,5 @@ describe("doesContactExist", () => {
|
||||
where: { id: contactId },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`doesContactExistDisplaysApiV2-${contactId}`], {
|
||||
tags: [contactCache.tag.byId(contactId)],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export const doesContactExist = reactCache(
|
||||
(id: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
export const doesContactExist = reactCache(async (id: string): Promise<boolean> => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !!contact;
|
||||
},
|
||||
[`doesContactExistDisplaysApiV2-${id}`],
|
||||
{
|
||||
tags: [contactCache.tag.byId(id)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
return !!contact;
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { displayCache } from "@/lib/display/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -8,13 +7,6 @@ import { TDisplayCreateInputV2 } from "../types/display";
|
||||
import { doesContactExist } from "./contact";
|
||||
import { createDisplay } from "./display";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/display/cache", () => ({
|
||||
displayCache: {
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn((inputs) => inputs.map((input) => input[0])), // Pass through validation for testing
|
||||
}));
|
||||
@@ -79,12 +71,6 @@ describe("createDisplay", () => {
|
||||
},
|
||||
select: { id: true, contactId: true, surveyId: true },
|
||||
});
|
||||
expect(displayCache.revalidate).toHaveBeenCalledWith({
|
||||
id: displayId,
|
||||
contactId,
|
||||
surveyId,
|
||||
environmentId,
|
||||
});
|
||||
expect(result).toEqual(mockDisplay); // Changed this line
|
||||
});
|
||||
|
||||
@@ -101,12 +87,6 @@ describe("createDisplay", () => {
|
||||
},
|
||||
select: { id: true, contactId: true, surveyId: true },
|
||||
});
|
||||
expect(displayCache.revalidate).toHaveBeenCalledWith({
|
||||
id: displayId,
|
||||
contactId: null,
|
||||
surveyId,
|
||||
environmentId,
|
||||
});
|
||||
expect(result).toEqual(mockDisplayWithoutContact); // Changed this line
|
||||
});
|
||||
|
||||
@@ -125,12 +105,6 @@ describe("createDisplay", () => {
|
||||
},
|
||||
select: { id: true, contactId: true, surveyId: true },
|
||||
});
|
||||
expect(displayCache.revalidate).toHaveBeenCalledWith({
|
||||
id: displayId,
|
||||
contactId: null, // Assuming prisma returns null if contact wasn't connected
|
||||
surveyId,
|
||||
environmentId,
|
||||
});
|
||||
expect(result).toEqual(mockDisplayWithoutContact); // Changed this line
|
||||
});
|
||||
|
||||
@@ -143,7 +117,6 @@ describe("createDisplay", () => {
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(ValidationError);
|
||||
expect(doesContactExist).not.toHaveBeenCalled();
|
||||
expect(prisma.display.create).not.toHaveBeenCalled();
|
||||
expect(displayCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma known request error", async () => {
|
||||
@@ -155,7 +128,6 @@ describe("createDisplay", () => {
|
||||
vi.mocked(prisma.display.create).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(DatabaseError);
|
||||
expect(displayCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw original error on other errors during creation", async () => {
|
||||
@@ -164,7 +136,6 @@ describe("createDisplay", () => {
|
||||
vi.mocked(prisma.display.create).mockRejectedValue(genericError);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(genericError);
|
||||
expect(displayCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw original error if doesContactExist fails", async () => {
|
||||
@@ -173,6 +144,5 @@ describe("createDisplay", () => {
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(contactCheckError);
|
||||
expect(prisma.display.create).not.toHaveBeenCalled();
|
||||
expect(displayCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
TDisplayCreateInputV2,
|
||||
ZDisplayCreateInputV2,
|
||||
} from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||
import { displayCache } from "@/lib/display/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -12,7 +11,7 @@ import { doesContactExist } from "./contact";
|
||||
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
|
||||
validateInputs([displayInput, ZDisplayCreateInputV2]);
|
||||
|
||||
const { environmentId, contactId, surveyId } = displayInput;
|
||||
const { contactId, surveyId } = displayInput;
|
||||
|
||||
try {
|
||||
const contactExists = contactId ? await doesContactExist(contactId) : false;
|
||||
@@ -36,13 +35,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
|
||||
select: { id: true, contactId: true, surveyId: true },
|
||||
});
|
||||
|
||||
displayCache.revalidate({
|
||||
id: display.id,
|
||||
contactId: display.contactId,
|
||||
surveyId: display.surveyId,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
return display;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -14,7 +14,13 @@ interface Context {
|
||||
}
|
||||
|
||||
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> => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
@@ -13,8 +12,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache");
|
||||
|
||||
const contactId = "test-contact-id";
|
||||
const mockContact = {
|
||||
id: contactId,
|
||||
@@ -30,12 +27,6 @@ const expectedContactAttributes: TContactAttributes = {
|
||||
};
|
||||
|
||||
describe("getContact", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
test("should return contact with formatted attributes when found", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContact);
|
||||
|
||||
@@ -57,8 +48,6 @@ describe("getContact", () => {
|
||||
id: contactId,
|
||||
attributes: expectedContactAttributes,
|
||||
});
|
||||
// Check if cache wrapper was called (though mocked to pass through)
|
||||
expect(cache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return null when contact is not found", async () => {
|
||||
@@ -79,7 +68,5 @@ describe("getContact", () => {
|
||||
},
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
// Check if cache wrapper was called (though mocked to pass through)
|
||||
expect(cache).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,42 +1,32 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
|
||||
export const getContact = reactCache((contactId: string) =>
|
||||
cache(
|
||||
async () => {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
export const getContact = reactCache(async (contactId: string) => {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contactAttributes = contact.attributes.reduce((acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
}, {}) as TContactAttributes;
|
||||
|
||||
return {
|
||||
id: contact.id,
|
||||
attributes: contactAttributes,
|
||||
};
|
||||
},
|
||||
},
|
||||
[`getContact-responses-api-${contactId}`],
|
||||
{
|
||||
tags: [contactCache.tag.byId(contactId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -16,19 +16,6 @@ vi.mock("@formbricks/logger", () => ({
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: (fn: any) => fn,
|
||||
}));
|
||||
vi.mock("@/lib/organization/cache", () => ({
|
||||
organizationCache: {
|
||||
tag: {
|
||||
byEnvironmentId: (id: string) => `tag-${id}`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("react", () => ({
|
||||
cache: (fn: any) => fn,
|
||||
}));
|
||||
|
||||
describe("getOrganizationBillingByEnvironmentId", () => {
|
||||
const environmentId = "env-123";
|
||||
|
||||
@@ -1,45 +1,36 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { organizationCache } from "@/lib/organization/cache";
|
||||
import { Organization } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const getOrganizationBillingByEnvironmentId = reactCache(
|
||||
async (environmentId: string): Promise<Organization["billing"] | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
try {
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
projects: {
|
||||
async (environmentId: string): Promise<Organization["billing"] | null> => {
|
||||
try {
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
projects: {
|
||||
some: {
|
||||
environments: {
|
||||
some: {
|
||||
environments: {
|
||||
some: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
billing: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
select: {
|
||||
billing: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return organization.billing;
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to get organization billing by environment ID");
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[`api-v2-client-getOrganizationBillingByEnvironmentId-${environmentId}`],
|
||||
{
|
||||
tags: [organizationCache.tag.byEnvironmentId(environmentId)],
|
||||
if (!organization) {
|
||||
return null;
|
||||
}
|
||||
)()
|
||||
|
||||
return organization.billing;
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to get organization billing by environment ID");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -4,9 +4,7 @@ import {
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { responseNoteCache } from "@/lib/responseNote/cache";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
@@ -50,9 +48,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/posthogServer");
|
||||
vi.mock("@/lib/response/cache");
|
||||
vi.mock("@/lib/response/utils");
|
||||
vi.mock("@/lib/responseNote/cache");
|
||||
vi.mock("@/lib/telemetry");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -138,8 +134,6 @@ describe("createResponse V2", () => {
|
||||
...ttc,
|
||||
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
||||
}));
|
||||
vi.mocked(responseCache.revalidate).mockResolvedValue(undefined);
|
||||
vi.mocked(responseNoteCache.revalidate).mockResolvedValue(undefined);
|
||||
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
|
||||
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { responseNoteCache } from "@/lib/responseNote/cache";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
@@ -44,7 +42,6 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
|
||||
|
||||
try {
|
||||
let contact: { id: string; attributes: TContactAttributes } | null = null;
|
||||
let userId: string | undefined = undefined;
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
@@ -53,7 +50,6 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
|
||||
|
||||
if (contactId) {
|
||||
contact = await getContact(contactId);
|
||||
userId = contact?.attributes.userId;
|
||||
}
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
@@ -101,19 +97,6 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
responseCache.revalidate({
|
||||
environmentId,
|
||||
id: response.id,
|
||||
contactId: contact?.id,
|
||||
...(singleUseId && { singleUseId }),
|
||||
userId,
|
||||
surveyId,
|
||||
});
|
||||
|
||||
responseNoteCache.revalidate({
|
||||
responseId: response.id,
|
||||
});
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const responsesLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
@@ -22,7 +22,13 @@ interface Context {
|
||||
}
|
||||
|
||||
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> => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { storageCache } from "@/lib/storage/cache";
|
||||
import { deleteFile } from "@/lib/storage/service";
|
||||
import { type TAccessType } from "@formbricks/types/storage";
|
||||
|
||||
@@ -8,8 +7,6 @@ export const handleDeleteFile = async (environmentId: string, accessType: TAcces
|
||||
const { message, success, code } = await deleteFile(environmentId, accessType, fileName);
|
||||
|
||||
if (success) {
|
||||
// revalidate cache
|
||||
storageCache.revalidate({ fileKey: `${environmentId}/${accessType}/${fileName}` });
|
||||
return responses.successResponse(message);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
environmentId?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const actionClassCache = {
|
||||
tag: {
|
||||
byNameAndEnvironmentId(name: string, environmentId: string): string {
|
||||
return `environments-${environmentId}-actionClass-${name}`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string): string {
|
||||
return `environments-${environmentId}-actionClasses`;
|
||||
},
|
||||
byId(id: string): string {
|
||||
return `actionClasses-${id}`;
|
||||
},
|
||||
},
|
||||
revalidate({ environmentId, name, id }: RevalidateProps): void {
|
||||
if (environmentId) {
|
||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
|
||||
if (id) {
|
||||
revalidateTag(this.tag.byId(id));
|
||||
}
|
||||
|
||||
if (name && environmentId) {
|
||||
revalidateTag(this.tag.byNameAndEnvironmentId(name, environmentId));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { actionClassCache } from "./cache";
|
||||
import {
|
||||
deleteActionClass,
|
||||
getActionClass,
|
||||
@@ -25,21 +24,6 @@ vi.mock("../utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../cache", () => ({
|
||||
cache: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("./cache", () => ({
|
||||
actionClassCache: {
|
||||
tag: {
|
||||
byEnvironmentId: vi.fn(),
|
||||
byNameAndEnvironmentId: vi.fn(),
|
||||
byId: vi.fn(),
|
||||
},
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("ActionClass Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -61,7 +45,6 @@ describe("ActionClass Service", () => {
|
||||
},
|
||||
];
|
||||
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
|
||||
vi.mocked(actionClassCache.tag.byEnvironmentId).mockReturnValue("mock-tag");
|
||||
|
||||
const result = await getActionClasses("env1");
|
||||
expect(result).toEqual(mockActionClasses);
|
||||
@@ -76,7 +59,6 @@ describe("ActionClass Service", () => {
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("fail"));
|
||||
vi.mocked(actionClassCache.tag.byEnvironmentId).mockReturnValue("mock-tag");
|
||||
await expect(getActionClasses("env1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
@@ -96,8 +78,6 @@ describe("ActionClass Service", () => {
|
||||
};
|
||||
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(mockActionClass);
|
||||
if (!actionClassCache.tag.byNameAndEnvironmentId) actionClassCache.tag.byNameAndEnvironmentId = vi.fn();
|
||||
vi.mocked(actionClassCache.tag.byNameAndEnvironmentId).mockReturnValue("mock-tag");
|
||||
|
||||
const result = await getActionClassByEnvironmentIdAndName("env2", "Action 2");
|
||||
expect(result).toEqual(mockActionClass);
|
||||
@@ -110,8 +90,6 @@ describe("ActionClass Service", () => {
|
||||
test("should return null when not found", async () => {
|
||||
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(null);
|
||||
if (!actionClassCache.tag.byNameAndEnvironmentId) actionClassCache.tag.byNameAndEnvironmentId = vi.fn();
|
||||
vi.mocked(actionClassCache.tag.byNameAndEnvironmentId).mockReturnValue("mock-tag");
|
||||
const result = await getActionClassByEnvironmentIdAndName("env2", "Action 2");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -119,8 +97,6 @@ describe("ActionClass Service", () => {
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findFirst).mockRejectedValue(new Error("fail"));
|
||||
if (!actionClassCache.tag.byNameAndEnvironmentId) actionClassCache.tag.byNameAndEnvironmentId = vi.fn();
|
||||
vi.mocked(actionClassCache.tag.byNameAndEnvironmentId).mockReturnValue("mock-tag");
|
||||
await expect(getActionClassByEnvironmentIdAndName("env2", "Action 2")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
@@ -140,8 +116,6 @@ describe("ActionClass Service", () => {
|
||||
};
|
||||
if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(mockActionClass);
|
||||
if (!actionClassCache.tag.byId) actionClassCache.tag.byId = vi.fn();
|
||||
vi.mocked(actionClassCache.tag.byId).mockReturnValue("mock-tag");
|
||||
const result = await getActionClass("id3");
|
||||
expect(result).toEqual(mockActionClass);
|
||||
expect(prisma.actionClass.findUnique).toHaveBeenCalledWith({
|
||||
@@ -153,8 +127,6 @@ describe("ActionClass Service", () => {
|
||||
test("should return null when not found", async () => {
|
||||
if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(null);
|
||||
if (!actionClassCache.tag.byId) actionClassCache.tag.byId = vi.fn();
|
||||
vi.mocked(actionClassCache.tag.byId).mockReturnValue("mock-tag");
|
||||
const result = await getActionClass("id3");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -162,8 +134,6 @@ describe("ActionClass Service", () => {
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn();
|
||||
vi.mocked(prisma.actionClass.findUnique).mockRejectedValue(new Error("fail"));
|
||||
if (!actionClassCache.tag.byId) actionClassCache.tag.byId = vi.fn();
|
||||
vi.mocked(actionClassCache.tag.byId).mockReturnValue("mock-tag");
|
||||
await expect(getActionClass("id3")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
@@ -183,18 +153,12 @@ describe("ActionClass Service", () => {
|
||||
};
|
||||
if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn();
|
||||
vi.mocked(prisma.actionClass.delete).mockResolvedValue(mockActionClass);
|
||||
vi.mocked(actionClassCache.revalidate).mockReturnValue(undefined);
|
||||
const result = await deleteActionClass("id4");
|
||||
expect(result).toEqual(mockActionClass);
|
||||
expect(prisma.actionClass.delete).toHaveBeenCalledWith({
|
||||
where: { id: "id4" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
expect(actionClassCache.revalidate).toHaveBeenCalledWith({
|
||||
environmentId: mockActionClass.environmentId,
|
||||
id: "id4",
|
||||
name: mockActionClass.name,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if action class is null", async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { ActionClass, Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -10,9 +9,7 @@ import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { surveyCache } from "../survey/cache";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { actionClassCache } from "./cache";
|
||||
|
||||
const selectActionClass = {
|
||||
id: true,
|
||||
@@ -27,87 +24,64 @@ const selectActionClass = {
|
||||
} satisfies Prisma.ActionClassSelect;
|
||||
|
||||
export const getActionClasses = reactCache(
|
||||
async (environmentId: string, page?: number): Promise<TActionClass[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
async (environmentId: string, page?: number): Promise<TActionClass[]> => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
return await prisma.actionClass.findMany({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
},
|
||||
select: selectActionClass,
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseError(`Database error when fetching actions for environment ${environmentId}`);
|
||||
}
|
||||
},
|
||||
[`getActionClasses-${environmentId}-${page}`],
|
||||
{
|
||||
tags: [actionClassCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
try {
|
||||
return await prisma.actionClass.findMany({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
},
|
||||
select: selectActionClass,
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseError(`Database error when fetching actions for environment ${environmentId}`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// This function is used to get an action by its name and environmentId(it can return private actions as well)
|
||||
export const getActionClassByEnvironmentIdAndName = reactCache(
|
||||
async (environmentId: string, name: string): Promise<TActionClass | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [name, ZString]);
|
||||
async (environmentId: string, name: string): Promise<TActionClass | null> => {
|
||||
validateInputs([environmentId, ZId], [name, ZString]);
|
||||
|
||||
try {
|
||||
const actionClass = await prisma.actionClass.findFirst({
|
||||
where: {
|
||||
name,
|
||||
environmentId,
|
||||
},
|
||||
select: selectActionClass,
|
||||
});
|
||||
try {
|
||||
const actionClass = await prisma.actionClass.findFirst({
|
||||
where: {
|
||||
name,
|
||||
environmentId,
|
||||
},
|
||||
select: selectActionClass,
|
||||
});
|
||||
|
||||
return actionClass;
|
||||
} catch (error) {
|
||||
throw new DatabaseError(`Database error when fetching action`);
|
||||
}
|
||||
},
|
||||
[`getActionClassByEnvironmentIdAndName-${environmentId}-${name}`],
|
||||
{
|
||||
tags: [actionClassCache.tag.byNameAndEnvironmentId(name, environmentId)],
|
||||
}
|
||||
)()
|
||||
return actionClass;
|
||||
} catch (error) {
|
||||
throw new DatabaseError(`Database error when fetching action`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getActionClass = reactCache(
|
||||
async (actionClassId: string): Promise<TActionClass | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([actionClassId, ZId]);
|
||||
export const getActionClass = reactCache(async (actionClassId: string): Promise<TActionClass | null> => {
|
||||
validateInputs([actionClassId, ZId]);
|
||||
|
||||
try {
|
||||
const actionClass = await prisma.actionClass.findUnique({
|
||||
where: {
|
||||
id: actionClassId,
|
||||
},
|
||||
select: selectActionClass,
|
||||
});
|
||||
|
||||
return actionClass;
|
||||
} catch (error) {
|
||||
throw new DatabaseError(`Database error when fetching action`);
|
||||
}
|
||||
try {
|
||||
const actionClass = await prisma.actionClass.findUnique({
|
||||
where: {
|
||||
id: actionClassId,
|
||||
},
|
||||
[`getActionClass-${actionClassId}`],
|
||||
{
|
||||
tags: [actionClassCache.tag.byId(actionClassId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
select: selectActionClass,
|
||||
});
|
||||
|
||||
return actionClass;
|
||||
} catch (error) {
|
||||
throw new DatabaseError(`Database error when fetching action`);
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteActionClass = async (actionClassId: string): Promise<TActionClass> => {
|
||||
validateInputs([actionClassId, ZId]);
|
||||
@@ -121,12 +95,6 @@ export const deleteActionClass = async (actionClassId: string): Promise<TActionC
|
||||
});
|
||||
if (actionClass === null) throw new ResourceNotFoundError("Action", actionClassId);
|
||||
|
||||
actionClassCache.revalidate({
|
||||
environmentId: actionClass.environmentId,
|
||||
id: actionClassId,
|
||||
name: actionClass.name,
|
||||
});
|
||||
|
||||
return actionClass;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -160,12 +128,6 @@ export const createActionClass = async (
|
||||
select: selectActionClass,
|
||||
});
|
||||
|
||||
actionClassCache.revalidate({
|
||||
name: actionClassPrisma.name,
|
||||
environmentId: actionClassPrisma.environmentId,
|
||||
id: actionClassPrisma.id,
|
||||
});
|
||||
|
||||
return actionClassPrisma;
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -215,20 +177,6 @@ export const updateActionClass = async (
|
||||
},
|
||||
});
|
||||
|
||||
// revalidate cache
|
||||
actionClassCache.revalidate({
|
||||
environmentId: result.environmentId,
|
||||
name: result.name,
|
||||
id: result.id,
|
||||
});
|
||||
|
||||
const surveyIds = result.surveyTriggers.map((survey) => survey.surveyId);
|
||||
for (const surveyId of surveyIds) {
|
||||
surveyCache.revalidate({
|
||||
id: surveyId,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// cache wrapper for unstable_cache
|
||||
// workaround for https://github.com/vercel/next.js/issues/51613
|
||||
// copied from https://github.com/vercel/next.js/issues/51613#issuecomment-1892644565
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { parse, stringify } from "superjson";
|
||||
|
||||
export { revalidateTag } from "next/cache";
|
||||
|
||||
export const cache = <T, P extends unknown[]>(
|
||||
fn: (...params: P) => Promise<T>,
|
||||
keys: Parameters<typeof unstable_cache>[1],
|
||||
opts: Parameters<typeof unstable_cache>[2]
|
||||
) => {
|
||||
const wrap = async (params: unknown[]): Promise<string> => {
|
||||
const result = await fn(...(params as P));
|
||||
return stringify(result);
|
||||
};
|
||||
|
||||
const cachedFn = unstable_cache(wrap, keys, opts);
|
||||
|
||||
return async (...params: P): Promise<T> => {
|
||||
const result = await cachedFn(params);
|
||||
return parse(result);
|
||||
};
|
||||
};
|
||||
34
apps/web/lib/cache/api-key.ts
vendored
34
apps/web/lib/cache/api-key.ts
vendored
@@ -1,34 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
hashedKey?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export const apiKeyCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `apiKeys-${id}`;
|
||||
},
|
||||
byHashedKey(hashedKey: string) {
|
||||
return `apiKeys-${hashedKey}-apiKey`;
|
||||
},
|
||||
byOrganizationId(organizationId: string) {
|
||||
return `organizations-${organizationId}-apiKeys`;
|
||||
},
|
||||
},
|
||||
revalidate({ id, hashedKey, organizationId }: RevalidateProps): void {
|
||||
if (id) {
|
||||
revalidateTag(this.tag.byId(id));
|
||||
}
|
||||
|
||||
if (hashedKey) {
|
||||
revalidateTag(this.tag.byHashedKey(hashedKey));
|
||||
}
|
||||
|
||||
if (organizationId) {
|
||||
revalidateTag(this.tag.byOrganizationId(organizationId));
|
||||
}
|
||||
},
|
||||
};
|
||||
34
apps/web/lib/cache/contact-attribute-key.ts
vendored
34
apps/web/lib/cache/contact-attribute-key.ts
vendored
@@ -1,34 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
environmentId?: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export const contactAttributeKeyCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `contactAttributeKey-${id}`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string) {
|
||||
return `environments-${environmentId}-contactAttributeKeys`;
|
||||
},
|
||||
byEnvironmentIdAndKey(environmentId: string, key: string) {
|
||||
return `contactAttributeKey-environment-${environmentId}-key-${key}`;
|
||||
},
|
||||
},
|
||||
revalidate: ({ id, environmentId, key }: RevalidateProps): void => {
|
||||
if (id) {
|
||||
revalidateTag(contactAttributeKeyCache.tag.byId(id));
|
||||
}
|
||||
|
||||
if (environmentId) {
|
||||
revalidateTag(contactAttributeKeyCache.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
|
||||
if (environmentId && key) {
|
||||
revalidateTag(contactAttributeKeyCache.tag.byEnvironmentIdAndKey(environmentId, key));
|
||||
}
|
||||
},
|
||||
};
|
||||
40
apps/web/lib/cache/contact-attribute.ts
vendored
40
apps/web/lib/cache/contact-attribute.ts
vendored
@@ -1,40 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
environmentId?: string;
|
||||
contactId?: string;
|
||||
userId?: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export const contactAttributeCache = {
|
||||
tag: {
|
||||
byContactId(contactId: string): string {
|
||||
return `contact-${contactId}-contactAttributes`;
|
||||
},
|
||||
byEnvironmentIdAndUserId(environmentId: string, userId: string): string {
|
||||
return `environments-${environmentId}-contact-userId-${userId}-contactAttributes`;
|
||||
},
|
||||
byKeyAndContactId(key: string, contactId: string): string {
|
||||
return `contact-${contactId}-contactAttribute-${key}`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string): string {
|
||||
return `contactAttributes-${environmentId}`;
|
||||
},
|
||||
},
|
||||
revalidate: ({ contactId, environmentId, userId, key }: RevalidateProps): void => {
|
||||
if (environmentId) {
|
||||
revalidateTag(contactAttributeCache.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
|
||||
if (environmentId && userId) {
|
||||
revalidateTag(contactAttributeCache.tag.byEnvironmentIdAndUserId(environmentId, userId));
|
||||
}
|
||||
if (contactId) {
|
||||
revalidateTag(contactAttributeCache.tag.byContactId(contactId));
|
||||
}
|
||||
if (contactId && key) {
|
||||
revalidateTag(contactAttributeCache.tag.byKeyAndContactId(key, contactId));
|
||||
}
|
||||
},
|
||||
};
|
||||
34
apps/web/lib/cache/contact.ts
vendored
34
apps/web/lib/cache/contact.ts
vendored
@@ -1,34 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
environmentId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export const contactCache = {
|
||||
tag: {
|
||||
byId(id: string): string {
|
||||
return `contacts-${id}`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string): string {
|
||||
return `environments-${environmentId}-contacts`;
|
||||
},
|
||||
byEnvironmentIdAndUserId(environmentId: string, userId: string): string {
|
||||
return `environments-${environmentId}-contactByUserId-${userId}`;
|
||||
},
|
||||
},
|
||||
revalidate: ({ id, environmentId, userId }: RevalidateProps): void => {
|
||||
if (id) {
|
||||
revalidateTag(contactCache.tag.byId(id));
|
||||
}
|
||||
|
||||
if (environmentId) {
|
||||
revalidateTag(contactCache.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
|
||||
if (environmentId && userId) {
|
||||
revalidateTag(contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId));
|
||||
}
|
||||
},
|
||||
};
|
||||
26
apps/web/lib/cache/invite.ts
vendored
26
apps/web/lib/cache/invite.ts
vendored
@@ -1,26 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export const inviteCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `invites-${id}`;
|
||||
},
|
||||
byOrganizationId(organizationId: string) {
|
||||
return `organizations-${organizationId}-invites`;
|
||||
},
|
||||
},
|
||||
revalidate({ id, organizationId }: RevalidateProps): void {
|
||||
if (id) {
|
||||
revalidateTag(this.tag.byId(id));
|
||||
}
|
||||
|
||||
if (organizationId) {
|
||||
revalidateTag(this.tag.byOrganizationId(organizationId));
|
||||
}
|
||||
},
|
||||
};
|
||||
26
apps/web/lib/cache/membership.ts
vendored
26
apps/web/lib/cache/membership.ts
vendored
@@ -1,26 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export const membershipCache = {
|
||||
tag: {
|
||||
byOrganizationId(organizationId: string) {
|
||||
return `organizations-${organizationId}-memberships`;
|
||||
},
|
||||
byUserId(userId: string) {
|
||||
return `users-${userId}-memberships`;
|
||||
},
|
||||
},
|
||||
revalidate: ({ organizationId, userId }: RevalidateProps): void => {
|
||||
if (organizationId) {
|
||||
revalidateTag(membershipCache.tag.byOrganizationId(organizationId));
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
revalidateTag(membershipCache.tag.byUserId(userId));
|
||||
}
|
||||
},
|
||||
};
|
||||
42
apps/web/lib/cache/organization.ts
vendored
42
apps/web/lib/cache/organization.ts
vendored
@@ -1,42 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
environmentId?: string;
|
||||
count?: boolean;
|
||||
}
|
||||
|
||||
export const organizationCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `organizations-${id}`;
|
||||
},
|
||||
byUserId(userId: string) {
|
||||
return `users-${userId}-organizations`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string) {
|
||||
return `environments-${environmentId}-organizations`;
|
||||
},
|
||||
byCount() {
|
||||
return "organizations-count";
|
||||
},
|
||||
},
|
||||
revalidate: ({ id, userId, environmentId, count }: RevalidateProps): void => {
|
||||
if (id) {
|
||||
revalidateTag(organizationCache.tag.byId(id));
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
revalidateTag(organizationCache.tag.byUserId(userId));
|
||||
}
|
||||
|
||||
if (environmentId) {
|
||||
revalidateTag(organizationCache.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
|
||||
if (count) {
|
||||
revalidateTag(organizationCache.tag.byCount());
|
||||
}
|
||||
},
|
||||
};
|
||||
34
apps/web/lib/cache/segment.ts
vendored
34
apps/web/lib/cache/segment.ts
vendored
@@ -1,34 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
environmentId?: string;
|
||||
attributeKey?: string;
|
||||
}
|
||||
|
||||
export const segmentCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `segment-${id}`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string): string {
|
||||
return `environments-${environmentId}-segements`;
|
||||
},
|
||||
byAttributeKey(attributeKey: string): string {
|
||||
return `attribute-${attributeKey}-segements`;
|
||||
},
|
||||
},
|
||||
revalidate({ id, environmentId, attributeKey }: RevalidateProps): void {
|
||||
if (id) {
|
||||
revalidateTag(this.tag.byId(id));
|
||||
}
|
||||
|
||||
if (environmentId) {
|
||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
|
||||
if (attributeKey) {
|
||||
revalidateTag(this.tag.byAttributeKey(attributeKey));
|
||||
}
|
||||
},
|
||||
};
|
||||
39
apps/web/lib/cache/team.ts
vendored
39
apps/web/lib/cache/team.ts
vendored
@@ -1,39 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
projectId?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export const teamCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `team-${id}`;
|
||||
},
|
||||
byProjectId(projectId: string) {
|
||||
return `project-teams-${projectId}`;
|
||||
},
|
||||
byUserId(userId: string) {
|
||||
return `user-${userId}-teams`;
|
||||
},
|
||||
byOrganizationId(organizationId: string) {
|
||||
return `organization-${organizationId}-teams`;
|
||||
},
|
||||
},
|
||||
revalidate: ({ id, projectId, userId, organizationId }: RevalidateProps): void => {
|
||||
if (id) {
|
||||
revalidateTag(teamCache.tag.byId(id));
|
||||
}
|
||||
if (projectId) {
|
||||
revalidateTag(teamCache.tag.byProjectId(projectId));
|
||||
}
|
||||
if (userId) {
|
||||
revalidateTag(teamCache.tag.byUserId(userId));
|
||||
}
|
||||
if (organizationId) {
|
||||
revalidateTag(teamCache.tag.byOrganizationId(organizationId));
|
||||
}
|
||||
},
|
||||
};
|
||||
35
apps/web/lib/cache/webhook.ts
vendored
35
apps/web/lib/cache/webhook.ts
vendored
@@ -1,35 +0,0 @@
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
environmentId?: string;
|
||||
source?: Webhook["source"];
|
||||
}
|
||||
|
||||
export const webhookCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `webhooks-${id}`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string) {
|
||||
return `environments-${environmentId}-webhooks`;
|
||||
},
|
||||
byEnvironmentIdAndSource(environmentId: string, source?: Webhook["source"]) {
|
||||
return `environments-${environmentId}-sources-${source}-webhooks`;
|
||||
},
|
||||
},
|
||||
revalidate({ id, environmentId, source }: RevalidateProps): void {
|
||||
if (id) {
|
||||
revalidateTag(this.tag.byId(id));
|
||||
}
|
||||
|
||||
if (environmentId) {
|
||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
|
||||
if (environmentId && source) {
|
||||
revalidateTag(this.tag.byEnvironmentIdAndSource(environmentId, source));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -85,7 +85,7 @@ export function symmetricDecrypt(payload: string, key: string): string {
|
||||
try {
|
||||
return symmetricDecryptV2(payload, key);
|
||||
} catch (err) {
|
||||
logger.warn("AES-GCM decryption failed; refusing to fall back to insecure CBC", err);
|
||||
logger.warn(err, "AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
surveyId?: string;
|
||||
contactId?: string | null;
|
||||
userId?: string;
|
||||
environmentId?: string;
|
||||
}
|
||||
|
||||
export const displayCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `displays-${id}`;
|
||||
},
|
||||
bySurveyId(surveyId: string) {
|
||||
return `surveys-${surveyId}-displays`;
|
||||
},
|
||||
byContactId(contactId: string) {
|
||||
return `contacts-${contactId}-displays`;
|
||||
},
|
||||
byEnvironmentIdAndUserId(environmentId: string, userId: string) {
|
||||
return `environments-${environmentId}-users-${userId}-displays`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string) {
|
||||
return `environments-${environmentId}-displays`;
|
||||
},
|
||||
},
|
||||
revalidate({ id, surveyId, contactId, environmentId, userId }: RevalidateProps): void {
|
||||
if (environmentId && userId) {
|
||||
revalidateTag(this.tag.byEnvironmentIdAndUserId(environmentId, userId));
|
||||
}
|
||||
|
||||
if (id) {
|
||||
revalidateTag(this.tag.byId(id));
|
||||
}
|
||||
|
||||
if (surveyId) {
|
||||
revalidateTag(this.tag.bySurveyId(surveyId));
|
||||
}
|
||||
|
||||
if (contactId) {
|
||||
revalidateTag(this.tag.byContactId(contactId));
|
||||
}
|
||||
|
||||
if (environmentId) {
|
||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -5,9 +5,7 @@ import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TDisplay, TDisplayFilters } from "@formbricks/types/displays";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { cache } from "../cache";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { displayCache } from "./cache";
|
||||
|
||||
export const selectDisplay = {
|
||||
id: true,
|
||||
@@ -19,37 +17,30 @@ export const selectDisplay = {
|
||||
} satisfies Prisma.DisplaySelect;
|
||||
|
||||
export const getDisplayCountBySurveyId = reactCache(
|
||||
async (surveyId: string, filters?: TDisplayFilters): Promise<number> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([surveyId, ZId]);
|
||||
async (surveyId: string, filters?: TDisplayFilters): Promise<number> => {
|
||||
validateInputs([surveyId, ZId]);
|
||||
|
||||
try {
|
||||
const displayCount = await prisma.display.count({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
...(filters &&
|
||||
filters.createdAt && {
|
||||
createdAt: {
|
||||
gte: filters.createdAt.min,
|
||||
lte: filters.createdAt.max,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
return displayCount;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getDisplayCountBySurveyId-${surveyId}-${JSON.stringify(filters)}`],
|
||||
{
|
||||
tags: [displayCache.tag.bySurveyId(surveyId)],
|
||||
try {
|
||||
const displayCount = await prisma.display.count({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
...(filters &&
|
||||
filters.createdAt && {
|
||||
createdAt: {
|
||||
gte: filters.createdAt.min,
|
||||
lte: filters.createdAt.max,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
return displayCount;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
)()
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteDisplay = async (displayId: string): Promise<TDisplay> => {
|
||||
@@ -62,12 +53,6 @@ export const deleteDisplay = async (displayId: string): Promise<TDisplay> => {
|
||||
select: selectDisplay,
|
||||
});
|
||||
|
||||
displayCache.revalidate({
|
||||
id: display.id,
|
||||
contactId: display.contactId,
|
||||
surveyId: display.surveyId,
|
||||
});
|
||||
|
||||
return display;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -2,73 +2,64 @@ import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { cache } from "../cache";
|
||||
import { organizationCache } from "../organization/cache";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const hasUserEnvironmentAccess = async (userId: string, environmentId: string) =>
|
||||
cache(
|
||||
async (): Promise<boolean> => {
|
||||
validateInputs([userId, ZId], [environmentId, ZId]);
|
||||
export const hasUserEnvironmentAccess = async (userId: string, environmentId: string) => {
|
||||
validateInputs([userId, ZId], [environmentId, ZId]);
|
||||
|
||||
try {
|
||||
const orgMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organization: {
|
||||
projects: {
|
||||
try {
|
||||
const orgMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organization: {
|
||||
projects: {
|
||||
some: {
|
||||
environments: {
|
||||
some: {
|
||||
environments: {
|
||||
some: {
|
||||
id: environmentId,
|
||||
},
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!orgMembership) return false;
|
||||
|
||||
if (
|
||||
orgMembership.role === "owner" ||
|
||||
orgMembership.role === "manager" ||
|
||||
orgMembership.role === "billing"
|
||||
)
|
||||
return true;
|
||||
|
||||
const teamMembership = await prisma.teamUser.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
team: {
|
||||
projectTeams: {
|
||||
some: {
|
||||
project: {
|
||||
environments: {
|
||||
some: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!orgMembership) return false;
|
||||
if (teamMembership) return true;
|
||||
|
||||
if (
|
||||
orgMembership.role === "owner" ||
|
||||
orgMembership.role === "manager" ||
|
||||
orgMembership.role === "billing"
|
||||
)
|
||||
return true;
|
||||
|
||||
const teamMembership = await prisma.teamUser.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
team: {
|
||||
projectTeams: {
|
||||
some: {
|
||||
project: {
|
||||
environments: {
|
||||
some: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (teamMembership) return true;
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`hasUserEnvironmentAccess-${userId}-${environmentId}`],
|
||||
{
|
||||
tags: [organizationCache.tag.byEnvironmentId(environmentId), organizationCache.tag.byUserId(userId)],
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
)();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export const environmentCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `environments-${id}`;
|
||||
},
|
||||
byProjectId(projectId: string) {
|
||||
return `projects-${projectId}-environments`;
|
||||
},
|
||||
},
|
||||
revalidate({ id, projectId: projectId }: RevalidateProps): void {
|
||||
if (id) {
|
||||
revalidateTag(this.tag.byId(id));
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
revalidateTag(this.tag.byProjectId(projectId));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import { EnvironmentType, Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { environmentCache } from "./cache";
|
||||
import { getEnvironment, getEnvironments, updateEnvironment } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -21,20 +20,6 @@ vi.mock("../utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../cache", () => ({
|
||||
cache: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("./cache", () => ({
|
||||
environmentCache: {
|
||||
revalidate: vi.fn(),
|
||||
tag: {
|
||||
byId: vi.fn(),
|
||||
byProjectId: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Environment Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -53,7 +38,6 @@ describe("Environment Service", () => {
|
||||
};
|
||||
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironment);
|
||||
vi.mocked(environmentCache.tag.byId).mockReturnValue("mock-tag");
|
||||
|
||||
const result = await getEnvironment("clh6pzwx90000e9ogjr0mf7sx");
|
||||
|
||||
@@ -67,7 +51,6 @@ describe("Environment Service", () => {
|
||||
|
||||
test("should return null when environment not found", async () => {
|
||||
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(environmentCache.tag.byId).mockReturnValue("mock-tag");
|
||||
|
||||
const result = await getEnvironment("clh6pzwx90000e9ogjr0mf7sx");
|
||||
|
||||
@@ -80,7 +63,6 @@ describe("Environment Service", () => {
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError);
|
||||
vi.mocked(environmentCache.tag.byId).mockReturnValue("mock-tag");
|
||||
|
||||
await expect(getEnvironment("clh6pzwx90000e9ogjr0mf7sx")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
@@ -121,7 +103,6 @@ describe("Environment Service", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(environmentCache.tag.byProjectId).mockReturnValue("mock-tag");
|
||||
|
||||
const result = await getEnvironments("clh6pzwx90000e9ogjr0mf7sy");
|
||||
|
||||
@@ -138,7 +119,6 @@ describe("Environment Service", () => {
|
||||
|
||||
test("should throw ResourceNotFoundError when project not found", async () => {
|
||||
vi.mocked(prisma.project.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(environmentCache.tag.byProjectId).mockReturnValue("mock-tag");
|
||||
|
||||
await expect(getEnvironments("clh6pzwx90000e9ogjr0mf7sy")).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
@@ -149,7 +129,6 @@ describe("Environment Service", () => {
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.project.findFirst).mockRejectedValue(prismaError);
|
||||
vi.mocked(environmentCache.tag.byProjectId).mockReturnValue("mock-tag");
|
||||
|
||||
await expect(getEnvironments("clh6pzwx90000e9ogjr0mf7sy")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
@@ -185,10 +164,6 @@ describe("Environment Service", () => {
|
||||
updatedAt: expect.any(Date),
|
||||
}),
|
||||
});
|
||||
expect(environmentCache.revalidate).toHaveBeenCalledWith({
|
||||
id: "clh6pzwx90000e9ogjr0mf7sx",
|
||||
projectId: "clh6pzwx90000e9ogjr0mf7sy",
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
|
||||
@@ -16,89 +16,69 @@ import {
|
||||
ZEnvironmentUpdateInput,
|
||||
} from "@formbricks/types/environment";
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||
import { cache } from "../cache";
|
||||
import { getOrganizationsByUserId } from "../organization/service";
|
||||
import { capturePosthogEnvironmentEvent } from "../posthogServer";
|
||||
import { getUserProjects } from "../project/service";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { environmentCache } from "./cache";
|
||||
|
||||
export const getEnvironment = reactCache(
|
||||
async (environmentId: string): Promise<TEnvironment | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
export const getEnvironment = reactCache(async (environmentId: string): Promise<TEnvironment | null> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
|
||||
try {
|
||||
const environment = await prisma.environment.findUnique({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
});
|
||||
return environment;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting environment");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const environment = await prisma.environment.findUnique({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
[`getEnvironment-${environmentId}`],
|
||||
{
|
||||
tags: [environmentCache.tag.byId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
});
|
||||
return environment;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting environment");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
export const getEnvironments = reactCache(
|
||||
async (projectId: string): Promise<TEnvironment[]> =>
|
||||
cache(
|
||||
async (): Promise<TEnvironment[]> => {
|
||||
validateInputs([projectId, ZId]);
|
||||
let projectPrisma;
|
||||
try {
|
||||
projectPrisma = await prisma.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
include: {
|
||||
environments: true,
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
if (!projectPrisma) {
|
||||
throw new ResourceNotFoundError("Project", projectId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const environments: TEnvironment[] = [];
|
||||
for (let environment of projectPrisma.environments) {
|
||||
let targetEnvironment: TEnvironment = ZEnvironment.parse(environment);
|
||||
environments.push(targetEnvironment);
|
||||
}
|
||||
|
||||
try {
|
||||
return environments;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.error(error, "Error getting environments");
|
||||
}
|
||||
throw new ValidationError("Data validation of environments array failed");
|
||||
}
|
||||
export const getEnvironments = reactCache(async (projectId: string): Promise<TEnvironment[]> => {
|
||||
validateInputs([projectId, ZId]);
|
||||
let projectPrisma;
|
||||
try {
|
||||
projectPrisma = await prisma.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
[`getEnvironments-${projectId}`],
|
||||
{
|
||||
tags: [environmentCache.tag.byProjectId(projectId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
include: {
|
||||
environments: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectPrisma) {
|
||||
throw new ResourceNotFoundError("Project", projectId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const environments: TEnvironment[] = [];
|
||||
for (let environment of projectPrisma.environments) {
|
||||
let targetEnvironment: TEnvironment = ZEnvironment.parse(environment);
|
||||
environments.push(targetEnvironment);
|
||||
}
|
||||
|
||||
try {
|
||||
return environments;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.error(error, "Error getting environments");
|
||||
}
|
||||
throw new ValidationError("Data validation of environments array failed");
|
||||
}
|
||||
});
|
||||
|
||||
export const updateEnvironment = async (
|
||||
environmentId: string,
|
||||
@@ -115,11 +95,6 @@ export const updateEnvironment = async (
|
||||
data: newData,
|
||||
});
|
||||
|
||||
environmentCache.revalidate({
|
||||
id: environmentId,
|
||||
projectId: updatedEnvironment.projectId,
|
||||
});
|
||||
|
||||
return updatedEnvironment;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -198,11 +173,6 @@ export const createEnvironment = async (
|
||||
},
|
||||
});
|
||||
|
||||
environmentCache.revalidate({
|
||||
id: environment.id,
|
||||
projectId: environment.projectId,
|
||||
});
|
||||
|
||||
await capturePosthogEnvironmentEvent(environment.id, "environment created", {
|
||||
environmentType: environment.type,
|
||||
});
|
||||
|
||||
@@ -1,49 +1,32 @@
|
||||
import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { organizationCache } from "@/lib/organization/cache";
|
||||
import { userCache } from "@/lib/user/cache";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
// Function to check if there are any users in the database
|
||||
export const getIsFreshInstance = reactCache(
|
||||
async (): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
try {
|
||||
const userCount = await prisma.user.count();
|
||||
if (userCount === 0) return true;
|
||||
else return false;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
["getIsFreshInstance"],
|
||||
{ tags: [userCache.tag.byCount()] }
|
||||
)()
|
||||
);
|
||||
export const getIsFreshInstance = reactCache(async (): Promise<boolean> => {
|
||||
try {
|
||||
const userCount = await prisma.user.count();
|
||||
if (userCount === 0) return true;
|
||||
else return false;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Function to check if there are any organizations in the database
|
||||
export const gethasNoOrganizations = reactCache(
|
||||
async (): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
try {
|
||||
const organizationCount = await prisma.organization.count();
|
||||
return organizationCount === 0;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
["gethasNoOrganizations"],
|
||||
{ tags: [organizationCache.tag.byCount()] }
|
||||
)()
|
||||
);
|
||||
export const gethasNoOrganizations = reactCache(async (): Promise<boolean> => {
|
||||
try {
|
||||
const organizationCount = await prisma.organization.count();
|
||||
return organizationCount === 0;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
environmentId?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export const integrationCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `integrations-${id}`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string) {
|
||||
return `environments-${environmentId}-integrations`;
|
||||
},
|
||||
byEnvironmentIdAndType(environmentId: string, type: string) {
|
||||
return `environments-${environmentId}-type-${type}-integrations`;
|
||||
},
|
||||
},
|
||||
revalidate({ id, environmentId, type }: RevalidateProps): void {
|
||||
if (id) {
|
||||
revalidateTag(this.tag.byId(id));
|
||||
}
|
||||
|
||||
if (environmentId) {
|
||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
|
||||
if (environmentId && type) {
|
||||
revalidateTag(this.tag.byEnvironmentIdAndType(environmentId, type));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -6,10 +6,8 @@ import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
|
||||
import { cache } from "../cache";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { integrationCache } from "./cache";
|
||||
|
||||
const transformIntegration = (integration: TIntegration): TIntegration => {
|
||||
return {
|
||||
@@ -47,11 +45,6 @@ export const createOrUpdateIntegration = async (
|
||||
environment: { connect: { id: environmentId } },
|
||||
},
|
||||
});
|
||||
|
||||
integrationCache.revalidate({
|
||||
environmentId,
|
||||
type: integrationData.type,
|
||||
});
|
||||
return integration;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -63,93 +56,64 @@ export const createOrUpdateIntegration = async (
|
||||
};
|
||||
|
||||
export const getIntegrations = reactCache(
|
||||
async (environmentId: string, page?: number): Promise<TIntegration[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
async (environmentId: string, page?: number): Promise<TIntegration[]> => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const integrations = await prisma.integration.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
});
|
||||
return integrations;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getIntegrations-${environmentId}-${page}`],
|
||||
{
|
||||
tags: [integrationCache.tag.byEnvironmentId(environmentId)],
|
||||
try {
|
||||
const integrations = await prisma.integration.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
});
|
||||
return integrations.map((integration) => transformIntegration(integration));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
)().then((cachedIntegration) => {
|
||||
return cachedIntegration.map((integration) => transformIntegration(integration));
|
||||
})
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getIntegration = reactCache(
|
||||
async (integrationId: string): Promise<TIntegration | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
try {
|
||||
const integration = await prisma.integration.findUnique({
|
||||
where: {
|
||||
id: integrationId,
|
||||
},
|
||||
});
|
||||
return integration;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
export const getIntegration = reactCache(async (integrationId: string): Promise<TIntegration | null> => {
|
||||
try {
|
||||
const integration = await prisma.integration.findUnique({
|
||||
where: {
|
||||
id: integrationId,
|
||||
},
|
||||
[`getIntegration-${integrationId}`],
|
||||
{
|
||||
tags: [integrationCache.tag.byId(integrationId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
});
|
||||
return integration ? transformIntegration(integration) : null;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const getIntegrationByType = reactCache(
|
||||
async (environmentId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
|
||||
async (environmentId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
|
||||
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
|
||||
|
||||
try {
|
||||
const integration = await prisma.integration.findUnique({
|
||||
where: {
|
||||
type_environmentId: {
|
||||
environmentId,
|
||||
type,
|
||||
},
|
||||
},
|
||||
});
|
||||
return integration;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getIntegrationByType-${environmentId}-${type}`],
|
||||
{
|
||||
tags: [integrationCache.tag.byEnvironmentIdAndType(environmentId, type)],
|
||||
try {
|
||||
const integration = await prisma.integration.findUnique({
|
||||
where: {
|
||||
type_environmentId: {
|
||||
environmentId,
|
||||
type,
|
||||
},
|
||||
},
|
||||
});
|
||||
return integration ? transformIntegration(integration) : null;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
)().then((cachedIntegration) => {
|
||||
if (cachedIntegration) {
|
||||
return transformIntegration(cachedIntegration);
|
||||
} else return null;
|
||||
})
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteIntegration = async (integrationId: string): Promise<TIntegration> => {
|
||||
@@ -162,12 +126,6 @@ export const deleteIntegration = async (integrationId: string): Promise<TIntegra
|
||||
},
|
||||
});
|
||||
|
||||
integrationCache.revalidate({
|
||||
id: integrationData.id,
|
||||
environmentId: integrationData.environmentId,
|
||||
type: integrationData.type,
|
||||
});
|
||||
|
||||
return integrationData;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -11,9 +11,7 @@ import {
|
||||
ZLanguageInput,
|
||||
ZLanguageUpdate,
|
||||
} from "@formbricks/types/project";
|
||||
import { projectCache } from "../project/cache";
|
||||
import { getProject } from "../project/service";
|
||||
import { surveyCache } from "../survey/cache";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
const languageSelect = {
|
||||
@@ -70,12 +68,6 @@ export const createLanguage = async (
|
||||
select: languageSelect,
|
||||
});
|
||||
|
||||
project.environments.forEach((environment) => {
|
||||
projectCache.revalidate({
|
||||
environmentId: environment.id,
|
||||
});
|
||||
});
|
||||
|
||||
return language;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -124,13 +116,6 @@ export const deleteLanguage = async (languageId: string, projectId: string): Pro
|
||||
select: { ...languageSelect, surveyLanguages: { select: { surveyId: true } } },
|
||||
});
|
||||
|
||||
project.environments.forEach((environment) => {
|
||||
projectCache.revalidate({
|
||||
id: prismaLanguage.projectId,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
});
|
||||
|
||||
// delete unused surveyLanguages
|
||||
const language = { ...prismaLanguage, surveyLanguages: undefined };
|
||||
|
||||
@@ -159,23 +144,6 @@ export const updateLanguage = async (
|
||||
select: { ...languageSelect, surveyLanguages: { select: { surveyId: true } } },
|
||||
});
|
||||
|
||||
project.environments.forEach((environment) => {
|
||||
projectCache.revalidate({
|
||||
id: prismaLanguage.projectId,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
surveyCache.revalidate({
|
||||
environmentId: environment.id,
|
||||
});
|
||||
});
|
||||
|
||||
// revalidate cache of all connected surveys
|
||||
prismaLanguage.surveyLanguages.forEach((surveyLanguage) => {
|
||||
surveyCache.revalidate({
|
||||
id: surveyLanguage.surveyId,
|
||||
});
|
||||
});
|
||||
|
||||
// delete unused surveyLanguages
|
||||
const language = { ...prismaLanguage, surveyLanguages: undefined };
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
mockProjectId,
|
||||
mockUpdatedLanguage,
|
||||
} from "./__mocks__/data.mock";
|
||||
import { projectCache } from "@/lib/project/cache";
|
||||
import { getProject } from "@/lib/project/service";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -30,12 +28,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getProject: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/project/cache", () => ({
|
||||
projectCache: { revalidate: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/lib/survey/cache", () => ({
|
||||
surveyCache: { revalidate: vi.fn() },
|
||||
}));
|
||||
|
||||
const fakeProject = {
|
||||
id: mockProjectId,
|
||||
@@ -60,8 +52,6 @@ describe("createLanguage", () => {
|
||||
vi.mocked(prisma.language.create).mockResolvedValue(mockLanguage);
|
||||
const result = await createLanguage(mockProjectId, mockLanguageInput);
|
||||
expect(result).toEqual(mockLanguage);
|
||||
// projectCache.revalidate called for each env
|
||||
expect(projectCache.revalidate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe("sad path", () => {
|
||||
@@ -95,9 +85,6 @@ describe("updateLanguage", () => {
|
||||
vi.mocked(prisma.language.update).mockResolvedValue(mockUpdatedLanguageWithSurveyLanguage);
|
||||
const result = await updateLanguage(mockProjectId, mockLanguageId, mockLanguageUpdate);
|
||||
expect(result).toEqual(mockUpdatedLanguage);
|
||||
// caches revalidated
|
||||
expect(projectCache.revalidate).toHaveBeenCalled();
|
||||
expect(surveyCache.revalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("sad path", () => {
|
||||
@@ -125,7 +112,6 @@ describe("deleteLanguage", () => {
|
||||
vi.mocked(prisma.language.delete).mockResolvedValue(mockLanguage);
|
||||
const result = await deleteLanguage(mockLanguageId, mockProjectId);
|
||||
expect(result).toEqual(mockLanguage);
|
||||
expect(projectCache.revalidate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe("sad path", () => {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export const membershipCache = {
|
||||
tag: {
|
||||
byOrganizationId(organizationId: string) {
|
||||
return `organizations-${organizationId}-memberships`;
|
||||
},
|
||||
byUserId(userId: string) {
|
||||
return `users-${userId}-memberships`;
|
||||
},
|
||||
},
|
||||
revalidate({ organizationId, userId }: RevalidateProps): void {
|
||||
if (organizationId) {
|
||||
revalidateTag(this.tag.byOrganizationId(organizationId));
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
revalidateTag(this.tag.byUserId(userId));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { membershipCache } from "./cache";
|
||||
import { createMembership, getMembershipByUserIdOrganizationId } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -16,16 +15,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./cache", () => ({
|
||||
membershipCache: {
|
||||
tag: {
|
||||
byUserId: vi.fn(),
|
||||
byOrganizationId: vi.fn(),
|
||||
},
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Membership Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -111,10 +100,6 @@ describe("Membership Service", () => {
|
||||
role: mockMembershipData.role,
|
||||
},
|
||||
});
|
||||
expect(membershipCache.revalidate).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns existing membership if role matches", async () => {
|
||||
@@ -163,10 +148,6 @@ describe("Membership Service", () => {
|
||||
role: "owner",
|
||||
},
|
||||
});
|
||||
expect(membershipCache.revalidate).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
|
||||
@@ -6,44 +6,34 @@ import { logger } from "@formbricks/logger";
|
||||
import { ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||
import { TMembership, ZMembership } from "@formbricks/types/memberships";
|
||||
import { cache } from "../cache";
|
||||
import { membershipCache } from "../membership/cache";
|
||||
import { organizationCache } from "../organization/cache";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const getMembershipByUserIdOrganizationId = reactCache(
|
||||
async (userId: string, organizationId: string): Promise<TMembership | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([userId, ZString], [organizationId, ZString]);
|
||||
async (userId: string, organizationId: string): Promise<TMembership | null> => {
|
||||
validateInputs([userId, ZString], [organizationId, ZString]);
|
||||
|
||||
try {
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) return null;
|
||||
if (!membership) return null;
|
||||
|
||||
return membership;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting membership by user id and organization id");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw new UnknownError("Error while fetching membership");
|
||||
}
|
||||
},
|
||||
[`getMembershipByUserIdOrganizationId-${userId}-${organizationId}`],
|
||||
{
|
||||
tags: [membershipCache.tag.byUserId(userId), membershipCache.tag.byOrganizationId(organizationId)],
|
||||
return membership;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting membership by user id and organization id");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
)()
|
||||
|
||||
throw new UnknownError("Error while fetching membership");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const createMembership = async (
|
||||
@@ -92,15 +82,6 @@ export const createMembership = async (
|
||||
});
|
||||
}
|
||||
|
||||
organizationCache.revalidate({
|
||||
userId,
|
||||
});
|
||||
|
||||
membershipCache.revalidate({
|
||||
userId,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
return membership;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -1,36 +1,27 @@
|
||||
import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { getMembershipByUserIdOrganizationId } from "../membership/service";
|
||||
import { getAccessFlags } from "../membership/utils";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { organizationCache } from "./cache";
|
||||
import { getOrganizationsByUserId } from "./service";
|
||||
|
||||
export const canUserAccessOrganization = (userId: string, organizationId: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([userId, ZId], [organizationId, ZId]);
|
||||
export const canUserAccessOrganization = async (userId: string, organizationId: string): Promise<boolean> => {
|
||||
validateInputs([userId, ZId], [organizationId, ZId]);
|
||||
|
||||
try {
|
||||
const userOrganizations = await getOrganizationsByUserId(userId);
|
||||
try {
|
||||
const userOrganizations = await getOrganizationsByUserId(userId);
|
||||
|
||||
const givenOrganizationExists = userOrganizations.filter(
|
||||
(organization) => (organization.id = organizationId)
|
||||
);
|
||||
if (!givenOrganizationExists) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`canUserAccessOrganization-${userId}-${organizationId}`],
|
||||
{
|
||||
tags: [organizationCache.tag.byId(organizationId)],
|
||||
const givenOrganizationExists = userOrganizations.filter(
|
||||
(organization) => (organization.id = organizationId)
|
||||
);
|
||||
if (!givenOrganizationExists) {
|
||||
return false;
|
||||
}
|
||||
)();
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyUserRoleAccess = async (
|
||||
organizationId: string,
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
environmentId?: string;
|
||||
count?: boolean;
|
||||
}
|
||||
|
||||
export const organizationCache = {
|
||||
tag: {
|
||||
byId(id: string) {
|
||||
return `organizations-${id}`;
|
||||
},
|
||||
byUserId(userId: string) {
|
||||
return `users-${userId}-organizations`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string) {
|
||||
return `environments-${environmentId}-organizations`;
|
||||
},
|
||||
byCount() {
|
||||
return "organizations-count";
|
||||
},
|
||||
},
|
||||
revalidate({ id, userId, environmentId, count }: RevalidateProps): void {
|
||||
if (id) {
|
||||
revalidateTag(this.tag.byId(id));
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
revalidateTag(this.tag.byUserId(userId));
|
||||
}
|
||||
|
||||
if (environmentId) {
|
||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
|
||||
if (count) {
|
||||
revalidateTag(this.tag.byCount());
|
||||
}
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user