chore: Comprehensive Cache Optimization & Performance Enhancement (#5926)

Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
Matti Nannt
2025-06-04 20:33:17 +02:00
committed by GitHub
parent 45fec0e184
commit c0b8edfdf2
318 changed files with 7388 additions and 12603 deletions

View File

@@ -0,0 +1,414 @@
---
description: Caching rules for performance improvements
globs:
alwaysApply: false
---
# Cache Optimization Patterns for Formbricks
## Cache Strategy Overview
Formbricks uses a **hybrid caching approach** optimized for enterprise scale:
- **Redis** for persistent cross-request caching
- **React `cache()`** for request-level deduplication
- **NO Next.js `unstable_cache()`** - avoid for reliability
## Key Files
### Core Cache Infrastructure
- [apps/web/modules/cache/lib/service.ts](mdc:apps/web/modules/cache/lib/service.ts) - Redis cache service
- [apps/web/modules/cache/lib/withCache.ts](mdc:apps/web/modules/cache/lib/withCache.ts) - Cache wrapper utilities
- [apps/web/modules/cache/lib/cacheKeys.ts](mdc:apps/web/modules/cache/lib/cacheKeys.ts) - Enterprise cache key patterns and utilities
### Environment State Caching (Critical Endpoint)
- [apps/web/app/api/v1/client/[environmentId]/environment/route.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/route.ts) - Main endpoint serving hundreds of thousands of SDK clients
- [apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts) - Optimized data layer with caching
## Enterprise-Grade Cache Key Patterns
**Always use** the `createCacheKey` utilities from [cacheKeys.ts](mdc:apps/web/modules/cache/lib/cacheKeys.ts):
```typescript
// ✅ Correct patterns
createCacheKey.environment.state(environmentId) // "fb:env:abc123:state"
createCacheKey.organization.billing(organizationId) // "fb:org:xyz789:billing"
createCacheKey.license.status(organizationId) // "fb:license:org123:status"
createCacheKey.user.permissions(userId, orgId) // "fb:user:456:org:123:permissions"
// ❌ Never use flat keys - collision-prone
"environment_abc123"
"user_data_456"
```
## When to Use Each Cache Type
### Use React `cache()` for Request Deduplication
```typescript
// ✅ Prevents multiple calls within same request
export const getEnterpriseLicense = reactCache(async () => {
// Complex license validation logic
});
```
### Use `withCache()` for Simple Database Queries
```typescript
// ✅ Simple caching with automatic fallback (TTL in milliseconds)
export const getActionClasses = (environmentId: string) => {
return withCache(() => fetchActionClassesFromDB(environmentId), {
key: createCacheKey.environment.actionClasses(environmentId),
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
})();
};
```
### Use Explicit Redis Cache for Complex Business Logic
```typescript
// ✅ Full control for high-stakes endpoints
export const getEnvironmentState = async (environmentId: string) => {
const cached = await environmentStateCache.getEnvironmentState(environmentId);
if (cached) return cached;
const fresh = await buildComplexState(environmentId);
await environmentStateCache.setEnvironmentState(environmentId, fresh);
return fresh;
};
```
## Caching Decision Framework
### When TO Add Caching
```typescript
// ✅ Expensive operations that benefit from caching
- Database queries (>10ms typical)
- External API calls (>50ms typical)
- Complex computations (>5ms)
- File system operations
- Heavy data transformations
// Example: Database query with complex joins (TTL in milliseconds)
export const getEnvironmentWithDetails = withCache(
async (environmentId: string) => {
return prisma.environment.findUnique({
where: { id: environmentId },
include: { /* complex joins */ }
});
},
{ key: createCacheKey.environment.details(environmentId), ttl: 60 * 30 * 1000 } // 30 minutes
)();
```
### When NOT to Add Caching
```typescript
// ❌ Don't cache these operations - minimal overhead
- Simple property access (<0.1ms)
- Basic transformations (<1ms)
- Functions that just call already-cached functions
- Pure computation without I/O
// ❌ Bad example: Redundant caching
const getCachedLicenseFeatures = withCache(
async () => {
const license = await getEnterpriseLicense(); // Already cached!
return license.active ? license.features : null; // Just property access
},
{ key: "license-features", ttl: 1800 * 1000 } // 30 minutes in milliseconds
);
// ✅ Good example: Simple and efficient
const getLicenseFeatures = async () => {
const license = await getEnterpriseLicense(); // Already cached
return license.active ? license.features : null; // 0.1ms overhead
};
```
### Computational Overhead Analysis
Before adding caching, analyze the overhead:
```typescript
// ✅ High overhead - CACHE IT
- Database queries: ~10-100ms
- External APIs: ~50-500ms
- File I/O: ~5-50ms
- Complex algorithms: >5ms
// ❌ Low overhead - DON'T CACHE
- Property access: ~0.001ms
- Simple lookups: ~0.1ms
- Basic validation: ~1ms
- Type checks: ~0.01ms
// Example decision tree:
const expensiveOperation = async () => {
return prisma.query(); // 50ms - CACHE IT
};
const cheapOperation = (data: any) => {
return data.property; // 0.001ms - DON'T CACHE
};
```
### Avoid Cache Wrapper Anti-Pattern
```typescript
// ❌ Don't create wrapper functions just for caching
const getCachedUserPermissions = withCache(
async (userId: string) => getUserPermissions(userId),
{ key: createCacheKey.user.permissions(userId), ttl: 3600 * 1000 } // 1 hour in milliseconds
);
// ✅ Add caching directly to the original function
export const getUserPermissions = withCache(
async (userId: string) => {
return prisma.user.findUnique({
where: { id: userId },
include: { permissions: true }
});
},
{ key: createCacheKey.user.permissions(userId), ttl: 3600 * 1000 } // 1 hour in milliseconds
);
```
## TTL Coordination Strategy
### Multi-Layer Cache Coordination
For endpoints serving client SDKs, coordinate TTLs across layers:
```typescript
// Client SDK cache (expiresAt) - longest TTL for fewer requests
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
// Server Redis cache - shorter TTL ensures fresh data for clients
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
// HTTP cache headers (seconds)
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
```
### Standard TTL Guidelines (in milliseconds for cache-manager + Keyv)
```typescript
// Configuration data - rarely changes
const CONFIG_TTL = 60 * 60 * 24 * 1000; // 24 hours
// User data - moderate frequency
const USER_TTL = 60 * 60 * 2 * 1000; // 2 hours
// Survey data - changes moderately
const SURVEY_TTL = 60 * 15 * 1000; // 15 minutes
// Billing data - expensive to compute
const BILLING_TTL = 60 * 30 * 1000; // 30 minutes
// Action classes - infrequent changes
const ACTION_CLASS_TTL = 60 * 30 * 1000; // 30 minutes
```
## High-Frequency Endpoint Optimization
### Performance Patterns for High-Volume Endpoints
```typescript
// ✅ Optimized high-frequency endpoint pattern
export const GET = async (request: NextRequest, props: { params: Promise<{ id: string }> }) => {
const params = await props.params;
try {
// Simple validation (avoid Zod for high-frequency)
if (!params.id || typeof params.id !== 'string') {
return responses.badRequestResponse("ID is required", undefined, true);
}
// Single optimized query with caching
const data = await getOptimizedData(params.id);
return responses.successResponse(
{
data,
expiresAt: new Date(Date.now() + CLIENT_TTL * 1000), // SDK cache duration
},
true,
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
);
} catch (err) {
// Simplified error handling for performance
if (err instanceof ResourceNotFoundError) {
return responses.notFoundResponse(err.resourceType, err.resourceId);
}
logger.error({ error: err, url: request.url }, "Error in high-frequency endpoint");
return responses.internalServerErrorResponse(err.message, true);
}
};
```
### Avoid These Performance Anti-Patterns
```typescript
// ❌ Avoid for high-frequency endpoints
const inputValidation = ZodSchema.safeParse(input); // Too slow
const startTime = Date.now(); logger.debug(...); // Logging overhead
const { data, revalidateEnvironment } = await get(); // Complex return types
```
### CORS Optimization
```typescript
// ✅ Balanced CORS caching (not too aggressive)
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
true,
"public, s-maxage=3600, max-age=3600" // 1 hour balanced approach
);
};
```
## Redis Cache Migration from Next.js
### Avoid Legacy Next.js Patterns
```typescript
// ❌ Old Next.js unstable_cache pattern (avoid)
const getCachedData = unstable_cache(
async (id) => fetchData(id),
['cache-key'],
{ tags: ['environment'], revalidate: 900 }
);
// ❌ Don't use revalidateEnvironment flags with Redis
return { data, revalidateEnvironment: true }; // This gets cached incorrectly!
// ✅ New Redis pattern with withCache (TTL in milliseconds)
export const getCachedData = (id: string) =>
withCache(
() => fetchData(id),
{
key: createCacheKey.environment.data(id),
ttl: 60 * 15 * 1000, // 15 minutes in milliseconds
}
)();
```
### Remove Revalidation Logic
When migrating from Next.js `unstable_cache`:
- Remove `revalidateEnvironment` or similar flags
- Remove tag-based invalidation logic
- Use TTL-based expiration instead
- Handle one-time updates (like `appSetupCompleted`) directly in cache
## Data Layer Optimization
### Single Query Pattern
```typescript
// ✅ Optimize with single database query
export const getOptimizedEnvironmentData = async (environmentId: string) => {
return prisma.environment.findUniqueOrThrow({
where: { id: environmentId },
include: {
project: {
select: { id: true, recontactDays: true, /* ... */ }
},
organization: {
select: { id: true, billing: true }
},
surveys: {
where: { status: "inProgress" },
select: { id: true, name: true, /* ... */ }
},
actionClasses: {
select: { id: true, name: true, /* ... */ }
}
}
});
};
// ❌ Avoid multiple separate queries
const environment = await getEnvironment(id);
const organization = await getOrganization(environment.organizationId);
const surveys = await getSurveys(id);
const actionClasses = await getActionClasses(id);
```
## Invalidation Best Practices
**Always use explicit key-based invalidation:**
```typescript
// ✅ Clear and debuggable
await invalidateCache(createCacheKey.environment.state(environmentId));
await invalidateCache([
createCacheKey.environment.surveys(environmentId),
createCacheKey.environment.actionClasses(environmentId)
]);
// ❌ Avoid complex tag systems
await invalidateByTags(["environment", "survey"]); // Don't do this
```
## Critical Performance Targets
### High-Frequency Endpoint Goals
- **Cache hit ratio**: >85%
- **Response time P95**: <200ms
- **Database load reduction**: >60%
- **HTTP cache duration**: 1hr browser, 30min Cloudflare
- **SDK refresh interval**: 1 hour with 30min server cache
### Performance Monitoring
- Use **existing elastic cache analytics** for metrics
- Log cache errors and warnings (not debug info)
- Track database query reduction
- Monitor response times for cached endpoints
- **Avoid performance logging** in high-frequency endpoints
## Error Handling Pattern
Always provide fallback to fresh data on cache errors:
```typescript
try {
const cached = await cache.get(key);
if (cached) return cached;
const fresh = await fetchFresh();
await cache.set(key, fresh, ttl); // ttl in milliseconds
return fresh;
} catch (error) {
// ✅ Always fallback to fresh data
logger.warn("Cache error, fetching fresh", { key, error });
return fetchFresh();
}
```
## Common Pitfalls to Avoid
1. **Never use Next.js `unstable_cache()`** - unreliable in production
2. **Don't use revalidation flags with Redis** - they get cached incorrectly
3. **Avoid Zod validation** for simple parameters in high-frequency endpoints
4. **Don't add performance logging** to high-frequency endpoints
5. **Coordinate TTLs** between client and server caches
6. **Don't over-engineer** with complex tag systems
7. **Avoid caching rapidly changing data** (real-time metrics)
8. **Always validate cache keys** to prevent collisions
9. **Don't add redundant caching layers** - analyze computational overhead first
10. **Avoid cache wrapper functions** - add caching directly to expensive operations
11. **Don't cache property access or simple transformations** - overhead is negligible
12. **Analyze the full call chain** before adding caching to avoid double-caching
13. **Remember TTL is in milliseconds** for cache-manager + Keyv stack (not seconds)
## Monitoring Strategy
- Use **existing elastic cache analytics** for metrics
- Log cache errors and warnings
- Track database query reduction
- Monitor response times for cached endpoints
- **Don't add custom metrics** that duplicate existing monitoring
## Important Notes
### TTL Units
- **cache-manager + Keyv**: TTL in **milliseconds**
- **Direct Redis commands**: TTL in **seconds** (EXPIRE, SETEX) or **milliseconds** (PEXPIRE, PSETEX)
- **HTTP cache headers**: TTL in **seconds** (max-age, s-maxage)
- **Client SDK**: TTL in **seconds** (expiresAt calculation)

View File

@@ -0,0 +1,152 @@
---
description:
globs:
alwaysApply: false
---
# EKS & ALB Optimization Guide for Error Reduction
## Infrastructure Overview
This project uses AWS EKS with Application Load Balancer (ALB) for the Formbricks application. The infrastructure has been optimized to minimize ELB 502/504 errors through careful configuration of connection handling, health checks, and pod lifecycle management.
## Key Infrastructure Files
### Terraform Configuration
- **Main Infrastructure**: [infra/terraform/main.tf](mdc:infra/terraform/main.tf) - EKS cluster, VPC, Karpenter, and core AWS resources
- **Monitoring**: [infra/terraform/cloudwatch.tf](mdc:infra/terraform/cloudwatch.tf) - CloudWatch alarms for 502/504 error tracking and alerting
- **Database**: [infra/terraform/rds.tf](mdc:infra/terraform/rds.tf) - Aurora PostgreSQL configuration
### Helm Configuration
- **Production**: [infra/formbricks-cloud-helm/values.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values.yaml.gotmpl) - Optimized ALB and pod configurations
- **Staging**: [infra/formbricks-cloud-helm/values-staging.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values-staging.yaml.gotmpl) - Staging environment with spot instances
- **Deployment**: [infra/formbricks-cloud-helm/helmfile.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/helmfile.yaml.gotmpl) - Multi-environment Helm releases
## ALB Optimization Patterns
### Connection Handling Optimizations
```yaml
# Key ALB annotations for reducing 502/504 errors
alb.ingress.kubernetes.io/load-balancer-attributes: |
idle_timeout.timeout_seconds=120,
connection_logs.s3.enabled=false,
access_logs.s3.enabled=false
alb.ingress.kubernetes.io/target-group-attributes: |
deregistration_delay.timeout_seconds=30,
stickiness.enabled=false,
load_balancing.algorithm.type=least_outstanding_requests,
target_group_health.dns_failover.minimum_healthy_targets.count=1
```
### Health Check Configuration
- **Interval**: 15 seconds for faster detection of unhealthy targets
- **Timeout**: 5 seconds to prevent false positives
- **Thresholds**: 2 healthy, 3 unhealthy for balanced responsiveness
- **Path**: `/health` endpoint optimized for < 100ms response time
## Pod Lifecycle Management
### Graceful Shutdown Pattern
```yaml
# PreStop hook to allow connection draining
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
# Termination grace period for complete cleanup
terminationGracePeriodSeconds: 45
```
### Health Probe Strategy
- **Startup Probe**: 5s initial delay, 5s interval, max 60s startup time
- **Readiness Probe**: 10s delay, 10s interval for traffic readiness
- **Liveness Probe**: 30s delay, 30s interval for container health
### Rolling Update Configuration
```yaml
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 25% # Maintain capacity during updates
maxSurge: 50% # Allow faster rollouts
```
## Karpenter Node Management
### Node Lifecycle Optimization
- **Startup Taints**: Prevent traffic during node initialization
- **Graceful Shutdown**: 30s grace period for pod eviction
- **Consolidation Delay**: 60s to reduce unnecessary churn
- **Eviction Policies**: Configured for smooth pod migrations
### Instance Selection
- **Families**: c8g, c7g, m8g, m7g, r8g, r7g (ARM64 Graviton)
- **Sizes**: 2, 4, 8 vCPUs for cost optimization
- **Bottlerocket AMI**: Enhanced security and performance
## Monitoring & Alerting
### Critical ALB Metrics
1. **ELB 502 Errors**: Threshold 20 over 5 minutes
2. **ELB 504 Errors**: Threshold 15 over 5 minutes
3. **Target Connection Errors**: Threshold 50 over 5 minutes
4. **4XX Errors**: Threshold 100 over 10 minutes (client issues)
### Expected Improvements
- **60-80% reduction** in ELB 502 errors
- **Faster recovery** during pod restarts
- **Better connection reuse** efficiency
- **Improved autoscaling** responsiveness
## Deployment Patterns
### Infrastructure Updates
1. **Terraform First**: Apply infrastructure changes via [infra/deploy-improvements.sh](mdc:infra/deploy-improvements.sh)
2. **Helm Second**: Deploy application configurations
3. **Verification**: Check pod status, endpoints, and ALB health
4. **Monitoring**: Watch CloudWatch metrics for 24-48 hours
### Environment-Specific Configurations
- **Production**: On-demand instances, stricter resource limits
- **Staging**: Spot instances, rate limiting disabled, relaxed resources
## Troubleshooting Patterns
### 502 Error Investigation
1. Check pod readiness and health probe status
2. Verify ALB target group health
3. Review deregistration timing during deployments
4. Monitor connection pool utilization
### 504 Error Analysis
1. Check application response times
2. Verify timeout configurations (ALB: 120s, App: aligned)
3. Review database query performance
4. Monitor resource utilization during traffic spikes
### Connection Error Patterns
1. Verify Karpenter node lifecycle timing
2. Check pod termination grace periods
3. Review ALB connection draining settings
4. Monitor cluster autoscaling events
## Best Practices
### When Making Changes
- **Test in staging first** with same configurations
- **Monitor metrics** for 24-48 hours after changes
- **Use gradual rollouts** with proper health checks
- **Maintain ALB timeout alignment** across all layers
### Performance Optimization
- **Health endpoint** should respond < 100ms consistently
- **Connection pooling** aligned with ALB idle timeouts
- **Resource requests/limits** tuned for consistent performance
- **Graceful shutdown** implemented in application code
### Monitoring Strategy
- **Real-time alerts** for error rate spikes
- **Trend analysis** for connection patterns
- **Capacity planning** based on LCU usage
- **4XX pattern analysis** for client behavior insights

View File

@@ -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

View File

@@ -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();

View File

@@ -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;
}
}
);

View File

@@ -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();

View File

@@ -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
});
});

View File

@@ -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;
}
});

View File

@@ -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);
});
});

View File

@@ -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;
}
};

View File

@@ -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);

View File

@@ -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;
});

View File

@@ -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");

View File

@@ -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);

View File

@@ -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;
}
}
);

View File

@@ -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);

View File

@@ -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

View File

@@ -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.`,
});

View File

@@ -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) => {

View File

@@ -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();
});

View File

@@ -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;
}
);

View File

@@ -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([]);

View File

@@ -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;
}
}
);

View File

@@ -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;
}
);

View File

@@ -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) {

View File

@@ -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> => {

View File

@@ -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`] }
);
});
});

View File

@@ -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)],
}
)()
);

View File

@@ -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;
}
};

View File

@@ -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
});
});
});

View File

@@ -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();
};

View File

@@ -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);
});
});

View File

@@ -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)],
}
)()
);

View File

@@ -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();
});
});

View File

@@ -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)],
}
)()
);

View File

@@ -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);
}
};

View File

@@ -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();
});

View File

@@ -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,
};
}
);

View File

@@ -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(),
}));

View File

@@ -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;

View File

@@ -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> => {

View File

@@ -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"
);
};

View File

@@ -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

View File

@@ -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}`);
}
});

View File

@@ -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)],
}
);
});
});

View File

@@ -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,
};
}
);

View File

@@ -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();
});
});
});

View File

@@ -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;
}
}
);

View File

@@ -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();
});

View File

@@ -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) {

View File

@@ -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
});
});

View File

@@ -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;
}
}
);

View File

@@ -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();
});
});

View File

@@ -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;
}
};

View File

@@ -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();
});
});

View File

@@ -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;
}
};

View File

@@ -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)],
});
});
});

View File

@@ -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;
});

View File

@@ -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();
});
});

View File

@@ -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) {

View File

@@ -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> => {

View File

@@ -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();
});
});

View File

@@ -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,
};
});

View File

@@ -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";

View File

@@ -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;
}
}
);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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> => {

View File

@@ -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);
}

View File

@@ -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));
}
},
};

View File

@@ -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 () => {

View File

@@ -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 (

View File

@@ -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);
};
};

View File

@@ -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));
}
},
};

View File

@@ -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));
}
},
};

View File

@@ -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));
}
},
};

View File

@@ -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));
}
},
};

View File

@@ -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));
}
},
};

View File

@@ -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));
}
},
};

View File

@@ -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());
}
},
};

View File

@@ -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));
}
},
};

View File

@@ -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));
}
},
};

View File

@@ -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));
}
},
};

View File

@@ -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;
}

View File

@@ -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));
}
},
};

View File

@@ -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) {

View File

@@ -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;
}
};

View File

@@ -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));
}
},
};

View File

@@ -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 () => {

View File

@@ -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,
});

View File

@@ -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;
}
});

View File

@@ -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));
}
},
};

View File

@@ -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) {

View File

@@ -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 };

View File

@@ -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", () => {

View File

@@ -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));
}
},
};

View File

@@ -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 () => {

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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