Compare commits

..

47 Commits

Author SHA1 Message Date
Dhruwang
d8d302dffe fix survey auto close 2025-05-27 09:34:35 +05:30
Jakob Schott
e9c45daf71 updated tests 2025-05-26 18:40:46 +02:00
Johannes
259f219eea Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks 2025-05-26 23:28:46 +07:00
Dhruwang
828ce86764 fixed extra card visible issue 2025-05-23 15:51:54 +05:30
Dhruwang
f4fe8674fd tweaks 2025-05-23 15:43:48 +05:30
Dhruwang
25e4575172 Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks 2025-05-23 11:40:38 +05:30
Jakob Schott
acb6db4939 Adapted test to accomedate that checkboxes use labels instead of text now 2025-05-22 10:00:59 +02:00
Jakob Schott
19221a65d7 Changed tests for NPS and Rating question, to accomedate new required preset in editor 2025-05-22 08:46:01 +02:00
Jakob Schott
a65cd855dc Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks 2025-05-21 19:01:40 +02:00
Jakob Schott
dabd5d361f removed unnecessary interactions form tests, to accomedate default required value 2025-05-21 19:01:32 +02:00
Jakob Schott
dc47586813 Removed icon component and used svg to not fail tests 2025-05-21 18:14:10 +02:00
Jakob Schott
be1a6f01c3 Adapted tests to accomedate new editor (not-requierd preselected for surveys) 2025-05-21 17:07:37 +02:00
Jakob Schott
8dbad6f5ef Fixed build errors 2025-05-21 15:37:24 +02:00
Jakob Schott
f75e5bc7b4 Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks 2025-05-21 15:25:56 +02:00
Jakob Schott
9930f9af03 restructured image in pictureselection, to make image download link visible. changed icon 2025-05-21 15:25:18 +02:00
Jakob Schott
3ff20d04e0 Added y-margin to endingcard for in-app surveys to preserve height. ScrollableContainer accepts CSS classes 2025-05-21 13:57:03 +02:00
Jakob Schott
8bac86638c Fixed height for welcome card. moved timetofinish and responseCount in scrollable container. removed unnecessary margin for Submitbutton 2025-05-21 13:28:10 +02:00
Johannes
100b15ac88 tweaks 2025-05-21 12:33:25 +07:00
Jakob Schott
ddb1de95c4 minor bug in tests, accomedating new component structure 2025-05-20 16:41:38 +02:00
Jakob Schott
913a6b5135 adapted failing tests on Rating and NPS to accomedate for new handling of required questions 2025-05-20 12:42:08 +02:00
Jakob Schott
8b56786be5 Updated test to reflect code changes 2025-05-19 23:20:18 +02:00
Jakob Schott
a8a8cf6c88 Updated tests 2025-05-19 22:44:33 +02:00
Jakob Schott
dc0cc5e526 Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks 2025-05-19 22:13:15 +02:00
Jakob Schott
00e0307c81 Several fixed too smoothen animations when changing cards 2025-05-19 22:02:36 +02:00
Jakob Schott
84d4c59532 fix for in-product survey card height 2025-05-19 20:43:02 +02:00
Jakob Schott
fba455c47f fixed left over card that was visible in product by setting its opacity to 0 2025-05-19 15:00:30 +02:00
Johannes
fd777ca227 blind tweak 2025-05-17 14:44:46 +07:00
Johannes
512e9fb0a7 add autofocus to date question 2025-05-17 14:42:31 +07:00
Johannes
d9be37a134 fix survey package build 2025-05-17 14:40:04 +07:00
Johannes
187e509b41 Update authOptions.ts 2025-05-17 00:26:06 -07:00
Johannes
9499e6265b Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks 2025-05-17 14:24:14 +07:00
Johannes
3564faa638 tweaks 2025-05-17 14:23:47 +07:00
Johannes
5356ce4ed2 make questions default optional, sonarqube less verbose 2025-05-17 12:13:58 +07:00
Johannes
03ddf3d09a finish link survey tweaks 2025-05-17 12:02:05 +07:00
Jakob Schott
31496ee092 adapted test to include additional card in the stack 2025-05-15 15:43:02 +02:00
Jakob Schott
0f2b5e1709 Merge branch 'main' of https://github.com/formbricks/formbricks into survey-height-tweaks 2025-05-15 15:36:24 +02:00
Jakob Schott
b5a0b165ed Fixed mobile view for link survey 2025-05-15 15:36:08 +02:00
Jakob Schott
25f8b2d07f added constraints based on viewport height 2025-05-15 14:35:12 +02:00
Jakob Schott
0b88e58dcb working and smooth animation 2025-05-15 13:55:49 +02:00
Jakob Schott
89985d4f4f Reduced height of surveys for previews and handled overflow, if survey height exceeds preview height 2025-05-15 09:24:06 +02:00
Jakob Schott
39e5518f2c updated test files 2025-05-14 22:19:15 +02:00
Jakob Schott
1954c5ca61 Fix for mobile views padding 2025-05-14 22:04:40 +02:00
Jakob Schott
b7679aa336 CSSProperties to handle sizing based on survey type 2025-05-14 21:49:11 +02:00
Johannes
9e7c1d5245 fix height and reduce motion distance 2025-05-14 21:41:55 +07:00
Johannes
4f9f064e76 Update packages/surveys/src/components/wrappers/scrollable-container.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-14 06:44:28 -07:00
Jakob Schott
6a9833dbeb smoothend animations for top aligned link-surveys 2025-05-14 10:53:27 +02:00
Johannes
94070774ad tweaks survey height 2025-05-13 10:59:19 +07:00
714 changed files with 20036 additions and 22406 deletions

View File

@@ -1,61 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Build & Deployment Best Practices
## Build Process
### Running Builds
- Use `pnpm build` from project root for full build
- Monitor for React hooks warnings and fix them immediately
- Ensure all TypeScript errors are resolved before deployment
### Common Build Issues & Fixes
#### React Hooks Warnings
- Capture ref values in variables within useEffect cleanup
- Avoid accessing `.current` directly in cleanup functions
- Pattern for fixing ref cleanup warnings:
```typescript
useEffect(() => {
const currentRef = myRef.current;
return () => {
if (currentRef) {
currentRef.cleanup();
}
};
}, []);
```
#### Test Failures During Build
- Ensure all test mocks include required constants like `SESSION_MAX_AGE`
- Mock Next.js navigation hooks properly: `useParams`, `useRouter`, `useSearchParams`
- Remove unused imports and constants from test files
- Use literal values instead of imported constants when the constant isn't actually needed
### Test Execution
- Run `pnpm test` to execute all tests
- Use `pnpm test -- --run filename.test.tsx` for specific test files
- Fix test failures before merging code
- Ensure 100% test coverage for new components
### Performance Monitoring
- Monitor build times and optimize if necessary
- Watch for memory usage during builds
- Use proper caching strategies for faster rebuilds
### Deployment Checklist
1. All tests passing
2. Build completes without warnings
3. TypeScript compilation successful
4. No linter errors
5. Database migrations applied (if any)
6. Environment variables configured
### EKS Deployment Considerations
- Ensure latest code is deployed to all pods
- Monitor AWS RDS Performance Insights for database issues
- Verify environment-specific configurations
- Check pod health and resource usage

View File

@@ -1,414 +0,0 @@
---
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

@@ -1,41 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Database Performance & Prisma Best Practices
## Critical Performance Rules
### Response Count Queries
- **NEVER** use `skip`/`offset` with `prisma.response.count()` - this causes expensive subqueries with OFFSET
- Always use only `where` clauses for count operations: `prisma.response.count({ where: { ... } })`
- For pagination, separate count queries from data queries
- Reference: [apps/web/lib/response/service.ts](mdc:apps/web/lib/response/service.ts) line 654-686
### Prisma Query Optimization
- Use proper indexes defined in [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
- Leverage existing indexes: `@@index([surveyId, createdAt])`, `@@index([createdAt])`
- Use cursor-based pagination for large datasets instead of offset-based
- Cache frequently accessed data using React Cache and custom cache tags
### Date Range Filtering
- When filtering by `createdAt`, always use indexed queries
- Combine with `surveyId` for optimal performance: `{ surveyId, createdAt: { gte: start, lt: end } }`
- Avoid complex WHERE clauses that can't utilize indexes
### Count vs Data Separation
- Always separate count queries from data fetching queries
- Use `Promise.all()` to run count and data queries in parallel
- Example pattern from [apps/web/modules/api/v2/management/responses/lib/response.ts](mdc:apps/web/modules/api/v2/management/responses/lib/response.ts):
```typescript
const [responses, totalCount] = await Promise.all([
prisma.response.findMany(query),
prisma.response.count({ where: whereClause }),
]);
```
### Monitoring & Debugging
- Monitor AWS RDS Performance Insights for problematic queries
- Look for queries with OFFSET in count operations - these indicate performance issues
- Use proper error handling with `DatabaseError` for Prisma exceptions

View File

@@ -1,101 +0,0 @@
---
description: >
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
and data patterns. It should be used **only when the agent explicitly requests database schema-level
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
globs: []
alwaysApply: agent-requested
---
# Formbricks Database Schema Reference
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
## Database Overview
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
### Core Hierarchy
```
Organization
└── Project
└── Environment (production/development)
├── Survey
├── Contact
├── ActionClass
└── Integration
```
## Schema Reference
For the complete and up-to-date database schema, please refer to:
- Main schema: `packages/database/schema.prisma`
- JSON type definitions: `packages/database/json-types.ts`
The schema.prisma file contains all model definitions, relationships, enums, and field types. The json-types.ts file contains TypeScript type definitions for JSON fields.
## Data Access Patterns
### Multi-tenancy
- All data is scoped by Organization
- Environment-level isolation for surveys and contacts
- Project-level grouping for related surveys
### Soft Deletion
Some models use soft deletion patterns:
- Check `isActive` fields where present
- Use proper filtering in queries
### Cascading Deletes
Configured cascade relationships:
- Organization deletion cascades to all child entities
- Survey deletion removes responses, displays, triggers
- Contact deletion removes attributes and responses
## Common Query Patterns
### Survey with Responses
```typescript
// Include response count and latest responses
const survey = await prisma.survey.findUnique({
where: { id: surveyId },
include: {
responses: {
take: 10,
orderBy: { createdAt: 'desc' }
},
_count: {
select: { responses: true }
}
}
});
```
### Environment Scoping
```typescript
// Always scope by environment
const surveys = await prisma.survey.findMany({
where: {
environmentId: environmentId,
// Additional filters...
}
});
```
### Contact with Attributes
```typescript
const contact = await prisma.contact.findUnique({
where: { id: contactId },
include: {
attributes: {
include: {
attributeKey: true
}
}
}
});
```
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.

View File

@@ -1,24 +0,0 @@
---
description: Guideline for writing end-user facing documentation in the apps/docs folder
globs:
alwaysApply: false
---
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
---
title: "FEATURE NAME"
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
icon: "link"
---
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
- In all headlines, only capitalize the current feature and nothing else, NO Camel Case
- Update the mint.json file and add the nav item at the correct position corresponding to where the MDX file is located
- If a feature is part of the Enterprise Edition, use this note:
<Note>
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license).
</Note>

View File

@@ -1,152 +0,0 @@
---
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

@@ -1,334 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Formbricks Architecture & Patterns
## Monorepo Structure
### Apps Directory
- `apps/web/` - Main Next.js web application
- `packages/` - Shared packages and utilities
### Key Directories in Web App
```
apps/web/
├── app/ # Next.js 13+ app directory
│ ├── (app)/ # Main application routes
│ ├── (auth)/ # Authentication routes
│ ├── api/ # API routes
│ └── share/ # Public sharing routes
├── components/ # Shared components
├── lib/ # Utility functions and services
└── modules/ # Feature-specific modules
```
## Routing Patterns
### App Router Structure
The application uses Next.js 13+ app router with route groups:
```
(app)/environments/[environmentId]/
├── surveys/[surveyId]/
│ ├── (analysis)/ # Analysis views
│ │ ├── responses/ # Response management
│ │ ├── summary/ # Survey summary
│ │ └── hooks/ # Analysis-specific hooks
│ ├── edit/ # Survey editing
│ └── settings/ # Survey settings
```
### Dynamic Routes
- `[environmentId]` - Environment-specific routes
- `[surveyId]` - Survey-specific routes
- `[sharingKey]` - Public sharing routes
## Service Layer Pattern
### Service Organization
Services are organized by domain in `apps/web/lib/`:
```typescript
// Example: Response service
// apps/web/lib/response/service.ts
export const getResponseCountAction = async ({
surveyId,
filterCriteria,
}: {
surveyId: string;
filterCriteria: any;
}) => {
// Service implementation
};
```
### Action Pattern
Server actions follow a consistent pattern:
```typescript
// Action wrapper for service calls
export const getResponseCountAction = async (params) => {
try {
const result = await responseService.getCount(params);
return { data: result };
} catch (error) {
return { error: error.message };
}
};
```
## Context Patterns
### Provider Structure
Context providers follow a consistent pattern:
```typescript
// Provider component
export const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) => {
const [selectedFilter, setSelectedFilter] = useState(defaultFilter);
const value = {
selectedFilter,
setSelectedFilter,
// ... other state and methods
};
return (
<ResponseFilterContext.Provider value={value}>
{children}
</ResponseFilterContext.Provider>
);
};
// Hook for consuming context
export const useResponseFilter = () => {
const context = useContext(ResponseFilterContext);
if (!context) {
throw new Error('useResponseFilter must be used within ResponseFilterProvider');
}
return context;
};
```
### Context Composition
Multiple contexts are often composed together:
```typescript
// Layout component with multiple providers
export default function AnalysisLayout({ children }: { children: React.ReactNode }) {
return (
<ResponseFilterProvider>
<ResponseCountProvider>
{children}
</ResponseCountProvider>
</ResponseFilterProvider>
);
}
```
## Component Patterns
### Page Components
Page components are located in the app directory and follow this pattern:
```typescript
// apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx
export default function ResponsesPage() {
return (
<div>
<ResponsesTable />
<ResponsesPagination />
</div>
);
}
```
### Component Organization
- **Pages** - Route components in app directory
- **Components** - Reusable UI components
- **Modules** - Feature-specific components and logic
### Shared Components
Common components are in `apps/web/components/`:
- UI components (buttons, inputs, modals)
- Layout components (headers, sidebars)
- Data display components (tables, charts)
## Hook Patterns
### Custom Hook Structure
Custom hooks follow consistent patterns:
```typescript
export const useResponseCount = ({
survey,
initialCount
}: {
survey: TSurvey;
initialCount?: number;
}) => {
const [responseCount, setResponseCount] = useState(initialCount ?? 0);
const [isLoading, setIsLoading] = useState(false);
// Hook logic...
return {
responseCount,
isLoading,
refetch,
};
};
```
### Hook Dependencies
- Use context hooks for shared state
- Implement proper cleanup with AbortController
- Optimize dependency arrays to prevent unnecessary re-renders
## Data Fetching Patterns
### Server Actions
The app uses Next.js server actions for data fetching:
```typescript
// Server action
export async function getResponsesAction(params: GetResponsesParams) {
const responses = await getResponses(params);
return { data: responses };
}
// Client usage
const { data } = await getResponsesAction(params);
```
### Error Handling
Consistent error handling across the application:
```typescript
try {
const result = await apiCall();
return { data: result };
} catch (error) {
console.error("Operation failed:", error);
return { error: error.message };
}
```
## Type Safety
### Type Organization
Types are organized in packages:
- `@formbricks/types` - Shared type definitions
- Local types in component/hook files
### Common Types
```typescript
import { TSurvey } from "@formbricks/types/surveys/types";
import { TResponse } from "@formbricks/types/responses";
import { TEnvironment } from "@formbricks/types/environment";
```
## State Management
### Local State
- Use `useState` for component-specific state
- Use `useReducer` for complex state logic
- Use refs for mutable values that don't trigger re-renders
### Global State
- React Context for feature-specific shared state
- URL state for filters and pagination
- Server state through server actions
## Performance Considerations
### Code Splitting
- Dynamic imports for heavy components
- Route-based code splitting with app router
- Lazy loading for non-critical features
### Caching Strategy
- Server-side caching for database queries
- Client-side caching with React Query (where applicable)
- Static generation for public pages
## Testing Strategy
### Test Organization
```
component/
├── Component.tsx
├── Component.test.tsx
└── hooks/
├── useHook.ts
└── useHook.test.tsx
```
### Test Patterns
- Unit tests for utilities and services
- Integration tests for components with context
- Hook tests with proper mocking
## Build & Deployment
### Build Process
- TypeScript compilation
- Next.js build optimization
- Asset optimization and bundling
### Environment Configuration
- Environment-specific configurations
- Feature flags for gradual rollouts
- Database connection management
## Security Patterns
### Authentication
- Session-based authentication
- Environment-based access control
- API route protection
### Data Validation
- Input validation on both client and server
- Type-safe API contracts
- Sanitization of user inputs
## Monitoring & Observability
### Error Tracking
- Client-side error boundaries
- Server-side error logging
- Performance monitoring
### Analytics
- User interaction tracking
- Performance metrics
- Database query monitoring
## Best Practices Summary
### Code Organization
- ✅ Follow the established directory structure
- ✅ Use consistent naming conventions
- ✅ Separate concerns (UI, logic, data)
- ✅ Keep components focused and small
### Performance
- ✅ Implement proper loading states
- ✅ Use AbortController for async operations
- ✅ Optimize database queries
- ✅ Implement proper caching strategies
### Type Safety
- ✅ Use TypeScript throughout
- ✅ Define proper interfaces for props
- ✅ Use type guards for runtime validation
- ✅ Leverage shared type packages
### Testing
- ✅ Write tests for critical functionality
- ✅ Mock external dependencies properly
- ✅ Test error scenarios and edge cases
- ✅ Maintain good test coverage

View File

@@ -1,5 +0,0 @@
---
description:
globs:
alwaysApply: false
---

View File

@@ -1,52 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# React Context & Provider Patterns
## Context Provider Best Practices
### Provider Implementation
- Use TypeScript interfaces for provider props with optional `initialCount` for testing
- Implement proper cleanup in `useEffect` to avoid React hooks warnings
- Reference: [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx)
### Cleanup Pattern for Refs
```typescript
useEffect(() => {
const currentPendingRequests = pendingRequests.current;
const currentAbortController = abortController.current;
return () => {
if (currentAbortController) {
currentAbortController.abort();
}
currentPendingRequests.clear();
};
}, []);
```
### Testing Context Providers
- Always wrap components using context in the provider during tests
- Use `initialCount` prop for predictable test scenarios
- Mock context dependencies like `useParams`, `useResponseFilter`
- Example from [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx):
```typescript
render(
<ResponseCountProvider survey={dummySurvey} initialCount={5}>
<ComponentUnderTest />
</ResponseCountProvider>
);
```
### Required Mocks for Context Testing
- Mock `next/navigation` with `useParams` returning environment and survey IDs
- Mock response filter context and actions
- Mock API actions that the provider depends on
### Context Hook Usage
- Create custom hooks like `useResponseCountContext()` for consuming context
- Provide meaningful error messages when context is used outside provider
- Use context for shared state that multiple components need to access

View File

@@ -1,5 +0,0 @@
---
description:
globs:
alwaysApply: false
---

View File

@@ -1,327 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Testing Patterns & Best Practices
## Running Tests
### Test Commands
From the **root directory** (formbricks/):
- `npm test` - Run all tests across all packages (recommended for CI/full testing)
- `npm run test:coverage` - Run all tests with coverage reports
- `npm run test:e2e` - Run end-to-end tests with Playwright
From the **apps/web directory** (apps/web/):
- `npm run test` - Run only web app tests (fastest for development)
- `npm run test:coverage` - Run web app tests with coverage
- `npm run test -- <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
- Use `.test.tsx` for React component/hook tests (runs in jsdom environment)
- Use `.test.ts` for utility/service tests (runs in Node environment)
- The vitest config uses `environmentMatchGlobs` to automatically set jsdom for `.tsx` files
### Test Structure
```typescript
// Import the mocked functions first
import { useHook } from "@/path/to/hook";
import { serviceFunction } from "@/path/to/service";
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
// Mock dependencies
vi.mock("@/path/to/hook", () => ({
useHook: vi.fn(),
}));
describe("ComponentName", () => {
beforeEach(() => {
vi.clearAllMocks();
// Setup default mocks
});
test("descriptive test name", async () => {
// Test implementation
});
});
```
## React Hook Testing
### Context Mocking
When testing hooks that use React Context:
```typescript
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: {
filter: [],
onlyComplete: false,
},
setSelectedFilter: vi.fn(),
selectedOptions: {
questionOptions: [],
questionFilterOptions: [],
},
setSelectedOptions: vi.fn(),
dateRange: { from: new Date(), to: new Date() },
setDateRange: vi.fn(),
resetState: vi.fn(),
});
```
### Testing Async Hooks
- Always use `waitFor` for async operations
- Test both loading and completed states
- Verify API calls with correct parameters
```typescript
test("fetches data on mount", async () => {
const { result } = renderHook(() => useHook());
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBe(expectedData);
expect(vi.mocked(apiCall)).toHaveBeenCalledWith(expectedParams);
});
```
### Testing Hook Dependencies
To test useEffect dependencies, ensure mocks return different values:
```typescript
// First render
mockGetFormattedFilters.mockReturnValue(mockFilters);
// Change dependency and trigger re-render
const newMockFilters = { ...mockFilters, finished: true };
mockGetFormattedFilters.mockReturnValue(newMockFilters);
rerender();
```
## Performance Testing
### Race Condition Testing
Test AbortController implementation:
```typescript
test("cancels previous request when new request is made", async () => {
let resolveFirst: (value: any) => void;
let resolveSecond: (value: any) => void;
const firstPromise = new Promise((resolve) => {
resolveFirst = resolve;
});
const secondPromise = new Promise((resolve) => {
resolveSecond = resolve;
});
vi.mocked(apiCall)
.mockReturnValueOnce(firstPromise as any)
.mockReturnValueOnce(secondPromise as any);
const { result } = renderHook(() => useHook());
// Trigger second request
result.current.refetch();
// Resolve in order - first should be cancelled
resolveFirst!({ data: 100 });
resolveSecond!({ data: 200 });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Should have result from second request
expect(result.current.data).toBe(200);
});
```
### Cleanup Testing
```typescript
test("cleans up on unmount", () => {
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
const { unmount } = renderHook(() => useHook());
unmount();
expect(abortSpy).toHaveBeenCalled();
abortSpy.mockRestore();
});
```
## Error Handling Testing
### API Error Testing
```typescript
test("handles API errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(apiCall).mockRejectedValue(new Error("API Error"));
const { result } = renderHook(() => useHook());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(consoleSpy).toHaveBeenCalledWith("Error message:", expect.any(Error));
expect(result.current.data).toBe(fallbackValue);
consoleSpy.mockRestore();
});
```
### Cancelled Request Testing
```typescript
test("does not update state for cancelled requests", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
let rejectFirst: (error: any) => void;
const firstPromise = new Promise((_, reject) => {
rejectFirst = reject;
});
vi.mocked(apiCall)
.mockReturnValueOnce(firstPromise as any)
.mockResolvedValueOnce({ data: 42 });
const { result } = renderHook(() => useHook());
result.current.refetch();
const abortError = new Error("Request cancelled");
rejectFirst!(abortError);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Should not log error for cancelled request
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
```
## Type Safety in Tests
### Mock Type Assertions
Use type assertions for edge cases:
```typescript
vi.mocked(apiCall).mockResolvedValue({
data: null as any, // For testing null handling
});
vi.mocked(apiCall).mockResolvedValue({
data: undefined as any, // For testing undefined handling
});
```
### Proper Mock Typing
Ensure mocks match the actual interface:
```typescript
const mockSurvey: TSurvey = {
id: "survey-123",
name: "Test Survey",
// ... other required properties
} as unknown as TSurvey; // Use when partial mocking is needed
```
## Common Test Patterns
### Testing State Changes
```typescript
test("updates state correctly", async () => {
const { result } = renderHook(() => useHook());
// Initial state
expect(result.current.value).toBe(initialValue);
// Trigger change
result.current.updateValue(newValue);
// Verify change
expect(result.current.value).toBe(newValue);
});
```
### Testing Multiple Scenarios
```typescript
test("handles different modes", async () => {
// Test regular mode
vi.mocked(useParams).mockReturnValue({ surveyId: "123" });
const { rerender } = renderHook(() => useHook());
await waitFor(() => {
expect(vi.mocked(regularApi)).toHaveBeenCalled();
});
// Test sharing mode
vi.mocked(useParams).mockReturnValue({
surveyId: "123",
sharingKey: "share-123"
});
rerender();
await waitFor(() => {
expect(vi.mocked(sharingApi)).toHaveBeenCalled();
});
});
```
## Test Organization
### Comprehensive Test Coverage
For hooks, ensure you test:
- ✅ Initialization (with/without initial values)
- ✅ Data fetching (success/error cases)
- ✅ State updates and refetching
- ✅ Dependency changes triggering effects
- ✅ Manual actions (refetch, reset)
- ✅ Race condition prevention
- ✅ Cleanup on unmount
- ✅ Mode switching (if applicable)
- ✅ Edge cases (null/undefined data)
### Test Naming
Use descriptive test names that explain the scenario:
- ✅ "initializes with initial count"
- ✅ "fetches response count on mount for regular survey"
- ✅ "cancels previous request when new request is made"
- ❌ "test hook"
- ❌ "it works"

View File

@@ -3,5 +3,4 @@ description: Whenever the user asks to write or update a test file for .tsx or .
globs:
alwaysApply: false
---
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md).
After writing the tests, run them and check if there's any issue with the tests and if all of them are passing. Fix the issues and rerun the tests until all pass.
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md)

View File

@@ -80,8 +80,8 @@ S3_ENDPOINT_URL=
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
S3_FORCE_PATH_STYLE=0
# Set this URL to add a public domain for all your client facing routes(default is WEBAPP_URL)
# PUBLIC_URL=https://survey.example.com
# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL)
# SURVEY_URL=https://survey.example.com
#####################
# Disable Features #
@@ -190,7 +190,7 @@ UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory
REDIS_URL=redis://localhost:6379
# REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
@@ -216,8 +216,3 @@ UNKEY_ROOT_KEY=
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400
# Audit logs options. Requires REDIS_URL env varibale. Default 0.
# AUDIT_LOG_ENABLED=0
# If the ip should be added in the log or not. Default 0
# AUDIT_LOG_GET_USER_IP=0

View File

@@ -4,16 +4,16 @@ on:
workflow_dispatch:
inputs:
VERSION:
description: "The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0."
description: 'The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0.'
required: true
type: string
REPOSITORY:
description: "The repository to use for the Docker image"
description: 'The repository to use for the Docker image'
required: false
type: string
default: "ghcr.io/formbricks/formbricks"
default: 'ghcr.io/formbricks/formbricks'
ENVIRONMENT:
description: "The environment to deploy to"
description: 'The environment to deploy to'
required: true
type: choice
options:
@@ -22,16 +22,16 @@ on:
workflow_call:
inputs:
VERSION:
description: "The version of the Docker image to release"
description: 'The version of the Docker image to release'
required: true
type: string
REPOSITORY:
description: "The repository to use for the Docker image"
description: 'The repository to use for the Docker image'
required: false
type: string
default: "ghcr.io/formbricks/formbricks"
default: 'ghcr.io/formbricks/formbricks'
ENVIRONMENT:
description: "The environment to deploy to"
description: 'The environment to deploy to'
required: true
type: string
@@ -75,7 +75,7 @@ jobs:
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
with:
helmfile-version: "v1.0.0"
helmfile-version: 'v1.0.0'
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets
@@ -92,7 +92,7 @@ jobs:
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
with:
helmfile-version: "v1.0.0"
helmfile-version: 'v1.0.0'
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets
@@ -100,43 +100,3 @@ jobs:
helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm
- name: Purge Cloudflare Cache
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
# Set hostname based on environment
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
PURGE_HOST="app.formbricks.com"
else
PURGE_HOST="stage.app.formbricks.com"
fi
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
# Prepare JSON payload for selective cache purge
json_payload=$(cat << EOF
{
"hosts": ["$PURGE_HOST"]
}
EOF
)
# Make API call to Cloudflare
response=$(curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "$json_payload")
echo "Cloudflare API response: $response"
# Verify the operation was successful
if [[ "$(echo "$response" | jq -r .success)" == "true" ]]; then
echo "✅ Successfully purged cache for $PURGE_HOST"
else
echo "❌ Cloudflare cache purge failed"
echo "Error details: $(echo "$response" | jq -r .errors)"
exit 1
fi

View File

@@ -45,16 +45,6 @@ jobs:
--health-interval=10s
--health-timeout=5s
--health-retries=5
valkey:
image: valkey/valkey:8.1.1
ports:
- 6379:6379
options: >-
--entrypoint "valkey-server"
--health-cmd="valkey-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0

1
.gitignore vendored
View File

@@ -73,4 +73,3 @@ infra/terraform/.terraform/
/.idea/
/*.iml
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
.cursorrules

View File

@@ -27,7 +27,7 @@ describe("ConnectWithFormbricks", () => {
render(
<ConnectWithFormbricks
environment={environment}
publicDomain={webAppUrl}
webAppUrl={webAppUrl}
widgetSetupCompleted={false}
channel={channel}
/>
@@ -40,7 +40,7 @@ describe("ConnectWithFormbricks", () => {
render(
<ConnectWithFormbricks
environment={environment}
publicDomain={webAppUrl}
webAppUrl={webAppUrl}
widgetSetupCompleted={true}
channel={channel}
/>
@@ -53,7 +53,7 @@ describe("ConnectWithFormbricks", () => {
render(
<ConnectWithFormbricks
environment={environment}
publicDomain={webAppUrl}
webAppUrl={webAppUrl}
widgetSetupCompleted={true}
channel={channel}
/>
@@ -67,7 +67,7 @@ describe("ConnectWithFormbricks", () => {
render(
<ConnectWithFormbricks
environment={environment}
publicDomain={webAppUrl}
webAppUrl={webAppUrl}
widgetSetupCompleted={false}
channel={channel}
/>

View File

@@ -12,14 +12,14 @@ import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
interface ConnectWithFormbricksProps {
environment: TEnvironment;
publicDomain: string;
webAppUrl: string;
widgetSetupCompleted: boolean;
channel: TProjectConfigChannel;
}
export const ConnectWithFormbricks = ({
environment,
publicDomain,
webAppUrl,
widgetSetupCompleted,
channel,
}: ConnectWithFormbricksProps) => {
@@ -49,7 +49,7 @@ export const ConnectWithFormbricks = ({
<div className="flex w-1/2 flex-col space-y-4">
<OnboardingSetupInstructions
environmentId={environment.id}
publicDomain={publicDomain}
webAppUrl={webAppUrl}
channel={channel}
widgetSetupCompleted={widgetSetupCompleted}
/>

View File

@@ -33,7 +33,7 @@ describe("OnboardingSetupInstructions", () => {
// Provide some default props for testing
const defaultProps = {
environmentId: "env-123",
publicDomain: "https://example.com",
webAppUrl: "https://example.com",
channel: "app" as const, // Assuming channel is either "app" or "website"
widgetSetupCompleted: false,
};

View File

@@ -18,14 +18,14 @@ const tabs = [
interface OnboardingSetupInstructionsProps {
environmentId: string;
publicDomain: string;
webAppUrl: string;
channel: TProjectConfigChannel;
widgetSetupCompleted: boolean;
}
export const OnboardingSetupInstructions = ({
environmentId,
publicDomain,
webAppUrl,
channel,
widgetSetupCompleted,
}: OnboardingSetupInstructionsProps) => {
@@ -34,7 +34,7 @@ export const OnboardingSetupInstructions = ({
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){
var appUrl = "${publicDomain}";
var appUrl = "${webAppUrl}";
var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script>
@@ -44,7 +44,7 @@ export const OnboardingSetupInstructions = ({
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){
var appUrl = "${publicDomain}";
var appUrl = "${webAppUrl}";
var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script>
@@ -57,7 +57,7 @@ export const OnboardingSetupInstructions = ({
if (typeof window !== "undefined") {
formbricks.setup({
environmentId: "${environmentId}",
appUrl: "${publicDomain}",
appUrl: "${webAppUrl}",
});
}
@@ -75,7 +75,7 @@ export const OnboardingSetupInstructions = ({
if (typeof window !== "undefined") {
formbricks.setup({
environmentId: "${environmentId}",
appUrl: "${publicDomain}",
appUrl: "${webAppUrl}",
});
}

View File

@@ -1,6 +1,6 @@
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { WEBAPP_URL } from "@/lib/constants";
import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
@@ -30,8 +30,6 @@ const Page = async (props: ConnectPageProps) => {
const channel = project.config.channel || null;
const publicDomain = getPublicDomain();
return (
<div className="flex min-h-full flex-col items-center justify-center py-10">
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
@@ -41,7 +39,7 @@ const Page = async (props: ConnectPageProps) => {
</div>
<ConnectWithFormbricks
environment={environment}
publicDomain={publicDomain}
webAppUrl={WEBAPP_URL}
widgetSetupCompleted={environment.appSetupCompleted}
channel={channel}
/>

View File

@@ -11,7 +11,7 @@ vi.mock("@/lib/constants", () => ({
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
PUBLIC_URL: "http://localhost:3000/survey",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",
@@ -86,8 +86,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("next/navigation", () => ({

View File

@@ -12,6 +12,20 @@ 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,6 +1,8 @@
"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";
@@ -9,31 +11,38 @@ import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
export const getTeamsByOrganizationId = reactCache(
async (organizationId: string): Promise<TOrganizationTeam[] | null> => {
validateInputs([organizationId, ZId]);
try {
const teams = await prisma.team.findMany({
where: {
organizationId,
},
select: {
id: true,
name: true,
},
});
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,
},
});
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);
return projectTeams;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getTeamsByOrganizationId-${organizationId}`],
{
tags: [teamCache.tag.byOrganizationId(organizationId)],
}
throw error;
}
}
)()
);

View File

@@ -1,33 +1,15 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { signOut } from "next-auth/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { LandingSidebar } from "./landing-sidebar";
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
// Mock server actions that this test needs
vi.mock("@/modules/auth/actions/sign-out", () => ({
logSignOutAction: vi.fn().mockResolvedValue(undefined),
}));
// Module mocks must be declared before importing the component
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
}));
// Mock our useSignOut hook
const mockSignOut = vi.fn();
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
useSignOut: () => ({
signOut: mockSignOut,
}),
}));
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
CreateOrganizationModal: ({ open }: { open: boolean }) => (
@@ -88,12 +70,6 @@ describe("LandingSidebar component", () => {
const logoutItem = await screen.findByText("common.logout");
await userEvent.click(logoutItem);
expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "o1",
redirect: true,
callbackUrl: "/auth/login",
});
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
});
});

View File

@@ -3,7 +3,6 @@
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import {
@@ -21,6 +20,7 @@ import {
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
import { signOut } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -44,7 +44,6 @@ export const LandingSidebar = ({
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
const { t } = useTranslate();
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const router = useRouter();
@@ -124,13 +123,7 @@ export const LandingSidebar = ({
<DropdownMenuItem
onClick={async () => {
await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: true,
callbackUrl: "/auth/login",
});
await signOut({ callbackUrl: "/auth/login" });
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -14,7 +14,7 @@ vi.mock("@/lib/constants", () => ({
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
PUBLIC_URL: "http://localhost:3000/survey",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",
@@ -89,8 +89,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/environment/service");

View File

@@ -23,6 +23,7 @@ vi.mock("@/lib/constants", () => ({
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",
@@ -97,8 +98,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({

View File

@@ -35,8 +35,6 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("next-auth", () => ({

View File

@@ -34,8 +34,6 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
// Mock dependencies

View File

@@ -26,14 +26,6 @@ vi.mock("@/lib/constants", () => ({
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
REDIS_URL: "redis://localhost:6379",
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("Contact Page Re-export", () => {

View File

@@ -4,9 +4,7 @@ import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getOrganizationProjectsLimit,
getRoleManagementPermission,
@@ -22,69 +20,62 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput,
});
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
withAuditLogging(
"created",
"project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const { user } = ctx;
export const createProjectAction = authenticatedActionClient
.schema(ZCreateProjectAction)
.action(async ({ parsedInput, ctx }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId;
const organizationId = parsedInput.organizationId;
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization project limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
],
});
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
const organization = await getOrganization(organizationId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
if (!organization) {
throw new Error("Organization not found");
}
)
);
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization project limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
return project;
});

View File

@@ -1,12 +1,11 @@
"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/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
@@ -16,80 +15,63 @@ const ZDeleteActionClassAction = z.object({
actionClassId: ZId,
});
export const deleteActionClassAction = authenticatedActionClient.schema(ZDeleteActionClassAction).action(
withAuditLogging(
"deleted",
"actionClass",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId;
ctx.auditLoggingCtx.oldObject = await getActionClass(parsedInput.actionClassId);
return await deleteActionClass(parsedInput.actionClassId);
}
)
);
export const deleteActionClassAction = authenticatedActionClient
.schema(ZDeleteActionClassAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
await deleteActionClass(parsedInput.actionClassId);
});
const ZUpdateActionClassAction = z.object({
actionClassId: ZId,
updatedAction: ZActionClassInput,
});
export const updateActionClassAction = authenticatedActionClient.schema(ZUpdateActionClassAction).action(
withAuditLogging(
"updated",
"actionClass",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const actionClass = await getActionClass(parsedInput.actionClassId);
if (actionClass === null) {
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
}
const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId;
ctx.auditLoggingCtx.oldObject = actionClass;
const result = await updateActionClass(
actionClass.environmentId,
parsedInput.actionClassId,
parsedInput.updatedAction
);
ctx.auditLoggingCtx.newObject = result;
return result;
export const updateActionClassAction = authenticatedActionClient
.schema(ZUpdateActionClassAction)
.action(async ({ ctx, parsedInput }) => {
const actionClass = await getActionClass(parsedInput.actionClassId);
if (actionClass === null) {
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
}
)
);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
return await updateActionClass(
actionClass.environmentId,
parsedInput.actionClassId,
parsedInput.updatedAction
);
});
const ZGetActiveInactiveSurveysAction = z.object({
actionClassId: ZId,
@@ -122,24 +104,31 @@ export const getActiveInactiveSurveysAction = authenticatedActionClient
return response;
});
const getLatestStableFbRelease = async (): Promise<string | null> => {
try {
const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases");
const releases = await res.json();
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();
if (Array.isArray(releases)) {
const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0]
?.tag_name as string;
if (latestStableReleaseTag) {
return latestStableReleaseTag;
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;
}
},
["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,6 +1,6 @@
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { signOut } from "next-auth/react";
import { usePathname, useRouter } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
@@ -10,17 +10,6 @@ import { TUser } from "@formbricks/types/user";
import { getLatestStableFbReleaseAction } from "../actions/actions";
import { MainNavigation } from "./MainNavigation";
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
// Mock server actions that this test needs
vi.mock("@/modules/auth/actions/sign-out", () => ({
logSignOutAction: vi.fn().mockResolvedValue(undefined),
}));
// Mock dependencies
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
@@ -29,9 +18,6 @@ vi.mock("next/navigation", () => ({
vi.mock("next-auth/react", () => ({
signOut: vi.fn(),
}));
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
useSignOut: vi.fn(() => ({ signOut: vi.fn() })),
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
getLatestStableFbReleaseAction: vi.fn(),
}));
@@ -217,12 +203,7 @@ describe("MainNavigation", () => {
});
test("renders user dropdown and handles logout", async () => {
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
// Set up localStorage spy on the mocked localStorage
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
vi.mocked(signOut).mockResolvedValue({ url: "/auth/login" });
render(<MainNavigation {...defaultProps} />);
// Find the avatar and get its parent div which acts as the trigger
@@ -243,23 +224,10 @@ describe("MainNavigation", () => {
const logoutButton = screen.getByText("common.logout");
await userEvent.click(logoutButton);
// Verify localStorage.removeItem is called with the correct key
expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id");
expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "org1",
redirect: false,
callbackUrl: "/auth/login",
});
expect(signOut).toHaveBeenCalledWith({ redirect: false, callbackUrl: "/auth/login" });
await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
});
// Clean up spy
removeItemSpy.mockRestore();
});
test("handles organization switching", async () => {

View File

@@ -4,10 +4,8 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
@@ -44,6 +42,7 @@ import {
UserIcon,
UsersIcon,
} from "lucide-react";
import { signOut } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
@@ -91,7 +90,6 @@ export const MainNavigation = ({
const [isCollapsed, setIsCollapsed] = useState(true);
const [isTextVisible, setIsTextVisible] = useState(true);
const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const project = projects.find((project) => project.id === environment.projectId);
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
@@ -391,16 +389,8 @@ export const MainNavigation = ({
<DropdownMenuItem
onClick={async () => {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: false,
callbackUrl: "/auth/login",
});
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" });
router.push(route.url);
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -2,15 +2,13 @@
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromIntegrationId,
getProjectIdFromEnvironmentId,
getProjectIdFromIntegrationId,
} from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZIntegrationInput } from "@formbricks/types/integration";
@@ -22,79 +20,48 @@ const ZCreateOrUpdateIntegrationAction = z.object({
export const createOrUpdateIntegrationAction = authenticatedActionClient
.schema(ZCreateOrUpdateIntegrationAction)
.action(
withAuditLogging(
"createdUpdated",
"integration",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createOrUpdateIntegration(
parsedInput.environmentId,
parsedInput.integrationData
);
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
return await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
});
const ZDeleteIntegrationAction = z.object({
integrationId: ZId,
});
export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action(
withAuditLogging(
"deleted",
"integration",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromIntegrationId(parsedInput.integrationId);
export const deleteIntegrationAction = authenticatedActionClient
.schema(ZDeleteIntegrationAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromIntegrationId(parsedInput.integrationId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.integrationId = parsedInput.integrationId;
const result = await deleteIntegration(parsedInput.integrationId);
ctx.auditLoggingCtx.oldObject = result;
return result;
}
)
);
return await deleteIntegration(parsedInput.integrationId);
});

View File

@@ -49,8 +49,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/integration/service");

View File

@@ -2,7 +2,7 @@
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod";
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";

View File

@@ -1,8 +1,10 @@
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 { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
@@ -10,6 +12,14 @@ 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
}));
@@ -36,11 +46,11 @@ vi.mock("react", async (importOriginal) => {
});
const environmentId = "test-environment-id";
// Use 'as any' to bypass complex type matching for mock data
// Ensure mockPrismaSurveys includes all fields used in selectSurvey mock
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",
@@ -89,8 +99,14 @@ 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 as any);
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys);
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");
@@ -118,29 +134,39 @@ describe("getSurveys", () => {
expect(transformPrismaSurvey).toHaveBeenCalledTimes(mockPrismaSurveys.length);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[0]);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[1]);
// React cache is already mocked globally - no need to check it here
// 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
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database connection error", {
code: "P2002",
clientVersion: "4.0.0",
// 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
});
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(prismaError);
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys");
// React cache is already mocked globally - no need to check it here
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called
});
test("should throw original error on other errors", async () => {
const genericError = new Error("Some other error");
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(genericError);
// 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);
await expect(getSurveys(environmentId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
// React cache is already mocked globally - no need to check it here
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called
});
});

View File

@@ -1,4 +1,6 @@
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";
@@ -10,29 +12,38 @@ 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[]> => {
validateInputs([environmentId, ZId]);
export const getSurveys = reactCache(
async (environmentId: string): Promise<TSurvey[]> =>
cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
status: {
not: "completed",
},
},
select: selectSurvey,
orderBy: {
updatedAt: "desc",
},
});
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;
}
});
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error({ error }, "getSurveys: Could not fetch surveys");
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSurveys-${environmentId}`],
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
}
)()
);

View File

@@ -1,10 +1,21 @@
import { cache } from "@/lib/cache";
import { webhookCache } from "@/lib/cache/webhook";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, 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: {
@@ -18,6 +29,12 @@ const environmentId = "test-environment-id";
const sourceZapier = "zapier";
describe("getWebhookCountBySource", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => {
vi.resetAllMocks();
});
@@ -39,6 +56,13 @@ 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 () => {
@@ -58,6 +82,13 @@ 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 () => {
@@ -69,6 +100,7 @@ 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 () => {
@@ -77,5 +109,6 @@ describe("getWebhookCountBySource", () => {
await expect(getWebhookCountBySource(environmentId)).rejects.toThrow(genericError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,3 +1,5 @@
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";
@@ -5,25 +7,29 @@ import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
export const getWebhookCountBySource = async (
environmentId: string,
source?: Webhook["source"]
): Promise<number> => {
validateInputs([environmentId, ZId], [source, z.string().optional()]);
export const getWebhookCountBySource = (environmentId: string, source?: Webhook["source"]): Promise<number> =>
cache(
async () => {
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);
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)],
}
throw error;
}
};
)();

View File

@@ -32,8 +32,6 @@ vi.mock("@/lib/constants", () => ({
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
SESSION_MAX_AGE: 1000,
REDIS_URL: "mock-redis-url",
AUDIT_LOG_ENABLED: true,
}));
// Mock child components

View File

@@ -2,7 +2,7 @@
import { getSlackChannels } from "@/lib/slack/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";

View File

@@ -25,14 +25,6 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://example.com",
},
}));
describe("AppConnectionPage Re-export", () => {

View File

@@ -25,14 +25,6 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("GeneralSettingsPage re-export", () => {

View File

@@ -25,8 +25,6 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("LanguagesPage re-export", () => {

View File

@@ -25,14 +25,6 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("ProjectLookSettingsPage re-export", () => {

View File

@@ -25,8 +25,6 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("TagsPage re-export", () => {

View File

@@ -25,8 +25,6 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
describe("ProjectTeams re-export", () => {

View File

@@ -41,8 +41,6 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId);

View File

@@ -1,9 +1,7 @@
"use server";
import { getUser, updateUser } from "@/lib/user/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZUserNotificationSettings } from "@formbricks/types/user";
@@ -13,25 +11,8 @@ const ZUpdateNotificationSettingsAction = z.object({
export const updateNotificationSettingsAction = authenticatedActionClient
.schema(ZUpdateNotificationSettingsAction)
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
.action(async ({ ctx, parsedInput }) => {
await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
});

View File

@@ -7,12 +7,10 @@ import {
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { deleteFile } from "@/lib/storage/service";
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { getUser, updateUser } from "@/lib/user/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { rateLimit } from "@/lib/utils/rate-limit";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
@@ -29,136 +27,93 @@ const limiter = rateLimit({
allowedPerInterval: 3, // max 3 calls for email verification per hour
});
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
return {
...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }),
};
}
async function handleEmailUpdate({
ctx,
parsedInput,
payload,
}: {
ctx: any;
parsedInput: any;
payload: TUserUpdateInput;
}) {
const inputEmail = parsedInput.email?.trim().toLowerCase();
if (!inputEmail || ctx.user.email === inputEmail) return payload;
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
}
if (!parsedInput.password) {
throw new AuthenticationError("Password is required to update email.");
}
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
if (!isCorrectPassword) {
throw new AuthorizationError("Incorrect credentials");
}
const isEmailUnique = await getIsEmailUnique(inputEmail);
if (!isEmailUnique) return payload;
if (EMAIL_VERIFICATION_DISABLED) {
payload.email = inputEmail;
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
}
return payload;
}
export const updateUserAction = authenticatedActionClient
.schema(
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
password: ZUserPassword.optional(),
})
)
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
.action(async ({ parsedInput, ctx }) => {
const inputEmail = parsedInput.email?.trim().toLowerCase();
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
let payload: TUserUpdateInput = {
...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }),
};
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
// Only process email update if a new email is provided and it's different from current email
if (inputEmail && ctx.user.email !== inputEmail) {
// Check rate limit
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
)
);
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
}
if (!parsedInput.password) {
throw new AuthenticationError("Password is required to update email.");
}
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
if (!isCorrectPassword) {
throw new AuthorizationError("Incorrect credentials");
}
// Check if the new email is unique, no user exists with the new email
const isEmailUnique = await getIsEmailUnique(inputEmail);
// If the new email is unique, proceed with the email update
if (isEmailUnique) {
if (EMAIL_VERIFICATION_DISABLED) {
payload.email = inputEmail;
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
}
}
}
// Only proceed with updateUser if we have actual changes to make
if (Object.keys(payload).length > 0) {
await updateUser(ctx.user.id, payload);
}
return true;
});
const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(),
});
export const updateAvatarAction = authenticatedActionClient.schema(ZUpdateAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
export const updateAvatarAction = authenticatedActionClient
.schema(ZUpdateAvatarAction)
.action(async ({ parsedInput, ctx }) => {
return await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
});
const ZRemoveAvatarAction = z.object({
environmentId: ZId,
});
export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const imageUrl = ctx.user.imageUrl;
if (!imageUrl) {
throw new Error("Image not found");
}
const fileName = getFileNameWithIdFromUrl(imageUrl);
if (!fileName) {
throw new Error("Invalid filename");
}
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
if (!deletionResult.success) {
throw new Error("Deletion failed");
}
const result = await updateUser(ctx.user.id, { imageUrl: null });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
export const removeAvatarAction = authenticatedActionClient
.schema(ZRemoveAvatarAction)
.action(async ({ parsedInput, ctx }) => {
const imageUrl = ctx.user.imageUrl;
if (!imageUrl) {
throw new Error("Image not found");
}
)
);
const fileName = getFileNameWithIdFromUrl(imageUrl);
if (!fileName) {
throw new Error("Invalid filename");
}
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
if (!deletionResult.success) {
throw new Error("Deletion failed");
}
return await updateUser(ctx.user.id, { imageUrl: null });
});

View File

@@ -3,13 +3,11 @@
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
@@ -17,6 +15,8 @@ import { Input } from "@/modules/ui/components/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react";
import { signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
@@ -38,6 +38,7 @@ export const EditProfileDetailsForm = ({
emailVerificationDisabled: boolean;
}) => {
const { t } = useTranslate();
const router = useRouter();
const form = useForm<TEditProfileNameForm>({
defaultValues: {
@@ -51,7 +52,6 @@ export const EditProfileDetailsForm = ({
const { isSubmitting, isDirty } = form.formState;
const [showModal, setShowModal] = useState(false);
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const handleConfirmPassword = async (password: string) => {
const values = form.getValues();
@@ -85,12 +85,8 @@ export const EditProfileDetailsForm = ({
toast.success(t("auth.verification-requested.new_email_verification_success"));
} else {
toast.success(t("environments.settings.profile.email_change_initiated"));
await signOutWithAudit({
reason: "email_change",
redirectUrl: "/email-change-without-verification-success",
redirect: true,
callbackUrl: "/email-change-without-verification-success",
});
await signOut({ redirect: false });
router.push(`/email-change-without-verification-success`);
return;
}
} else {
@@ -179,24 +175,20 @@ export const EditProfileDetailsForm = ({
variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between">
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
{appLanguages.find((l) => l.code === field.value)?.label[field.value] ?? "NA"}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => (
<DropdownMenuRadioItem
key={lang.code}
value={lang.code}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuContent className="w-40 bg-slate-50 text-slate-700" align="start">
{appLanguages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => field.onChange(lang.code)}
className="min-h-8 cursor-pointer">
{lang.label[field.value]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>

View File

@@ -4,6 +4,16 @@ 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(),
}));
@@ -16,6 +26,9 @@ 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,3 +1,5 @@
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";
@@ -5,21 +7,28 @@ import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getUserById = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
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;
},
select: {
password: true,
identityProvider: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
}
[`getUserById-${userId}`],
{
tags: [userCache.tag.byId(userId)],
}
)()
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
@@ -38,15 +47,24 @@ export const verifyUserPassword = async (userId: string, password: string): Prom
return true;
};
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
const user = await prisma.user.findUnique({
where: {
email: email.toLowerCase(),
},
select: {
id: 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,
},
});
return !user;
});
return !user;
},
[`getIsEmailUnique-${email}`],
{
tags: [userCache.tag.byEmail(email)],
}
)()
);

View File

@@ -1,10 +1,8 @@
"use server";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { deleteOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
@@ -18,65 +16,43 @@ const ZUpdateOrganizationNameAction = z.object({
export const updateOrganizationNameAction = authenticatedActionClient
.schema(ZUpdateOrganizationNameAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
return await updateOrganization(parsedInput.organizationId, parsedInput.data);
});
const ZDeleteOrganizationAction = z.object({
organizationId: ZId,
});
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
withAuditLogging(
"deleted",
"organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
export const deleteOrganizationAction = authenticatedActionClient
.schema(ZDeleteOrganizationAction)
.action(async ({ parsedInput, ctx }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
}
)
);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
return await deleteOrganization(parsedInput.organizationId);
});

View File

@@ -30,14 +30,6 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("TeamsPage re-export", () => {

View File

@@ -2,7 +2,7 @@
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { revalidatePath } from "next/cache";
import { z } from "zod";
@@ -75,6 +75,7 @@ export const getSurveySummaryAction = authenticatedActionClient
},
],
});
return getSurveySummary(parsedInput.surveyId, parsedInput.filterCriteria);
});

View File

@@ -5,6 +5,7 @@ import {
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { useParams, usePathname, useSearchParams } from "next/navigation";
@@ -45,20 +46,13 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
vi.mock("@/app/lib/surveys/surveys");
vi.mock("@/app/share/[sharingKey]/actions");
vi.mock("@/lib/utils/hooks/useIntervalWhenFocused");
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
}));
@@ -75,6 +69,7 @@ const mockUseResponseFilter = vi.mocked(useResponseFilter);
const mockGetResponseCountAction = vi.mocked(getResponseCountAction);
const mockRevalidateSurveyIdPath = vi.mocked(revalidateSurveyIdPath);
const mockGetFormattedFilters = vi.mocked(getFormattedFilters);
const mockUseIntervalWhenFocused = vi.mocked(useIntervalWhenFocused);
const MockSecondaryNavigation = vi.mocked(SecondaryNavigation);
const mockSurveyLanguages: TSurveyLanguage[] = [
@@ -125,6 +120,7 @@ const mockSurvey = {
const defaultProps = {
environmentId: "testEnvId",
survey: mockSurvey,
initialTotalResponseCount: 10,
activeId: "summary",
};
@@ -171,20 +167,23 @@ describe("SurveyAnalysisNavigation", () => {
);
});
test("renders navigation correctly for sharing page", () => {
test("passes correct runWhen flag to useIntervalWhenFocused based on share embed modal", () => {
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary`
);
mockUseParams.mockReturnValue({ sharingKey: "test-sharing-key" });
mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id });
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
mockGetFormattedFilters.mockReturnValue([] as any);
mockGetResponseCountAction.mockResolvedValue({ data: 5 });
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue("true") } as any);
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(mockUseIntervalWhenFocused).toHaveBeenCalledWith(expect.any(Function), 10000, false, false);
cleanup();
expect(MockSecondaryNavigation).toHaveBeenCalled();
const lastCallArgs = MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[0].href).toContain("/share/test-sharing-key");
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any);
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(mockUseIntervalWhenFocused).toHaveBeenCalledWith(expect.any(Function), 10000, true, false);
});
test("displays correct response count string in label for various scenarios", async () => {
@@ -197,8 +196,8 @@ describe("SurveyAnalysisNavigation", () => {
mockGetFormattedFilters.mockReturnValue([] as any);
// Scenario 1: total = 10, filtered = null (initial state)
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(MockSecondaryNavigation.mock.calls[0][0].navigation[1].label).toBe("common.responses");
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={10} />);
expect(MockSecondaryNavigation.mock.calls[0][0].navigation[1].label).toBe("common.responses (10)");
cleanup();
vi.resetAllMocks(); // Reset mocks for next case
@@ -214,11 +213,11 @@ describe("SurveyAnalysisNavigation", () => {
if (args && "filterCriteria" in args) return { data: 15, error: null, success: true };
return { data: 15, error: null, success: true };
});
render(<SurveyAnalysisNavigation {...defaultProps} />);
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={15} />);
await waitFor(() => {
const lastCallArgs =
MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[1].label).toBe("common.responses");
expect(lastCallArgs.navigation[1].label).toBe("common.responses (15)");
});
cleanup();
vi.resetAllMocks();
@@ -235,11 +234,11 @@ describe("SurveyAnalysisNavigation", () => {
if (args && "filterCriteria" in args) return { data: 15, error: null, success: true };
return { data: 10, error: null, success: true };
});
render(<SurveyAnalysisNavigation {...defaultProps} />);
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={10} />);
await waitFor(() => {
const lastCallArgs =
MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[1].label).toBe("common.responses");
expect(lastCallArgs.navigation[1].label).toBe("common.responses (15)");
});
});
});

View File

@@ -1,30 +1,105 @@
"use client";
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import {
getResponseCountAction,
revalidateSurveyIdPath,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getResponseCountBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { useTranslate } from "@tolgee/react";
import { InboxIcon, PresentationIcon } from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { TSurvey } from "@formbricks/types/surveys/types";
interface SurveyAnalysisNavigationProps {
environmentId: string;
survey: TSurvey;
initialTotalResponseCount: number | null;
activeId: string;
}
export const SurveyAnalysisNavigation = ({
environmentId,
survey,
initialTotalResponseCount,
activeId,
}: SurveyAnalysisNavigationProps) => {
const pathname = usePathname();
const { t } = useTranslate();
const params = useParams();
const [filteredResponseCount, setFilteredResponseCount] = useState<number | null>(null);
const [totalResponseCount, setTotalResponseCount] = useState<number | null>(initialTotalResponseCount);
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const searchParams = useSearchParams();
const isShareEmbedModalOpen = searchParams.get("share") === "true";
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${survey.id}`;
const { selectedFilter, dateRange } = useResponseFilter();
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
[selectedFilter, dateRange, survey]
);
const latestFiltersRef = useRef(filters);
latestFiltersRef.current = filters;
const getResponseCount = () => {
if (isSharingPage) return getResponseCountBySurveySharingKeyAction({ sharingKey });
return getResponseCountAction({ surveyId: survey.id });
};
const fetchResponseCount = async () => {
const count = await getResponseCount();
const responseCount = count?.data ?? 0;
setTotalResponseCount(responseCount);
};
const getFilteredResponseCount = useCallback(() => {
if (isSharingPage)
return getResponseCountBySurveySharingKeyAction({
sharingKey,
filterCriteria: latestFiltersRef.current,
});
return getResponseCountAction({ surveyId: survey.id, filterCriteria: latestFiltersRef.current });
}, [isSharingPage, sharingKey, survey.id]);
const fetchFilteredResponseCount = useCallback(async () => {
const count = await getFilteredResponseCount();
const responseCount = count?.data ?? 0;
setFilteredResponseCount(responseCount);
}, [getFilteredResponseCount]);
useEffect(() => {
fetchFilteredResponseCount();
}, [filters, isSharingPage, sharingKey, survey.id, fetchFilteredResponseCount]);
useIntervalWhenFocused(
() => {
fetchResponseCount();
fetchFilteredResponseCount();
},
10000,
!isShareEmbedModalOpen,
false
);
const getResponseCountString = () => {
if (totalResponseCount === null) return "";
if (filteredResponseCount === null) return `(${totalResponseCount})`;
const totalCount = Math.max(totalResponseCount, filteredResponseCount);
if (totalCount === filteredResponseCount) return `(${totalCount})`;
return `(${filteredResponseCount} of ${totalCount})`;
};
const navigation = [
{
@@ -39,7 +114,7 @@ export const SurveyAnalysisNavigation = ({
},
{
id: "responses",
label: t("common.responses"),
label: `${t("common.responses")} ${getResponseCountString()}`,
icon: <InboxIcon className="h-5 w-5" />,
href: `${url}/responses?referer=true`,
current: pathname?.includes("/responses"),

View File

@@ -162,6 +162,7 @@ describe("ResponsePage", () => {
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
});
expect(mockGetResponseCountAction).toHaveBeenCalled();
expect(mockGetResponsesAction).toHaveBeenCalled();
});
@@ -178,6 +179,7 @@ describe("ResponsePage", () => {
await waitFor(() => {
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
});
expect(mockGetResponseCountBySurveySharingKeyAction).toHaveBeenCalled();
expect(mockGetResponsesBySurveySharingKeyAction).toHaveBeenCalled();
});
@@ -295,7 +297,8 @@ describe("ResponsePage", () => {
rerender(<ResponsePage {...defaultProps} />);
await waitFor(() => {
// Should fetch responses again due to filter change
// Should fetch count and responses again due to filter change
expect(mockGetResponseCountAction).toHaveBeenCalledTimes(2);
expect(mockGetResponsesAction).toHaveBeenCalledTimes(2);
// Check if it fetches with offset 0 (first page)
expect(mockGetResponsesAction).toHaveBeenLastCalledWith(

View File

@@ -1,12 +1,18 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import {
getResponseCountAction,
getResponsesAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getResponsesBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import {
getResponseCountBySurveySharingKeyAction,
getResponsesBySurveySharingKeyAction,
} from "@/app/share/[sharingKey]/actions";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { useParams, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -20,7 +26,7 @@ interface ResponsePageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
publicDomain: string;
webAppUrl: string;
user?: TUser;
environmentTags: TTag[];
responsesPerPage: number;
@@ -32,7 +38,7 @@ export const ResponsePage = ({
environment,
survey,
surveyId,
publicDomain,
webAppUrl,
user,
environmentTags,
responsesPerPage,
@@ -43,6 +49,7 @@ export const ResponsePage = ({
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const [responseCount, setResponseCount] = useState<number | null>(null);
const [responses, setResponses] = useState<TResponse[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
@@ -90,6 +97,9 @@ export const ResponsePage = ({
const deleteResponses = (responseIds: string[]) => {
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
if (responseCount) {
setResponseCount(responseCount - responseIds.length);
}
};
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
@@ -108,6 +118,29 @@ export const ResponsePage = ({
}
}, [searchParams, resetState]);
useEffect(() => {
const handleResponsesCount = async () => {
let responseCount = 0;
if (isSharingPage) {
const responseCountActionResponse = await getResponseCountBySurveySharingKeyAction({
sharingKey,
filterCriteria: filters,
});
responseCount = responseCountActionResponse?.data || 0;
} else {
const responseCountActionResponse = await getResponseCountAction({
surveyId,
filterCriteria: filters,
});
responseCount = responseCountActionResponse?.data || 0;
}
setResponseCount(responseCount);
};
handleResponsesCount();
}, [filters, isSharingPage, sharingKey, surveyId]);
useEffect(() => {
const fetchInitialResponses = async () => {
try {
@@ -155,7 +188,7 @@ export const ResponsePage = ({
<>
<div className="flex gap-1.5">
<CustomFilter survey={surveyMemoized} />
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} publicDomain={publicDomain} />}
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} webAppUrl={webAppUrl} />}
</div>
<ResponseDataView
survey={survey}

View File

@@ -1,9 +1,8 @@
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
@@ -62,11 +61,10 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn",
WEBAPP_URL: "http://localhost:3000",
RESPONSES_PER_PAGE: 10,
SESSION_MAX_AGE: 1000,
}));
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn(),
vi.mock("@/lib/getSurveyUrl", () => ({
getSurveyDomain: vi.fn(),
}));
vi.mock("@/lib/response/service", () => ({
@@ -111,14 +109,6 @@ vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
useParams: () => ({
environmentId: "test-env-id",
surveyId: "test-survey-id",
sharingKey: null,
}),
}));
const mockEnvironmentId = "test-env-id";
const mockSurveyId = "test-survey-id";
const mockUserId = "test-user-id";
@@ -160,7 +150,7 @@ const mockEnvironment = {
const mockTags: TTag[] = [{ id: "tag1", name: "Tag 1", environmentId: mockEnvironmentId } as unknown as TTag];
const mockLocale: TUserLocale = "en-US";
const mockPublicDomain = "http://customdomain.com";
const mockSurveyDomain = "http://customdomain.com";
const mockParams = {
environmentId: mockEnvironmentId,
@@ -179,7 +169,7 @@ describe("ResponsesPage", () => {
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
vi.mocked(getSurveyDomain).mockReturnValue(mockSurveyDomain);
});
afterEach(() => {
@@ -190,7 +180,7 @@ describe("ResponsesPage", () => {
test("renders correctly with all data", async () => {
const props = { params: mockParams };
const jsx = await Page(props);
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
render(jsx);
await screen.findByTestId("page-content-wrapper");
expect(screen.getByTestId("page-header")).toBeInTheDocument();
@@ -205,7 +195,8 @@ describe("ResponsesPage", () => {
survey: mockSurvey,
isReadOnly: false,
user: mockUser,
publicDomain: mockPublicDomain,
surveyDomain: mockSurveyDomain,
responseCount: 10,
}),
undefined
);
@@ -215,6 +206,7 @@ describe("ResponsesPage", () => {
environmentId: mockEnvironmentId,
survey: mockSurvey,
activeId: "responses",
initialTotalResponseCount: 10,
}),
undefined
);
@@ -224,7 +216,7 @@ describe("ResponsesPage", () => {
environment: mockEnvironment,
survey: mockSurvey,
surveyId: mockSurveyId,
publicDomain: mockPublicDomain,
webAppUrl: "http://localhost:3000",
environmentTags: mockTags,
user: mockUser,
responsesPerPage: 10,

View File

@@ -1,8 +1,8 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
@@ -33,11 +33,10 @@ const Page = async (props) => {
const tags = await getTagsByEnvironmentId(params.environmentId);
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
const locale = await findMatchingLocale();
const publicDomain = getPublicDomain();
const surveyDomain = getSurveyDomain();
return (
<PageContentWrapper>
@@ -49,17 +48,22 @@ const Page = async (props) => {
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
responseCount={responseCount}
surveyDomain={surveyDomain}
responseCount={totalResponseCount}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
<SurveyAnalysisNavigation
environmentId={environment.id}
survey={survey}
activeId="responses"
initialTotalResponseCount={totalResponseCount}
/>
</PageHeader>
<ResponsePage
environment={environment}
survey={survey}
surveyId={params.surveyId}
publicDomain={publicDomain}
webAppUrl={WEBAPP_URL}
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}

View File

@@ -3,10 +3,8 @@
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid";
@@ -65,55 +63,37 @@ const ZGenerateResultShareUrlAction = z.object({
export const generateResultShareUrlAction = authenticatedActionClient
.schema(ZGenerateResultShareUrlAction)
.action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const resultShareKey = customAlphabet(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
20
)();
const resultShareKey = customAlphabet(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
20
)();
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
await updateSurvey({ ...survey, resultShareKey });
const newSurvey = await updateSurvey({ ...survey, resultShareKey });
ctx.auditLoggingCtx.newObject = newSurvey;
return resultShareKey;
}
)
);
return resultShareKey;
});
const ZGetResultShareUrlAction = z.object({
surveyId: ZId,
@@ -152,50 +132,30 @@ const ZDeleteResultShareUrlAction = z.object({
export const deleteResultShareUrlAction = authenticatedActionClient
.schema(ZDeleteResultShareUrlAction)
.action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
const newSurvey = await updateSurvey({ ...survey, resultShareKey: null });
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
}
)
);
return await updateSurvey({ ...survey, resultShareKey: null });
});
const ZGetEmailHtmlAction = z.object({
surveyId: ZId,

View File

@@ -41,36 +41,6 @@ const mockSurveyWeb = {
styling: null,
} as unknown as TSurvey;
vi.mock("@/lib/constants", () => ({
INTERCOM_SECRET_KEY: "test-secret-key",
IS_INTERCOM_CONFIGURED: true,
INTERCOM_APP_ID: "test-app-id",
ENCRYPTION_KEY: "test-encryption-key",
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
GITHUB_ID: "test-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
IS_FORMBRICKS_CLOUD: false,
}));
const mockSurveyLink = {
...mockSurveyWeb,
id: "survey2",
@@ -149,7 +119,7 @@ describe("ShareEmbedSurvey", () => {
const defaultProps = {
survey: mockSurveyWeb,
publicDomain: "https://public-domain.com",
surveyDomain: "test.com",
open: true,
modalView: "start" as "start" | "embed" | "panel",
setOpen: mockSetOpen,
@@ -158,7 +128,7 @@ describe("ShareEmbedSurvey", () => {
beforeEach(() => {
mockEmbedViewComponent.mockImplementation(
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, surveyDomain, locale }) => (
<div>
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
@@ -166,7 +136,7 @@ describe("ShareEmbedSurvey", () => {
<div data-testid="embedview-survey-id">{survey.id}</div>
<div data-testid="embedview-email">{email}</div>
<div data-testid="embedview-surveyUrl">{surveyUrl}</div>
<div data-testid="embedview-publicDomain">{publicDomain}</div>
<div data-testid="embedview-surveyDomain">{surveyDomain}</div>
<div data-testid="embedview-locale">{locale}</div>
</div>
)
@@ -204,32 +174,20 @@ describe("ShareEmbedSurvey", () => {
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
});
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
test("calls setOpen(false) when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
const embedViewButton = screen.getByText("EmbedViewMockContent");
await userEvent.click(embedViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
test("calls setOpen(false) when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
await userEvent.click(panelInfoViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("PanelInfoViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {

View File

@@ -1,7 +1,6 @@
"use client";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { Badge } from "@/modules/ui/components/badge";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
@@ -24,7 +23,7 @@ import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
interface ShareEmbedSurveyProps {
survey: TSurvey;
publicDomain: string;
surveyDomain: string;
open: boolean;
modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
@@ -33,7 +32,7 @@ interface ShareEmbedSurveyProps {
export const ShareEmbedSurvey = ({
survey,
publicDomain,
surveyDomain,
open,
modalView,
setOpen,
@@ -63,20 +62,6 @@ export const ShareEmbedSurvey = ({
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
const [surveyUrl, setSurveyUrl] = useState("");
useEffect(() => {
const fetchSurveyUrl = async () => {
try {
const url = await getSurveyUrl(survey, publicDomain, "default");
setSurveyUrl(url);
} catch (error) {
console.error("Failed to fetch survey URL:", error);
// Fallback to a default URL if fetching fails
setSurveyUrl(`${publicDomain}/s/${survey.id}`);
}
};
fetchSurveyUrl();
}, [survey, publicDomain]);
useEffect(() => {
if (survey.type !== "link") {
setActiveId(tabs[3].id);
@@ -101,7 +86,7 @@ export const ShareEmbedSurvey = ({
};
const handleInitialPageButton = () => {
setShowView("start");
setOpen(false);
};
return (
@@ -120,7 +105,7 @@ export const ShareEmbedSurvey = ({
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
@@ -174,7 +159,7 @@ export const ShareEmbedSurvey = ({
survey={survey}
email={email}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>

View File

@@ -104,15 +104,13 @@ describe("SummaryDropOffs", () => {
// Check drop-off counts and percentages
expect(screen.getByText("20")).toBeInTheDocument();
expect(screen.getByText("15")).toBeInTheDocument();
expect(screen.getByText("10")).toBeInTheDocument();
expect(screen.getByText("(20%)")).toBeInTheDocument();
// Check percentage values
const percentageElements = screen.getAllByText(/\d+%/);
expect(percentageElements).toHaveLength(3);
expect(percentageElements[0]).toHaveTextContent("20%");
expect(percentageElements[1]).toHaveTextContent("19%");
expect(percentageElements[2]).toHaveTextContent("15%");
expect(screen.getByText("15")).toBeInTheDocument();
expect(screen.getByText("(19%)")).toBeInTheDocument(); // 18.75% rounded to 19%
expect(screen.getByText("10")).toBeInTheDocument();
expect(screen.getByText("(15%)")).toBeInTheDocument(); // 15.38% rounded to 15%
});
test("renders empty state when dropOff array is empty", () => {

View File

@@ -23,9 +23,9 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="">
<div className="grid min-h-10 grid-cols-6 items-center rounded-t-xl border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-3 px-4 md:px-6">{t("common.questions")}</div>
<div className="flex justify-end px-4 md:px-6">
<div className="grid h-10 grid-cols-6 items-center border-y border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-3 pl-4 md:pl-6">{t("common.questions")}</div>
<div className="flex justify-center">
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
@@ -37,16 +37,14 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
</Tooltip>
</TooltipProvider>
</div>
<div className="px-4 text-right md:px-6">{t("environments.surveys.summary.impressions")}</div>
<div className="px-4 text-right md:mr-1 md:pl-6 md:pr-6">
{t("environments.surveys.summary.drop_offs")}
</div>
<div className="px-4 text-center md:px-6">{t("environments.surveys.summary.impressions")}</div>
<div className="pr-6 text-center md:pl-6">{t("environments.surveys.summary.drop_offs")}</div>
</div>
{dropOff.map((quesDropOff) => (
<div
key={quesDropOff.questionId}
className="grid grid-cols-6 items-start border-b border-slate-100 text-xs text-slate-800 md:text-sm">
<div className="col-span-3 flex gap-3 px-4 py-2 md:px-6">
className="grid grid-cols-6 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="col-span-3 flex gap-3 pl-4 md:pl-6">
{getIcon(quesDropOff.questionType)}
<p>
{formatTextWithSlashes(
@@ -59,21 +57,17 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
"default"
)["default"],
"@",
["text-sm"]
["text-lg"]
)}
</p>
</div>
<div className="whitespace-pre-wrap px-4 py-2 text-right font-mono font-medium md:px-6">
<div className="whitespace-pre-wrap text-center font-semibold">
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
</div>
<div className="whitespace-pre-wrap px-4 py-2 text-right font-mono font-medium md:px-6">
{quesDropOff.impressions}
</div>
<div className="px-4 py-2 text-right md:px-6">
<span className="mr-1 inline-block w-fit rounded-xl bg-slate-100 px-2 py-1 text-left text-xs">
{Math.round(quesDropOff.dropOffPercentage)}%
</span>
<span className="mr-1 font-mono font-medium">{quesDropOff.dropOffCount}</span>
<div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.impressions}</div>
<div className="pl-6 text-center md:px-6">
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>
</div>
</div>
))}

View File

@@ -38,10 +38,18 @@ interface SummaryListProps {
responseCount: number | null;
environment: TEnvironment;
survey: TSurvey;
totalResponseCount: number;
locale: TUserLocale;
}
export const SummaryList = ({ summary, environment, responseCount, survey, locale }: SummaryListProps) => {
export const SummaryList = ({
summary,
environment,
responseCount,
survey,
totalResponseCount,
locale,
}: SummaryListProps) => {
const { setSelectedFilter, selectedFilter } = useResponseFilter();
const { t } = useTranslate();
const setFilter = (
@@ -107,7 +115,11 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
type="response"
environment={environment}
noWidgetRequired={survey.type === "link"}
emptyMessage={t("environments.surveys.summary.no_responses_found")}
emptyMessage={
totalResponseCount === 0
? undefined
: t("environments.surveys.summary.no_response_matches_filter")
}
/>
) : (
summary.map((questionSummary) => {

View File

@@ -1,23 +1,30 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import {
getResponseCountAction,
getSurveySummaryAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getSummaryBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import {
getResponseCountBySurveySharingKeyAction,
getSummaryBySurveySharingKeyAction,
} from "@/app/share/[sharingKey]/actions";
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { useParams, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { SummaryList } from "./SummaryList";
import { SummaryMetadata } from "./SummaryMetadata";
const defaultSurveySummary: TSurveySummary = {
const initialSurveySummary: TSurveySummary = {
meta: {
completedPercentage: 0,
completedResponses: 0,
@@ -36,80 +43,111 @@ interface SummaryPageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
publicDomain: string;
webAppUrl: string;
user?: TUser;
totalResponseCount: number;
documentsPerPage?: number;
locale: TUserLocale;
isReadOnly: boolean;
initialSurveySummary?: TSurveySummary;
}
export const SummaryPage = ({
environment,
survey,
surveyId,
publicDomain,
webAppUrl,
totalResponseCount,
locale,
isReadOnly,
initialSurveySummary,
}: SummaryPageProps) => {
const params = useParams();
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const searchParams = useSearchParams();
const isShareEmbedModalOpen = searchParams.get("share") === "true";
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
initialSurveySummary || defaultSurveySummary
);
const [responseCount, setResponseCount] = useState<number | null>(null);
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(initialSurveySummary);
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const [isLoading, setIsLoading] = useState(true);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
// Only fetch data when filters change or when there's no initial data
useEffect(() => {
// If we have initial data and no filters are applied, don't fetch
const hasNoFilters =
(!selectedFilter ||
Object.keys(selectedFilter).length === 0 ||
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
(!dateRange || (!dateRange.from && !dateRange.to));
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
[selectedFilter, dateRange, survey]
);
if (initialSurveySummary && hasNoFilters) {
setIsLoading(false);
return;
}
// Use a ref to keep the latest state and props
const latestFiltersRef = useRef(filters);
latestFiltersRef.current = filters;
const fetchSummary = async () => {
setIsLoading(true);
const getResponseCount = useCallback(() => {
if (isSharingPage)
return getResponseCountBySurveySharingKeyAction({
sharingKey,
filterCriteria: latestFiltersRef.current,
});
return getResponseCountAction({
surveyId,
filterCriteria: latestFiltersRef.current,
});
}, [isSharingPage, sharingKey, surveyId]);
const getSummary = useCallback(() => {
if (isSharingPage)
return getSummaryBySurveySharingKeyAction({
sharingKey,
filterCriteria: latestFiltersRef.current,
});
return getSurveySummaryAction({
surveyId,
filterCriteria: latestFiltersRef.current,
});
}, [isSharingPage, sharingKey, surveyId]);
const handleInitialData = useCallback(
async (isInitialLoad = false) => {
if (isInitialLoad) {
setIsLoading(true);
}
try {
// Recalculate filters inside the effect to ensure we have the latest values
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
let updatedSurveySummary;
const [updatedResponseCountData, updatedSurveySummary] = await Promise.all([
getResponseCount(),
getSummary(),
]);
if (isSharingPage) {
updatedSurveySummary = await getSummaryBySurveySharingKeyAction({
sharingKey,
filterCriteria: currentFilters,
});
} else {
updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
}
const responseCount = updatedResponseCountData?.data ?? 0;
const surveySummary = updatedSurveySummary?.data ?? initialSurveySummary;
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
setResponseCount(responseCount);
setSurveySummary(surveySummary);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
if (isInitialLoad) {
setIsLoading(false);
}
}
};
},
[getResponseCount, getSummary]
);
fetchSummary();
}, [selectedFilter, dateRange, survey, isSharingPage, sharingKey, surveyId, initialSurveySummary]);
useEffect(() => {
handleInitialData(true);
}, [filters, isSharingPage, sharingKey, surveyId, handleInitialData]);
useIntervalWhenFocused(
() => {
handleInitialData(false);
},
10000,
!isShareEmbedModalOpen,
false
);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
@@ -133,15 +171,16 @@ export const SummaryPage = ({
<div className="flex gap-1.5">
<CustomFilter survey={surveyMemoized} />
{!isReadOnly && !isSharingPage && (
<ResultsShareButton survey={surveyMemoized} publicDomain={publicDomain} />
<ResultsShareButton survey={surveyMemoized} webAppUrl={webAppUrl} />
)}
</div>
<ScrollToTop containerId="mainContent" />
<SummaryList
summary={surveySummary.summary}
responseCount={surveySummary.meta.totalResponses}
responseCount={responseCount}
survey={surveyMemoized}
environment={environment}
totalResponseCount={totalResponseCount}
locale={locale}
/>
</>

View File

@@ -7,22 +7,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { SurveyAnalysisCTA } from "./SurveyAnalysisCTA";
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/utils", () => ({
withAuditLogging: vi.fn((...args: any[]) => {
// Check if the last argument is a function and return it directly
if (typeof args[args.length - 1] === "function") {
return args[args.length - 1];
}
// Otherwise, return a new function that takes a function as an argument and returns it
return (fn: any) => fn;
}),
}));
const mockPublicDomain = "https://public-domain.com";
// Mock constants
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
@@ -46,15 +30,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
AUDIT_LOG_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "mock-url",
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
// Create a spy for refreshSingleUseId so we can override it in tests
@@ -75,12 +51,11 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
useSearchParams: () => mockSearchParams,
usePathname: () => "/current",
useParams: () => ({ environmentId: "env123", surveyId: "survey123" }),
}));
// Mock copySurveyLink to return a predictable string
vi.mock("@/modules/survey/lib/client-utils", () => ({
copySurveyLink: vi.fn((url: string, suId: string) => `${url}?suId=${suId}`),
copySurveyLink: vi.fn((url: string, id: string) => `${url}?id=${id}`),
}));
// Mock the copy survey action
@@ -94,27 +69,6 @@ vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((response) => response?.error || "Unknown error"),
}));
// Mock ResponseCountProvider dependencies
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
useResponseFilter: vi.fn(() => ({ selectedFilter: "all", dateRange: {} })),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions", () => ({
getResponseCountAction: vi.fn(() => Promise.resolve({ data: 5 })),
}));
vi.mock("@/app/lib/surveys/surveys", () => ({
getFormattedFilters: vi.fn(() => []),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getResponseCountBySurveySharingKeyAction: vi.fn(() => Promise.resolve({ data: 5 })),
}));
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn(() => mockPublicDomain),
}));
vi.spyOn(toast, "success");
vi.spyOn(toast, "error");
@@ -135,6 +89,7 @@ const dummySurvey = {
} as unknown as TSurvey;
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
const dummyUser = { id: "user123", name: "Test User" } as TUser;
const surveyDomain = "https://surveys.test.formbricks.com";
describe("SurveyAnalysisCTA - handleCopyLink", () => {
afterEach(() => {
@@ -147,7 +102,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
@@ -158,7 +113,9 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
await waitFor(() => {
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
expect(writeTextMock).toHaveBeenCalledWith("https://public-domain.com/s/survey123?suId=newSingleUseId");
expect(writeTextMock).toHaveBeenCalledWith(
"https://surveys.test.formbricks.com/s/survey123?id=newSingleUseId"
);
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
});
@@ -170,7 +127,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
@@ -203,7 +160,7 @@ describe("SurveyAnalysisCTA - Edit functionality", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
@@ -224,7 +181,7 @@ describe("SurveyAnalysisCTA - Edit functionality", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={0}
/>
@@ -246,7 +203,7 @@ describe("SurveyAnalysisCTA - Edit functionality", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={true}
publicDomain={mockPublicDomain}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
@@ -275,7 +232,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
@@ -318,7 +275,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
@@ -344,7 +301,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
@@ -378,7 +335,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>

View File

@@ -24,7 +24,7 @@ interface SurveyAnalysisCTAProps {
environment: TEnvironment;
isReadOnly: boolean;
user: TUser;
publicDomain: string;
surveyDomain: string;
responseCount: number;
}
@@ -40,7 +40,7 @@ export const SurveyAnalysisCTA = ({
environment,
isReadOnly,
user,
publicDomain,
surveyDomain,
responseCount,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
@@ -56,7 +56,7 @@ export const SurveyAnalysisCTA = ({
dropdown: false,
});
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
const surveyUrl = useMemo(() => `${surveyDomain}/s/${survey.id}`, [survey.id, surveyDomain]);
const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -171,7 +171,7 @@ export const SurveyAnalysisCTA = ({
icon: SquarePenIcon,
tooltip: t("common.edit"),
onClick: () => {
responseCount > 0
responseCount && responseCount > 0
? setIsCautionDialogOpen(true)
: router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`);
},
@@ -202,7 +202,7 @@ export const SurveyAnalysisCTA = ({
<ShareEmbedSurvey
key={key}
survey={survey}
publicDomain={publicDomain}
surveyDomain={surveyDomain}
open={modalState[key as keyof ModalState]}
setOpen={setOpen}
user={user}

View File

@@ -64,7 +64,7 @@ const defaultProps = {
survey: mockSurveyLink,
email: "test@example.com",
surveyUrl: "http://example.com/survey1",
publicDomain: "http://example.com",
surveyDomain: "http://example.com",
setSurveyUrl: vi.fn(),
locale: "en" as any,
disableBack: false,

View File

@@ -20,7 +20,7 @@ interface EmbedViewProps {
survey: any;
email: string;
surveyUrl: string;
publicDomain: string;
surveyDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
locale: TUserLocale;
}
@@ -35,7 +35,7 @@ export const EmbedView = ({
survey,
email,
surveyUrl,
publicDomain,
surveyDomain,
setSurveyUrl,
locale,
}: EmbedViewProps) => {
@@ -83,7 +83,7 @@ export const EmbedView = ({
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>

View File

@@ -6,12 +6,12 @@ import { LinkTab } from "./LinkTab";
// Mock ShareSurveyLink
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
ShareSurveyLink: vi.fn(({ survey, surveyUrl, publicDomain, locale }) => (
ShareSurveyLink: vi.fn(({ survey, surveyUrl, surveyDomain, locale }) => (
<div data-testid="share-survey-link">
Mocked ShareSurveyLink
<span data-testid="survey-id">{survey.id}</span>
<span data-testid="survey-url">{surveyUrl}</span>
<span data-testid="public-domain">{publicDomain}</span>
<span data-testid="survey-domain">{surveyDomain}</span>
<span data-testid="locale">{locale}</span>
</div>
)),
@@ -49,7 +49,7 @@ const mockSurvey: TSurvey = {
} as unknown as TSurvey;
const mockSurveyUrl = "https://app.formbricks.com/s/survey1";
const mockPublicDomain = "https://app.formbricks.com";
const mockSurveyDomain = "https://app.formbricks.com";
const mockSetSurveyUrl = vi.fn();
const mockLocale: TUserLocale = "en-US";
@@ -82,7 +82,7 @@ describe("LinkTab", () => {
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
surveyDomain={mockSurveyDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
@@ -97,7 +97,7 @@ describe("LinkTab", () => {
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
surveyDomain={mockSurveyDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
@@ -105,7 +105,7 @@ describe("LinkTab", () => {
expect(screen.getByTestId("share-survey-link")).toBeInTheDocument();
expect(screen.getByTestId("survey-id")).toHaveTextContent(mockSurvey.id);
expect(screen.getByTestId("survey-url")).toHaveTextContent(mockSurveyUrl);
expect(screen.getByTestId("public-domain")).toHaveTextContent(mockPublicDomain);
expect(screen.getByTestId("survey-domain")).toHaveTextContent(mockSurveyDomain);
expect(screen.getByTestId("locale")).toHaveTextContent(mockLocale);
});
@@ -114,7 +114,7 @@ describe("LinkTab", () => {
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
surveyDomain={mockSurveyDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
@@ -129,7 +129,7 @@ describe("LinkTab", () => {
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
surveyDomain={mockSurveyDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>

View File

@@ -9,12 +9,12 @@ import { TUserLocale } from "@formbricks/types/user";
interface LinkTabProps {
survey: TSurvey;
surveyUrl: string;
publicDomain: string;
surveyDomain: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
}
export const LinkTab = ({ survey, surveyUrl, publicDomain, setSurveyUrl, locale }: LinkTabProps) => {
export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale }: LinkTabProps) => {
const { t } = useTranslate();
const docsLinks = [
@@ -44,7 +44,7 @@ export const LinkTab = ({ survey, surveyUrl, publicDomain, setSurveyUrl, locale
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>

View File

@@ -1,8 +1,9 @@
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import { getTranslate } from "@/tolgee/server";
import { cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
@@ -34,16 +35,7 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn().mockReturnValue("https://public-domain.com"),
}));
vi.mock("@/lib/getSurveyUrl");
vi.mock("@/lib/project/service");
vi.mock("@/lib/survey/service");
vi.mock("@/lib/utils/styling");
@@ -129,7 +121,7 @@ const mockComputedStyling = {
thankYouCardIconBgColor: "#DDDDDD",
} as any;
const mockPublicDomain = "https://app.formbricks.com";
const mockSurveyDomain = "https://app.formbricks.com";
const mockRawHtml = `${doctype}<html><body>Test Email Content for ${mockSurvey.name}</body></html>`;
const mockCleanedHtml = `<html><body>Test Email Content for ${mockSurvey.name}</body></html>`;
@@ -144,7 +136,7 @@ describe("getEmailTemplateHtml", () => {
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
vi.mocked(getStyling).mockReturnValue(mockComputedStyling);
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
vi.mocked(getSurveyDomain).mockReturnValue(mockSurveyDomain);
vi.mocked(getPreviewEmailTemplateHtml).mockResolvedValue(mockRawHtml);
});
@@ -155,8 +147,8 @@ describe("getEmailTemplateHtml", () => {
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockSurvey.environmentId);
expect(getStyling).toHaveBeenCalledWith(mockProject, mockSurvey);
expect(getPublicDomain).toHaveBeenCalledTimes(1);
const expectedSurveyUrl = `${mockPublicDomain}/s/${mockSurvey.id}`;
expect(getSurveyDomain).toHaveBeenCalledTimes(1);
const expectedSurveyUrl = `${mockSurveyDomain}/s/${mockSurvey.id}`;
expect(getPreviewEmailTemplateHtml).toHaveBeenCalledWith(
mockSurvey,
expectedSurveyUrl,

View File

@@ -1,4 +1,4 @@
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
@@ -17,7 +17,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
}
const styling = getStyling(project, survey);
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
const surveyUrl = getSurveyDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';

View File

@@ -1,3 +1,4 @@
import { cache } from "@/lib/cache";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service";
@@ -25,6 +26,23 @@ 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(),
}));
@@ -144,6 +162,10 @@ 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", () => {
@@ -204,6 +226,9 @@ describe("getSurveySummaryDropOff", () => {
requiredQuestionIds: [],
calculations: {},
});
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("calculates dropOff correctly with welcome card disabled", () => {
@@ -342,7 +367,9 @@ describe("getQuestionSummary", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
);
// React cache is already mocked globally - no need to mock it again
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("summarizes OpenText questions", async () => {
@@ -719,7 +746,9 @@ describe("getSurveySummary", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
);
// React cache is already mocked globally - no need to mock it again
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("returns survey summary successfully", async () => {
@@ -729,6 +758,7 @@ describe("getSurveySummary", () => {
expect(summary.dropOff).toBeDefined();
expect(summary.summary).toBeDefined();
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, undefined);
expect(prisma.response.findMany).toHaveBeenCalled(); // Check if getResponsesForSummary was effectively called
expect(getDisplayCountBySurveyId).toHaveBeenCalled();
});
@@ -740,6 +770,7 @@ describe("getSurveySummary", () => {
test("handles filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = { finished: true };
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(2); // Assume 2 finished responses
const finishedResponses = mockResponses
.filter((r) => r.finished)
.map((r) => ({ ...r, contactId: null, personAttributes: {} }));
@@ -747,6 +778,7 @@ describe("getSurveySummary", () => {
await getSurveySummary(mockSurveyId, filterCriteria);
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, filterCriteria);
expect(prisma.response.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ surveyId: mockSurveyId }), // buildWhereClause is mocked
@@ -766,7 +798,9 @@ describe("getResponsesForSummary", () => {
vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
);
// React cache is already mocked globally - no need to mock it again
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("fetches and transforms responses", async () => {
@@ -809,16 +843,6 @@ 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);
@@ -852,16 +876,6 @@ 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);
@@ -890,16 +904,6 @@ 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,14 +1,18 @@
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 { getResponseCountBySurveyId } from "@/lib/response/service";
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";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -901,57 +905,66 @@ export const getQuestionSummary = async (
};
export const getSurveySummary = reactCache(
async (surveyId: string, filterCriteria?: TResponseFilterCriteria): Promise<TSurveySummary> => {
validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]);
async (surveyId: string, filterCriteria?: TResponseFilterCriteria): Promise<TSurveySummary> =>
cache(
async () => {
validateInputs([surveyId, ZId], [filterCriteria, ZResponseFilterCriteria.optional()]);
try {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
try {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
const batchSize = 5000;
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
const batchSize = 5000;
const responseCount = await getResponseCountBySurveyId(surveyId, filterCriteria);
// Use cursor-based pagination instead of count + offset to avoid expensive queries
const responses: TSurveySummaryResponse[] = [];
let cursor: string | undefined = undefined;
let hasMore = true;
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
while (hasMore) {
const batch = await getResponsesForSummary(surveyId, batchSize, 0, filterCriteria, cursor);
responses.push(...batch);
const pages = Math.ceil(responseCount / batchSize);
if (batch.length < batchSize) {
hasMore = false;
} else {
// Use the last response's ID as cursor for next batch
cursor = batch[batch.length - 1].id;
// Create an array of batch fetch promises
const batchPromises = Array.from({ length: pages }, (_, i) =>
getResponsesForSummary(surveyId, batchSize, i * batchSize, filterCriteria)
);
// Fetch all batches in parallel
const batchResults = await Promise.all(batchPromises);
// Combine all batch results
const responses = batchResults.flat();
const responseIds = hasFilter ? responses.map((response) => response.id) : [];
const displayCount = await getDisplayCountBySurveyId(surveyId, {
createdAt: filterCriteria?.createdAt,
...(hasFilter && { responseIds }),
});
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
const [meta, questionWiseSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount),
getQuestionSummary(survey, responses, dropOff),
]);
return { meta, dropOff, summary: questionWiseSummary };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getSurveySummary-${surveyId}-${JSON.stringify(filterCriteria)}`],
{
tags: [
surveyCache.tag.byId(surveyId),
responseCache.tag.bySurveyId(surveyId),
displayCache.tag.bySurveyId(surveyId),
],
}
const 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(
@@ -959,87 +972,80 @@ export const getResponsesForSummary = reactCache(
surveyId: string,
limit: number,
offset: number,
filterCriteria?: TResponseFilterCriteria,
cursor?: string
): Promise<TSurveySummaryResponse[]> => {
validateInputs(
[surveyId, ZId],
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.string().cuid2().optional()]
);
filterCriteria?: TResponseFilterCriteria
): Promise<TSurveySummaryResponse[]> =>
cache(
async () => {
validateInputs(
[surveyId, ZId],
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()]
);
const queryLimit = limit ?? RESPONSES_PER_PAGE;
const survey = await getSurvey(surveyId);
if (!survey) return [];
try {
const whereClause: Prisma.ResponseWhereInput = {
surveyId,
...buildWhereClause(survey, filterCriteria),
};
// Add cursor condition for cursor-based pagination
if (cursor) {
whereClause.id = {
lt: cursor, // Get responses with ID less than cursor (for desc order)
};
}
const responses = await prisma.response.findMany({
where: whereClause,
select: {
id: true,
data: true,
updatedAt: true,
contact: {
const queryLimit = limit ?? RESPONSES_PER_PAGE;
const survey = await getSurvey(surveyId);
if (!survey) return [];
try {
const responses = await prisma.response.findMany({
where: {
surveyId,
...buildWhereClause(survey, filterCriteria),
},
select: {
id: true,
attributes: {
select: { attributeKey: true, value: true },
data: true,
updatedAt: true,
contact: {
select: {
id: true,
attributes: {
select: { attributeKey: true, value: true },
},
},
},
contactAttributes: true,
language: true,
ttc: true,
finished: 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,
});
orderBy: [
{
createdAt: "desc",
},
],
take: queryLimit,
skip: offset,
});
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
...responsePrisma,
contact: responsePrisma.contact
? {
id: responsePrisma.contact.id as string,
userId: responsePrisma.contact.attributes.find(
(attribute) => attribute.attributeKey.key === "userId"
)?.value as string,
}
: null,
};
})
);
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);
return transformedResponses;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getResponsesForSummary-${surveyId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
{
tags: [responseCache.tag.bySurveyId(surveyId)],
}
throw error;
}
}
)()
);

View File

@@ -1,10 +1,8 @@
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import SurveyPage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
@@ -38,8 +36,9 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
WEBAPP_URL: "http://localhost:3000",
RESPONSES_PER_PAGE: 10,
SESSION_MAX_AGE: 1000,
DOCUMENTS_PER_PAGE: 10,
}));
vi.mock(
@@ -63,8 +62,8 @@ vi.mock(
})
);
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn(),
vi.mock("@/lib/getSurveyUrl", () => ({
getSurveyDomain: vi.fn(),
}));
vi.mock("@/lib/response/service", () => ({
@@ -79,13 +78,6 @@ vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary",
() => ({
getSurveySummary: vi.fn(),
})
);
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
@@ -108,11 +100,6 @@ vi.mock("@/tolgee/server", () => ({
vi.mock("next/navigation", () => ({
notFound: vi.fn(),
useParams: () => ({
environmentId: "test-environment-id",
surveyId: "test-survey-id",
sharingKey: null,
}),
}));
const mockEnvironmentId = "test-environment-id";
@@ -185,21 +172,6 @@ const mockSession = {
expires: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour from now
} as any;
const mockSurveySummary = {
meta: {
completedPercentage: 75,
completedResponses: 15,
displayCount: 20,
dropOffPercentage: 25,
dropOffCount: 5,
startsPercentage: 80,
totalResponses: 20,
ttcAverage: 120,
},
dropOff: [],
summary: [],
};
describe("SurveyPage", () => {
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
@@ -210,8 +182,7 @@ describe("SurveyPage", () => {
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(getPublicDomain).mockReturnValue("http://localhost:3000");
vi.mocked(getSurveySummary).mockResolvedValue(mockSurveySummary);
vi.mocked(getSurveyDomain).mockReturnValue("test.domain.com");
vi.mocked(notFound).mockClear();
});
@@ -222,8 +193,7 @@ describe("SurveyPage", () => {
test("renders correctly with valid data", async () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
const jsx = await SurveyPage({ params });
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
render(await SurveyPage({ params }));
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
@@ -234,13 +204,15 @@ describe("SurveyPage", () => {
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);
expect(vi.mocked(getUser)).toHaveBeenCalledWith(mockUserId);
expect(vi.mocked(getPublicDomain)).toHaveBeenCalled();
expect(vi.mocked(getResponseCountBySurveyId)).toHaveBeenCalledWith(mockSurveyId);
expect(vi.mocked(getSurveyDomain)).toHaveBeenCalled();
expect(vi.mocked(SurveyAnalysisNavigation).mock.calls[0][0]).toEqual(
expect.objectContaining({
environmentId: mockEnvironmentId,
survey: mockSurvey,
activeId: "summary",
initialTotalResponseCount: 10,
})
);
@@ -249,18 +221,19 @@ describe("SurveyPage", () => {
environment: mockEnvironment,
survey: mockSurvey,
surveyId: mockSurveyId,
publicDomain: "http://localhost:3000",
webAppUrl: WEBAPP_URL,
user: mockUser,
totalResponseCount: 10,
documentsPerPage: DOCUMENTS_PER_PAGE,
isReadOnly: false,
locale: mockUser.locale ?? DEFAULT_LOCALE,
initialSurveySummary: mockSurveySummary,
})
);
});
test("calls notFound if surveyId is not present in params", async () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: undefined }) as any;
const jsx = await SurveyPage({ params });
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
render(await SurveyPage({ params }));
expect(vi.mocked(notFound)).toHaveBeenCalled();
});
@@ -270,7 +243,7 @@ describe("SurveyPage", () => {
try {
// We need to await the component itself because it's an async component
const SurveyPageComponent = await SurveyPage({ params });
render(<ResponseFilterProvider>{SurveyPageComponent}</ResponseFilterProvider>);
render(SurveyPageComponent);
} catch (e: any) {
expect(e.message).toBe("common.survey_not_found");
}
@@ -283,7 +256,7 @@ describe("SurveyPage", () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
try {
const SurveyPageComponent = await SurveyPage({ params });
render(<ResponseFilterProvider>{SurveyPageComponent}</ResponseFilterProvider>);
render(SurveyPageComponent);
} catch (e: any) {
expect(e.message).toBe("common.user_not_found");
}

View File

@@ -1,9 +1,9 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -37,10 +37,12 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
throw new Error(t("common.user_not_found"));
}
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
const publicDomain = getPublicDomain();
// I took this out cause it's cloud only right?
// const { active: isEnterpriseEdition } = await getEnterpriseLicense();
const surveyDomain = getSurveyDomain();
return (
<PageContentWrapper>
@@ -52,23 +54,30 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
surveyDomain={surveyDomain}
responseCount={totalResponseCount}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
<SurveyAnalysisNavigation
environmentId={environment.id}
survey={survey}
activeId="summary"
initialTotalResponseCount={totalResponseCount}
/>
</PageHeader>
<SummaryPage
environment={environment}
survey={survey}
surveyId={params.surveyId}
publicDomain={publicDomain}
webAppUrl={WEBAPP_URL}
user={user}
totalResponseCount={totalResponseCount}
documentsPerPage={DOCUMENTS_PER_PAGE}
isReadOnly={isReadOnly}
locale={user.locale ?? DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}
/>
<SettingsId title={t("common.survey_id")} id={surveyId} />
<SettingsId title={t("common.survey_id")} id={surveyId}></SettingsId>
</PageContentWrapper>
);
};

View File

@@ -5,10 +5,8 @@ import { getResponseDownloadUrl, getResponseFilteringValues } from "@/lib/respon
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
@@ -16,7 +14,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
import { ZSurvey } from "@formbricks/types/surveys/types";
const ZGetResponsesDownloadUrlAction = z.object({
surveyId: ZId,
@@ -104,54 +102,39 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
}
};
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
withAuditLogging(
"updated",
"survey",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurvey }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
export const updateSurveyAction = authenticatedActionClient
.schema(ZSurvey)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
)
);
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
return await updateSurvey(parsedInput);
});

View File

@@ -138,7 +138,7 @@ describe("ResultsShareButton", () => {
test("renders initial state and fetches sharing key (no existing key)", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
expect(screen.getByTestId("dropdown-menu-trigger")).toBeInTheDocument();
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
@@ -150,7 +150,7 @@ describe("ResultsShareButton", () => {
test("handles copy private link to clipboard", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
const copyLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
@@ -166,9 +166,7 @@ describe("ResultsShareButton", () => {
test("handles copy public link to clipboard", async () => {
const shareKey = "publicShareKey";
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
render(
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
);
render(<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} webAppUrl={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
const copyPublicLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
@@ -186,7 +184,7 @@ describe("ResultsShareButton", () => {
test("handles publish to web successfully", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
mockGenerateResultShareUrlAction.mockResolvedValue({ data: "newShareKey" });
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
@@ -212,9 +210,7 @@ describe("ResultsShareButton", () => {
const shareKey = "toUnpublishKey";
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
mockDeleteResultShareUrlAction.mockResolvedValue({ data: { id: mockSurvey.id } });
render(
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
);
render(<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} webAppUrl={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const unpublishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
@@ -238,7 +234,7 @@ describe("ResultsShareButton", () => {
test("opens and closes ShareSurveyResults modal", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>

View File

@@ -21,10 +21,10 @@ import { ShareSurveyResults } from "../(analysis)/summary/components/ShareSurvey
interface ResultsShareButtonProps {
survey: TSurvey;
publicDomain: string;
webAppUrl: string;
}
export const ResultsShareButton = ({ survey, publicDomain }: ResultsShareButtonProps) => {
export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProps) => {
const { t } = useTranslate();
const [showResultsLinkModal, setShowResultsLinkModal] = useState(false);
@@ -34,7 +34,7 @@ export const ResultsShareButton = ({ survey, publicDomain }: ResultsShareButtonP
const handlePublish = async () => {
const resultShareKeyResponse = await generateResultShareUrlAction({ surveyId: survey.id });
if (resultShareKeyResponse?.data) {
setSurveyUrl(publicDomain + "/share/" + resultShareKeyResponse.data);
setSurveyUrl(webAppUrl + "/share/" + resultShareKeyResponse.data);
setShowPublishModal(true);
} else {
const errorMessage = getFormattedErrorMessage(resultShareKeyResponse);
@@ -58,13 +58,13 @@ export const ResultsShareButton = ({ survey, publicDomain }: ResultsShareButtonP
const fetchSharingKey = async () => {
const resultShareUrlResponse = await getResultShareUrlAction({ surveyId: survey.id });
if (resultShareUrlResponse?.data) {
setSurveyUrl(publicDomain + "/share/" + resultShareUrlResponse.data);
setSurveyUrl(webAppUrl + "/share/" + resultShareUrlResponse.data);
setShowPublishModal(true);
}
};
fetchSharingKey();
}, [survey.id, publicDomain]);
}, [survey.id, webAppUrl]);
const copyUrlToClipboard = () => {
if (typeof window !== "undefined") {

View File

@@ -11,7 +11,6 @@ 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";
@@ -29,7 +28,6 @@ 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 =
@@ -49,8 +47,6 @@ export const SurveyStatusDropdown = ({
? t("common.survey_completed")
: ""
);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
toast.error(errorMessage);

View File

@@ -39,8 +39,6 @@ vi.mock("@/lib/constants", () => ({
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({

View File

@@ -14,64 +14,41 @@ describe("ClientEnvironmentRedirect", () => {
cleanup();
});
test("should redirect to the first environment ID when no last environment exists", () => {
test("should redirect to the provided environment ID when no last environment exists", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage
const localStorageMock = {
getItem: vi.fn().mockReturnValue(null),
removeItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
render(<ClientEnvironmentRedirect userEnvironments={["test-env-id"]} />);
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id");
});
test("should redirect to the last environment ID when it exists in localStorage and is valid", () => {
test("should redirect to the last environment ID when it exists in localStorage", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage with a last environment ID
const localStorageMock = {
getItem: vi.fn().mockReturnValue("last-env-id"),
removeItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
render(<ClientEnvironmentRedirect userEnvironments={["last-env-id", "other-env-id"]} />);
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(mockPush).toHaveBeenCalledWith("/environments/last-env-id");
});
test("should clear invalid environment ID and redirect to default when stored ID is not in user environments", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage with an invalid environment ID
const localStorageMock = {
getItem: vi.fn().mockReturnValue("invalid-env-id"),
removeItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
render(<ClientEnvironmentRedirect userEnvironments={["valid-env-1", "valid-env-2"]} />);
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(localStorageMock.removeItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(mockPush).toHaveBeenCalledWith("/environments/valid-env-1");
});
test("should update redirect when environment ID prop changes", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
@@ -79,20 +56,19 @@ describe("ClientEnvironmentRedirect", () => {
// Mock localStorage
const localStorageMock = {
getItem: vi.fn().mockReturnValue(null),
removeItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
const { rerender } = render(<ClientEnvironmentRedirect userEnvironments={["initial-env-id"]} />);
const { rerender } = render(<ClientEnvironmentRedirect environmentId="initial-env-id" />);
expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id");
// Clear mock calls
mockPush.mockClear();
// Rerender with new environment ID
rerender(<ClientEnvironmentRedirect userEnvironments={["new-env-id"]} />);
rerender(<ClientEnvironmentRedirect environmentId="new-env-id" />);
expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id");
});
});

View File

@@ -5,23 +5,22 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
interface ClientEnvironmentRedirectProps {
userEnvironments: string[];
environmentId: string;
}
const ClientEnvironmentRedirect = ({ userEnvironments }: ClientEnvironmentRedirectProps) => {
const ClientEnvironmentRedirect = ({ environmentId }: ClientEnvironmentRedirectProps) => {
const router = useRouter();
useEffect(() => {
const lastEnvironmentId = localStorage.getItem(FORMBRICKS_ENVIRONMENT_ID_LS);
if (lastEnvironmentId && userEnvironments.includes(lastEnvironmentId)) {
if (lastEnvironmentId) {
// Redirect to the last environment the user was in
router.push(`/environments/${lastEnvironmentId}`);
} else {
// If the last environmentId is not valid, remove it from localStorage and redirect to the provided environmentId
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
router.push(`/environments/${userEnvironments[0]}`);
router.push(`/environments/${environmentId}`);
}
}, [userEnvironments, router]);
}, [environmentId, router]);
return null;
};

View File

@@ -1,14 +1,14 @@
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";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
@@ -51,17 +51,22 @@ export const POST = async (request: Request) => {
}
// Fetch webhooks
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 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 webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
// Prepare webhook and email promises
@@ -181,33 +186,10 @@ export const POST = async (request: Request) => {
// Update survey status if necessary
if (survey.autoComplete && responseCount >= survey.autoComplete) {
let logStatus: TAuditStatus = "success";
try {
await updateSurvey({
...survey,
status: "completed",
});
} catch (error) {
logStatus = "failure";
logger.error(
{ error, url: request.url, surveyId },
`Failed to update survey ${surveyId} status to completed`
);
} finally {
await queueAuditEvent({
status: logStatus,
action: "updated",
targetType: "survey",
userId: UNKNOWN_DATA,
userType: "system",
targetId: survey.id,
organizationId: organization.id,
newObject: {
status: "completed",
},
});
}
await updateSurvey({
...survey,
status: "completed",
});
}
// Await webhook and email promises with allSettled to prevent early rejection

View File

@@ -1,140 +1,8 @@
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { authOptions } from "@/modules/auth/lib/authOptions";
import NextAuth from "next-auth";
import { logger } from "@formbricks/logger";
export const fetchCache = "force-no-store";
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
const authOptions = {
...baseAuthOptions,
callbacks: {
...baseAuthOptions.callbacks,
async jwt(params: any) {
let result: any = params.token;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.jwt) {
result = await baseAuthOptions.callbacks.jwt(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("JWT callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
// Audit JWT operations (token refresh, updates)
if (params.trigger && params.token?.profile?.id) {
const status: TAuditStatus = error ? "failure" : "success";
const auditLog = {
action: "jwtTokenCreated" as const,
targetType: "user" as const,
userId: params.token.profile.id,
targetId: params.token.profile.id,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: { trigger: params.trigger, tokenType: "jwt" },
...(error ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
}
if (error) throw error;
return result;
},
async session(params: any) {
let result: any = params.session;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.session) {
result = await baseAuthOptions.callbacks.session(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("Session callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
if (error) throw error;
return result;
},
async signIn({ user, account, profile, email, credentials }) {
let result: boolean | string = true;
let error: any = undefined;
let authMethod = "unknown";
try {
if (baseAuthOptions.callbacks?.signIn) {
result = await baseAuthOptions.callbacks.signIn({
user,
account,
profile,
email,
credentials,
});
}
// Determine authentication method for more detailed logging
if (account?.provider === "credentials") {
authMethod = "password";
} else if (account?.provider === "token") {
authMethod = "email_verification";
} else if (account?.provider && account.provider !== "credentials") {
authMethod = "sso";
}
} catch (err) {
error = err;
result = false;
logger.withContext({ eventId, err }).error("User sign-in failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
const status: TAuditStatus = result === false ? "failure" : "success";
const auditLog = {
action: "signedIn" as const,
targetType: "user" as const,
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error ? { errorMessage: error.message } : {}),
},
...(status === "failure" ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
if (error) throw error;
return result;
},
},
};
return NextAuth(authOptions)(req, ctx);
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -1,6 +1,5 @@
import { responses } from "@/app/lib/api/response";
import { CRON_SECRET } from "@/lib/constants";
import { env } from "@/lib/env";
import { captureTelemetry } from "@/lib/telemetry";
import packageJson from "@/package.json";
import { headers } from "next/headers";
@@ -14,10 +13,6 @@ export const POST = async () => {
return responses.notAuthenticatedResponse();
}
if (env.TELEMETRY_DISABLED === "1") {
return responses.successResponse({}, true);
}
const [surveyCount, responseCount, userCount] = await Promise.all([
prisma.survey.count(),
prisma.response.count(),

View File

@@ -1,5 +1,6 @@
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";
@@ -65,6 +66,15 @@ 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,6 +6,7 @@ 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 {
@@ -132,6 +133,14 @@ 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,5 +1,6 @@
import { cache } from "@/lib/cache";
import { TContact } from "@/modules/ee/contacts/types/contact";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getContactByUserId } from "./contact";
@@ -12,6 +13,15 @@ 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";
@@ -27,6 +37,12 @@ const contactMock: Partial<TContact> & {
};
describe("getContactByUserId", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => {
vi.resetAllMocks();
});

View File

@@ -1,9 +1,11 @@
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<{
@@ -14,29 +16,36 @@ export const getContactByUserId = reactCache(
};
}[];
id: string;
} | null> => {
const contact = await prisma.contact.findFirst({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
} | null> =>
cache(
async () => {
const contact = await prisma.contact.findFirst({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
value: userId,
},
},
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
if (!contact) {
return null;
}
if (!contact) {
return null;
}
return contact;
}
return contact;
},
[`getContactByUserId-sync-api-${environmentId}-${userId}`],
{
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
}
)()
);

View File

@@ -1,3 +1,4 @@
import { cache } from "@/lib/cache";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurveys } from "@/lib/survey/service";
import { anySurveyHasFilters } from "@/lib/survey/utils";
@@ -13,6 +14,15 @@ 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(),
}));
@@ -110,6 +120,9 @@ 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,5 +1,11 @@
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";
@@ -14,135 +20,154 @@ 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[]> => {
validateInputs([environmentId, ZId]);
try {
const product = await getProjectByEnvironmentId(environmentId);
): Promise<TSurvey[]> =>
cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const product = await getProjectByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
if (!product) {
throw new Error("Product not found");
}
let surveys = await getSurveys(environmentId);
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "app");
// 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 [];
}
// if no surveys are left, return an empty array
if (surveys.length === 0) {
return [];
}
const displays = await prisma.display.findMany({
where: {
contactId,
},
});
const displays = await prisma.display.findMany({
where: {
contactId,
},
});
const responses = await prisma.response.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;
// 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");
}
case "displaySome":
if (survey.displayLimit === null) {
});
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 (responses && responses.filter((response) => response.surveyId === survey.id).length !== 0) {
return false;
// 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;
}
return displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit;
default:
throw Error("Invalid displayOption");
}
});
// Evaluate the segment filters
const result = await evaluateSegment(
{
attributes: contactAttributes ?? {},
deviceType,
environmentId,
contactId,
userId: String(contactAttributes.userId),
},
segment.filters
);
const latestDisplay = displays[0];
return result ? survey : null;
});
// 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;
const resolvedSurveys = await Promise.all(surveyPromises);
surveys = resolvedSurveys.filter((survey) => !!survey) as TSurvey[];
if (!surveys) {
throw new ResourceNotFoundError("Survey", environmentId);
}
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;
return surveys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
});
// if no surveys are left, return an empty array
if (surveys.length === 0) {
return [];
},
[`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 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;
}
}
)()
);

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