Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd61c00ad1 | ||
|
|
d8f422f98d | ||
|
|
7f200df1ac | ||
|
|
9b38ed65c0 | ||
|
|
25c20e5093 | ||
|
|
ec8a441649 | ||
|
|
04a91dc71a | ||
|
|
2023926a3a | ||
|
|
75465ac4bf | ||
|
|
2dbd0b3832 | ||
|
|
3b7d97b194 | ||
|
|
a1fab0e744 | ||
|
|
87331f0a3a | ||
|
|
6cd4f1fcaa | ||
|
|
6b84dc09a7 | ||
|
|
d125064430 | ||
|
|
39490b6883 | ||
|
|
e45f9c5a13 | ||
|
|
9ceb5ea3c5 | ||
|
|
16bd52148e | ||
|
|
9a2705c84c | ||
|
|
6c069a4afe | ||
|
|
42cbc46609 | ||
|
|
096026b92a | ||
|
|
060eafac60 | ||
|
|
f6f36750ec | ||
|
|
2e9aa01771 | ||
|
|
40d70cd1d4 | ||
|
|
17ef0dc12b | ||
|
|
e530ad5412 | ||
|
|
882eb85a33 | ||
|
|
6b0726bcde | ||
|
|
712467a7f4 | ||
|
|
0a9e732ea4 | ||
|
|
b101157d67 |
@@ -0,0 +1,82 @@
|
||||
# Cal.com Development Guide for AI Agents
|
||||
|
||||
This directory contains comprehensive documentation for AI agents working on the Cal.com codebase.
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
- **[Commands](commands.md)** - Build, test, and development commands
|
||||
- **[Knowledge Base](knowledge-base.md)** - Knowledge base & best practices
|
||||
- **[Architecture Overview](#architecture-overview)** - System structure and patterns
|
||||
|
||||
## Getting Started
|
||||
|
||||
Cal.com is a monorepo using Yarn workspaces and Turbo for build orchestration. The main application is in `apps/web/` with shared packages in `packages/`.
|
||||
|
||||
### Key Directories
|
||||
|
||||
- `apps/web/` - Main Next.js application
|
||||
- `packages/prisma/` - Database schema and migrations
|
||||
- `packages/trpc/` - API layer using tRPC
|
||||
- `packages/ui/` - Shared UI components
|
||||
- `packages/features/` - Feature-specific code
|
||||
- `packages/app-store/` - Third-party app integrations
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Database Layer
|
||||
|
||||
- **Prisma ORM** with PostgreSQL
|
||||
- Schema in `packages/prisma/schema.prisma`
|
||||
- Always use `select` instead of `include` for better performance
|
||||
- Never expose `credential.key` field in API responses
|
||||
|
||||
### API Layer
|
||||
|
||||
- **tRPC** for type-safe APIs
|
||||
- Routers in `packages/trpc/server/routers/`
|
||||
- Authentication handled via NextAuth.js
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Next.js 13+** with App Router in some areas
|
||||
- **React 18** with TypeScript
|
||||
- **Tailwind CSS** for styling
|
||||
- Internationalization with `next-i18next`
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Use early returns to reduce nesting
|
||||
- Throw descriptive errors with proper error codes
|
||||
- Prefer composition over prop drilling
|
||||
|
||||
### Performance
|
||||
|
||||
- Avoid O(n²) logic in backend code
|
||||
- Minimize Day.js usage in performance-critical paths
|
||||
- Use `select` queries to only fetch needed data
|
||||
- Consider using `.utc()` for Day.js operations
|
||||
|
||||
### Security
|
||||
|
||||
- Never commit secrets or API keys
|
||||
- Always validate input data
|
||||
- Use proper authentication checks
|
||||
- Never expose sensitive credential fields
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit tests** with Vitest
|
||||
- **Integration tests** for complex workflows
|
||||
- **E2E tests** with Playwright
|
||||
- Test files use `.test.ts` or `.spec.ts` extensions
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
For large PRs (>500 lines or >10 files):
|
||||
|
||||
- Split by feature boundaries
|
||||
- Separate database migrations, backend logic, frontend components
|
||||
- Create dependency chains that can be merged sequentially
|
||||
- Pattern: Database → Backend → Frontend → Tests
|
||||
@@ -0,0 +1,56 @@
|
||||
# Coding Standards & Best Practices
|
||||
|
||||
|
||||
|
||||
## Import Guidelines
|
||||
|
||||
### Type Imports
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Use type imports for TypeScript types
|
||||
import type { User } from "@prisma/client";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
// ❌ Bad - Regular import for types
|
||||
import { User } from "@prisma/client";
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Code Structure
|
||||
|
||||
### Early Returns
|
||||
|
||||
- Prefer early returns to reduce nesting: `if (!booking) return null;`
|
||||
|
||||
### Composition Over Prop Drilling
|
||||
|
||||
- Use React children and context instead of passing props through multiple components
|
||||
|
||||
### Security Rules
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER expose credential keys
|
||||
const user = await prisma.user.findFirst({
|
||||
select: {
|
||||
credentials: {
|
||||
select: {
|
||||
key: true, // ❌ NEVER do this
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ Good - Never select credential.key field
|
||||
const user = await prisma.user.findFirst({
|
||||
select: {
|
||||
credentials: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
// key field is excluded for security
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
# Build, Test & Development Commands
|
||||
|
||||
## Development Commands
|
||||
|
||||
- `yarn dev` - Start development server for web app
|
||||
- `yarn dev:all` - Start web, website, and console apps
|
||||
- `yarn dev:api` - Start web app with API proxy and API
|
||||
- `yarn dev:console` - Start web app with console
|
||||
- `yarn dx` - Start development with database setup
|
||||
|
||||
## Build Commands
|
||||
|
||||
- `yarn build` - Build all packages and apps
|
||||
- `yarn build:ai` - Build AI package specifically
|
||||
- `yarn clean` - Remove build artifacts (node_modules, .next, .turbo, dist)
|
||||
|
||||
## Lint & Type Check
|
||||
|
||||
- `yarn lint` - Run ESLint on codebase
|
||||
- `yarn lint:fix` - Run ESLint and fix issues
|
||||
- `yarn lint:report` - Generate lint report
|
||||
- `yarn type-check` - Run TypeScript type checking
|
||||
- `yarn format` - Format code with Prettier
|
||||
|
||||
## Testing Commands
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- `yarn test` - Run unit tests (vitest)
|
||||
- `yarn test <filename>` - Run tests for specific file
|
||||
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests for specific file
|
||||
- `yarn test <filename> -t "<testName>" -- --integrationTestsOnly` - Run specific test by name
|
||||
- `yarn tdd` - Run tests in watch mode
|
||||
- `yarn test:ui` - Run tests with UI interface
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
- `yarn e2e` - Run end-to-end tests (Playwright)
|
||||
- `yarn e2e <filename>` - Run E2E tests for specific file
|
||||
- `yarn e2e <filename> --grep "<testName>"` - Run specific E2E test by name
|
||||
- `yarn e2e:app-store` - Run app store E2E tests
|
||||
- `yarn e2e:embed` - Run embed E2E tests
|
||||
- `yarn test-e2e` - Run database seed + E2E tests
|
||||
|
||||
## Database Commands
|
||||
|
||||
- `yarn prisma` - Run Prisma CLI commands
|
||||
- `yarn db-seed` - Seed database with test data
|
||||
- `yarn db-deploy` - Deploy database migrations
|
||||
- `yarn db-studio` - Open Prisma Studio
|
||||
- `psql "postgresql://postgres:@localhost:5432/calendso"` - Connect to local PostgreSQL database
|
||||
|
||||
## App Store Commands
|
||||
|
||||
- `yarn create-app` - Create new app store integration
|
||||
- `yarn edit-app` - Edit existing app
|
||||
- `yarn delete-app` - Delete app
|
||||
- `yarn app-store:build` - Build app store
|
||||
- `yarn app-store:watch` - Watch app store for changes
|
||||
|
||||
## Useful Development Patterns
|
||||
|
||||
### Running Single Tests
|
||||
|
||||
```bash
|
||||
# Unit test specific file
|
||||
yarn vitest run packages/lib/some-file.test.ts
|
||||
|
||||
# Integration test specific file
|
||||
yarn test routing-form-response-denormalized.integration-test.ts -- --integrationTestsOnly
|
||||
|
||||
# E2E test specific file
|
||||
yarn e2e tests/booking-flow.e2e.ts
|
||||
|
||||
# Run specific test by name
|
||||
yarn e2e tests/booking-flow.e2e.ts --grep "should create booking"
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
- Copy `.env.example` to `.env` and configure
|
||||
- Copy `.env.appStore.example` to `.env.appStore` for app store development
|
||||
- Run `yarn dx` for initial development setup with database
|
||||
@@ -0,0 +1,320 @@
|
||||
# Knowledge Base - Product & Domain-Specific Information
|
||||
|
||||
## Repo note for calcom/cal.com
|
||||
|
||||
The whole thing is a monorepo. You need to be working in the apps/web folder.
|
||||
|
||||
Linting and Formatting
|
||||
- Run lint with report generation: `yarn lint:report`
|
||||
- Run type checking: `yarn type-check:ci --force`
|
||||
- Run auto-fix: `yarn lint -- --fix`
|
||||
|
||||
Development
|
||||
- Install dependencies: `yarn`
|
||||
- Set up environment:
|
||||
- Copy `.env.example` to `.env`
|
||||
- Generate keys with `openssl rand -base64 32` for both `NEXTAUTH_SECRET` and `CALENDSO_ENCRYPTION_KEY`
|
||||
- Configure Postgres database URL in `.env`
|
||||
- Set DATABASE_DIRECT_URL to the same value as DATABASE_URL
|
||||
|
||||
Database Setup
|
||||
- Development: `yarn workspace @calcom/prisma db-migrate`
|
||||
- Production: `yarn workspace @calcom/prisma db-deploy`
|
||||
|
||||
When setting up local development database, it'll create a bunch of users for you. The passwords are the same as the username. e.g. 'free:free' and 'pro:pro'
|
||||
|
||||
PR Requirements
|
||||
- PR title must follow Conventional Commits specification
|
||||
- For most PRs, you only need to run linting and type checking
|
||||
- E2E tests will only run if PR has "ready-for-e2e" label
|
||||
|
||||
Logging
|
||||
- Control logging verbosity by setting `NEXT_PUBLIC_LOGGER_LEVEL` in .env:
|
||||
- 0: silly
|
||||
- 1: trace
|
||||
- 2: debug
|
||||
- 3: info
|
||||
- 4: warn
|
||||
- 5: error
|
||||
- 6: fatal
|
||||
|
||||
## When addressing issues in the Cal.com repository
|
||||
|
||||
When working on the Cal.com repository, prioritize fixing type issues before addressing failing tests. Running `yarn type-check:ci --force` to identify and fix TypeScript errors should be done first, as these errors are often the root cause of test failures. Only after resolving type issues should you move on to fixing failing tests with `TZ=UTC yarn test`.
|
||||
|
||||
## When creating pull requests
|
||||
|
||||
Create pull requests in draft mode by default, so that actual human can mark it as ready for review only when it is.
|
||||
|
||||
## When developing Playwright tests in the Cal.com repository
|
||||
|
||||
Always ensure Playwright tests pass locally before pushing code. The user requires fast local e2e feedback loops instead of relying on CI, which is too slow for development iteration. Never push test code until those tests are passing locally first.
|
||||
|
||||
## When fixing failing tests in the Cal.com repository
|
||||
|
||||
When fixing failing tests in the Cal.com repository, take an incremental approach by addressing one file at a time rather than attempting to fix all issues simultaneously. This methodical approach makes it easier to identify and resolve specific issues without getting overwhelmed by the complexity of multiple failing tests across different files. Focus on getting each file's tests passing completely before moving on to the next file.
|
||||
|
||||
To identify and fix issues in the Cal.com codebase:
|
||||
|
||||
1. Run `yarn type-check:ci --force` to identify TypeScript type errors and get fresh results always, bypassing any caching issues
|
||||
2. Run `yarn test` to identify failing unit tests
|
||||
3. Address both type errors and failing tests before considering the task complete
|
||||
4. Type errors often need to be fixed first as they may be causing the test failures
|
||||
|
||||
## When implementing mock services in the Cal.com test files
|
||||
|
||||
When mocking calendar services in Cal.com test files, implement the Calendar interface rather than adding individual properties from each specific calendar service type (like FeishuCalendarService). Since all calendar services implement the Calendar interface and are stored in a map, the mock service should also implement this interface to ensure type compatibility. This approach is more maintainable than trying to add all properties from each specific calendar service implementation. For complex mocks that cause type compatibility issues with deep mocks, consider using simpler fake implementations that directly implement the required interfaces instead of trying to match all properties of the original service classes. When needed, you can modify other mock files to support your implementation rather than trying to force compatibility with existing mocks.
|
||||
|
||||
## When implementing mocks for Cal.com app-store integrations
|
||||
|
||||
When mocking app-store resources in Cal.com tests, prefer implementing simpler mock designs that directly implement the required interfaces rather than trying to match complex deep mock structures created with mockDeep. This approach is more maintainable and helps resolve type compatibility issues. The user encourages creative solutions and refactoring to better designs when the standard mocking approach causes persistent type errors.
|
||||
|
||||
## When implementing workflow triggers in the Cal.com repository
|
||||
|
||||
To trigger workflows in Cal.com, use the `scheduleWorkflowReminders` function. This is the standard approach used throughout the codebase for triggering any workflow. Before implementing new workflow triggers, examine existing implementations in the codebase to understand the pattern. The function filters workflows by trigger type and processes each workflow step. Key locations where this is used include booking handlers, confirmation processes, and other booking-related events.
|
||||
|
||||
## When making changes to Prisma schema in Cal.com
|
||||
|
||||
After making changes to the Prisma schema in Cal.com and creating migrations, you need to run `yarn prisma generate` to update the TypeScript types. This is especially important when switching Node.js versions, after adding new fields to models, or after pulling changes that include Prisma schema updates, as it ensures the TypeScript compiler recognizes the updated schema structure. If you encounter errors related to missing enum values (like CreationSource.WEBAPP), running `yarn prisma generate` will typically resolve these issues by regenerating the TypeScript types from the schema.
|
||||
|
||||
If you encounter enum generator errors during the Prisma generate step (like "Cannot find module './enum-generator.ts'"), run `yarn install` first before trying to generate. This ensures all dependencies are properly installed before the generation process.
|
||||
|
||||
Whenever you change the schema.prisma file, remember to always consolidate migrations by squashing them as declared in the Prisma docs: https://www.prisma.io/docs/orm/prisma-migrate/workflows/squashing-migrations. This helps maintain a clean migration history and prevents accumulation of multiple migration files.
|
||||
|
||||
## When reviewing PRs in the Cal.com repository
|
||||
|
||||
When asked to review a PR, focus on providing a clear summary of what the PR is doing and its core functionality. Avoid getting sidetracked by CI failures, testing issues, or technical implementation details unless specifically requested. The user prefers concise, focused reviews that prioritize understanding the main purpose and changes of the PR.
|
||||
|
||||
## When running Playwright tests in the Cal.com repository
|
||||
|
||||
Use the command format `PLAYWRIGHT_HEADLESS=1 yarn e2e [test-file.e2e.ts]` to run Playwright tests instead of the standard `yarn playwright test` command. This format includes the proper timezone setting, virtual display server, and uses the repository's e2e runner.
|
||||
|
||||
## When running tests in the Cal.com repository
|
||||
|
||||
When running tests in the Cal.com repository, use the TZ=UTC environment variable (e.g., `TZ=UTC yarn test`) to ensure consistent timezone handling. This prevents timezone-related test failures that might occur when tests are run in different environments or by different developers with varying local timezone settings.
|
||||
|
||||
## When working on calendar cache features in the Cal.com codebase
|
||||
|
||||
The calendar cache system follows specific patterns in `packages/features/calendar-cache-sql`. When implementing provider-specific calendar cache services (like for Outlook/Office365), the provider-specific code should be placed in the corresponding provider directory (e.g., `packages/app-store/office365calendar`).
|
||||
|
||||
## When working on PRs in the Cal.com repository
|
||||
|
||||
When making changes to the Cal.com codebase, always run type checks locally using `yarn type-check:ci` before concluding that CI failures are unrelated to your changes. Even if errors appear in files you haven't directly modified, your changes might still be causing type issues through dependencies or type inference. Compare type check results between the main branch and your feature branch to confirm whether you've introduced new type errors.
|
||||
|
||||
## When working on type issues in the Cal.com repository
|
||||
|
||||
Type casting with "as any" is strictly forbidden in the Cal.com codebase. When encountering Prisma type incompatibilities or other TypeScript type issues, proper type-safe solutions must be used instead, such as Prisma extensions system, type parameter constraints, repository pattern isolation, explicit type definitions, and extension composition patterns that are already established in the codebase.
|
||||
|
||||
## When working on UI layout changes in the Cal.com web application
|
||||
|
||||
The event types page UI components are located in `apps/web/modules/event-types/views/event-types-listing-view.tsx`. This file contains the layout implementation for the search bar and team tabs components on the event types listing page.
|
||||
|
||||
Changes to shared UI patterns (like tab layouts and button alignments) need to be checked across multiple views to maintain consistency:
|
||||
- Event types page layout: `apps/web/modules/event-types/views/event-types-listing-view.tsx`
|
||||
- Bookings page layout: `apps/web/modules/bookings/views/bookings-listing-view.tsx`
|
||||
- Common elements like tabs, search bars, and filter buttons should maintain consistent alignment across views
|
||||
|
||||
## When working on workflow triggers or similar enum-based features in the Cal.com codebase
|
||||
|
||||
To add new workflow triggers:
|
||||
|
||||
1. Check packages/prisma/schema.prisma for existing webhooks and workflow trigger enums as reference
|
||||
2. Add the same enums to workflows (only when asked by user specifically, or else focus on users requirement)
|
||||
3. Add enums to packages/features/ee/workflows/lib/constants.ts for UI display
|
||||
4. Add translations to en/locale.json using the format {enum}_trigger (all lowercase). Webhook triggers serve as the reference implementation pattern for workflow triggers.
|
||||
|
||||
## When working with app-store integrations in Cal.com
|
||||
|
||||
The Cal.com repository uses generated files (*.generated.ts) for app-store integrations. These files are created by the app-store-cli tool. When making structural changes to how integrations are imported or used, you need to update CLI code that generates these files. Typically manual changes to the *.generated.ts files are not made. That was only for PR 19771 for a proof-of-concept. Recent changes have moved from dynamic imports to static map-based imports for better performance. When working with browser components in the app-store, static imports should be used rather than dynamic imports (using Next.js' dynamic function) to maintain consistency with the performance improvements.
|
||||
|
||||
When modifying the app-store-cli build.ts file, you must ensure it correctly handles all types of generated files:
|
||||
1. Regular service files (calendar.services.generated.ts, crm.services.generated.ts, etc.) need default imports
|
||||
2. Browser component files (apps.browser-addon.generated.tsx, etc.) may require dynamic imports with Next.js
|
||||
3. After making changes to build.ts, always verify all generated files have the correct imports by checking each file type
|
||||
|
||||
The lazyImport parameter in getExportedObject() determines whether to use dynamic imports (for browser components) or static imports (for server-side services).
|
||||
|
||||
## When working with branches in the Cal.com repository
|
||||
|
||||
When asked to move changes to a different branch in the Cal.com repository, use git commands to commit existing changes to the specified branch rather than redoing the work. This is more efficient and prevents duplication of effort. The user prefers direct branch operations over reimplementing the same changes multiple times.
|
||||
|
||||
## When working with calendar events in the Cal.com codebase
|
||||
|
||||
Cal.com events in Google Calendar can be identified by checking if the iCalUID ends with "@Cal.com" (e.g., "2GBXSdEixretciJfKVmYN8@Cal.com"). This identifier is used to distinguish Cal.com bookings from other calendar events for data storage and privacy purposes.
|
||||
|
||||
## When working with CI/CD in the Cal.com repository
|
||||
|
||||
When reviewing CI check failures in Cal.com:
|
||||
1. E2E tests can be flaky and may fail intermittently
|
||||
2. Focus only on CI failures that are directly related to your code changes
|
||||
3. Infrastructure-related failures (like dependency installation issues) can be disregarded if all code-specific checks (type checking, linting, unit tests) are passing
|
||||
|
||||
## When working with database models in Cal.com
|
||||
|
||||
Database models in Cal.com are defined in `packages/prisma/schema.prisma`. When adding new fields to models:
|
||||
|
||||
1. For timestamp fields like `createdAt` and `updatedAt`:
|
||||
- Do not set default values if you want existing records to have null values
|
||||
- Only new records should get timestamps automatically
|
||||
- For `updatedAt` fields, ensure they're updated when records are modified
|
||||
|
||||
2. Create a migration using `npx prisma migrate dev --name migration_name` to update the database schema
|
||||
|
||||
3. When implementing cache-related features that require timestamp tracking, always update the database schema first before modifying application code that references those fields. The schema changes must be completed and migrated before the application can successfully query or use the new fields.
|
||||
|
||||
## When working with generated files in the Cal.com repository
|
||||
|
||||
When fixing imports in Cal.com's generated files (like packages/app-store/apps.browser-*.generated.tsx), always check the actual exports in the source files first. For EventTypeAppCardInterface components, they likely use named exports rather than default exports, requiring imports like `import * as ComponentName from "./path"` instead of `import ComponentName from "./path"`. This verification step is crucial when working with browser-addon, browser-appsettings, browser-eventtypesettings, and browser-install generated files. For these files, if you're seeing import errors, check whether the components are exported as default exports in their source files and adjust your import statements accordingly.
|
||||
|
||||
## When working with git and CI systems
|
||||
|
||||
Always push committed changes to the remote repository before waiting for or checking CI status. Waiting for CI checks on unpushed local commits is backwards - the CI runs on the remote repository state, not local commits. The proper sequence is: commit locally, run local checks, push to remote, then monitor CI status.
|
||||
|
||||
## When working with imports/exports in the Cal.com codebase
|
||||
|
||||
When working with imports in the Cal.com codebase, particularly in app-store integrations, pay attention to whether modules use named exports or default exports. Many services like VideoApiAdapter, CalendarService, and PaymentService are exported as named exports, but the actual export name may differ from the generic service type (e.g., `export class AppleCalendarService` instead of `export class CalendarService`). When importing these services, verify the actual export name in the source file and use the appropriate named import syntax (e.g., `import { AppleCalendarService } from "./applecalendar/lib/CalendarService"` or with renaming `import { AppleCalendarService as ApplecalendarCalendarService } from "./applecalendar/lib/CalendarService"`). Always check the actual export pattern and name in the source file to determine the correct import syntax.
|
||||
|
||||
## When working with tRPC components in the Cal.com repository
|
||||
|
||||
When making changes that affect tRPC components or after pulling updates that modify tRPC-related files, you need to follow a specific build order:
|
||||
1. First run `yarn prisma generate` to ensure all database types are up-to-date
|
||||
2. Then run `cd packages/trpc && yarn build` to rebuild the tRPC package
|
||||
This sequence ensures that type definitions are properly generated before building the tRPC components that depend on them, preventing type errors during compilation.
|
||||
|
||||
## When working with workflows or webhooks in the Cal.com repository
|
||||
|
||||
Workflows and webhooks are two completely separate features in Cal.com with different implementations and file structures. The workflow constants file is located at `packages/features/ee/workflows/lib/constants.ts`, not in the webhooks directory. When working on workflow triggers, do not reference or use webhook trigger implementations - they are distinct systems that should not be confused or mixed.
|
||||
|
||||
## When adding new feature flags to the Cal.com repository
|
||||
|
||||
To seed new feature flags in Cal.com, create a Prisma migration using the command `yarn prisma migrate dev --create-only --name seed_[feature_name]_feature`. The migration file should be placed in `packages/prisma/migrations/` with a timestamp prefix format like `20250724210733_seed_calendar_cache_sql_features/migration.sql`. Follow the pattern from existing feature seeding migrations like `packages/prisma/migrations/20241216000000_add_calendar_cache_serve/migration.sql` for the SQL structure. The migration should INSERT the new features into the `Feature` table with appropriate type (like `OPERATIONAL`) and default `enabled` status for manual team enablement.
|
||||
|
||||
## When adding new UI elements or text strings to Cal.com
|
||||
|
||||
All UI strings in Cal.com must be properly translated using the i18n system. This includes:
|
||||
- Labels for new UI elements (like dropdown labels, settings headers)
|
||||
- Option values that are displayed to users
|
||||
- Any text that appears in the interface
|
||||
|
||||
Even if some related strings are already translated (like "Bookings" and "Insights"), new strings must be explicitly added to the translation system.
|
||||
|
||||
## When creating a pull request
|
||||
|
||||
### Title
|
||||
|
||||
- Use conventional commits: `feat:`, `fix:`, `refactor:`
|
||||
- Be specific: `fix: handle timezone edge case in booking creation`
|
||||
- Not generic: `fix: booking bug`
|
||||
|
||||
### Size Limits
|
||||
|
||||
- **Large PRs** (>500 lines or >10 files) are not recommended.
|
||||
- Guide the user how to split large PRs into smaller ones.
|
||||
|
||||
## When handling errors
|
||||
|
||||
### Descriptive Errors
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Descriptive error with context
|
||||
throw new Error(`Unable to create booking: User ${userId} has no available time slots for ${date}`);
|
||||
|
||||
// ❌ Bad - Generic error
|
||||
throw new Error("Booking failed");
|
||||
```
|
||||
|
||||
### Error Types
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Use proper error classes
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid booking time slot",
|
||||
});
|
||||
```
|
||||
|
||||
## Basic Performance Guidelines
|
||||
|
||||
- Aim for O(n) or O(n log n) complexity, avoid O(n²)
|
||||
- Use database-level filtering instead of JavaScript filtering
|
||||
- Consider pagination for large datasets
|
||||
- Use database transactions for related operations
|
||||
|
||||
## When querying database
|
||||
|
||||
Prefer Select over Include:
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Use select for performance and security
|
||||
const booking = await prisma.booking.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ❌ Bad - Include fetches all fields
|
||||
const booking = await prisma.booking.findFirst({
|
||||
include: {
|
||||
user: true, // This gets ALL user fields
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Avoid barrel imports
|
||||
|
||||
```typescript
|
||||
// ❌ Bad - Avoid importing from index.ts barrel files
|
||||
import { BookingService, UserService } from "./services";
|
||||
|
||||
// ✅ Good - Import directly from source files
|
||||
import { BookingService } from "./services/BookingService";
|
||||
import { UserService } from "./services/UserService";
|
||||
|
||||
|
||||
// ❌ Bad
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
// ✅ Good - Import directly from source files
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
```
|
||||
|
||||
## File Naming Conventions
|
||||
|
||||
### Repository Files
|
||||
|
||||
- **Must** include `Repository` suffix, PascalCase matching class: `PrismaBookingRepository.ts`
|
||||
|
||||
### Service Files
|
||||
|
||||
- **Must** include `Service` suffix, PascalCase matching class, avoid generic names: `MembershipService.ts`
|
||||
|
||||
### General Files
|
||||
|
||||
- **Components**: PascalCase (e.g., `BookingForm.tsx`)
|
||||
- **Utilities**: kebab-case (e.g., `date-utils.ts`)
|
||||
- **Types**: PascalCase with `.types.ts` suffix (e.g., `Booking.types.ts`)
|
||||
- **Tests**: Same as source file + `.test.ts` or `.spec.ts`
|
||||
- **Avoid**: Dot-suffixes like `.service.ts`, `.repository.ts` (except for tests, types, specs)
|
||||
|
||||
## When using Day.js
|
||||
|
||||
```typescript
|
||||
// ⚠️ Slow in performance-critical code (loops)
|
||||
dates.map((date) => dayjs(date).add(1, "day").format());
|
||||
|
||||
// ✅ Better - Use .utc() for performance
|
||||
dates.map((date) => dayjs.utc(date).add(1, "day").format());
|
||||
|
||||
// ✅ Best - Use native Date when possible
|
||||
dates.map((date) => new Date(date.valueOf() + 24 * 60 * 60 * 1000));
|
||||
```
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
feat: italian language support
|
||||
@@ -2,4 +2,22 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
extends: ["./packages/config/eslint-preset.js"],
|
||||
plugins: ["import"],
|
||||
rules: {
|
||||
"import/no-cycle": ["warn", { maxDepth: Infinity }],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["packages/lib/**/*.{ts,tsx,js,jsx}", "packages/prisma/**/*.{ts,tsx,js,jsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"warn",
|
||||
{
|
||||
paths: ["@calcom/app-store"],
|
||||
patterns: ["@calcom/app-store/*"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
/packages/lib/slots.ts @calcom/Foundation
|
||||
/packages/platform/atoms/**/*WebWrapper.tsx @calcom/Consumer
|
||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/DBA
|
||||
/packages/prisma/**/* @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Atoms E2E Tests
|
||||
name: E2E Atoms
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -19,7 +19,7 @@ env:
|
||||
jobs:
|
||||
e2e-atoms:
|
||||
timeout-minutes: 15
|
||||
name: Atoms E2E Tests
|
||||
name: E2E Atoms
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: docker/login-action@v3
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/yarn-playwright-install
|
||||
- name: Run Atoms E2E Tests
|
||||
- name: Run E2E Atoms Tests
|
||||
working-directory: packages/platform/examples/base
|
||||
run: yarn test:e2e
|
||||
- name: Upload Test Results
|
||||
|
||||
Vendored
@@ -0,0 +1,20 @@
|
||||
# Cal.com Development Guide for AI Agents
|
||||
|
||||
**📖 Complete documentation is in the [.agents/](.agents/) directory.**
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Essential Commands
|
||||
|
||||
- `yarn dev` - Start development server
|
||||
- `yarn build` - Build all packages
|
||||
- `yarn lint:fix` - Lint and fix code
|
||||
- `yarn type-check` - TypeScript checking
|
||||
- `yarn test <filename> -- --integrationTestsOnly` - Run integration tests
|
||||
- `yarn e2e <filename> --grep "<testName>"` - Run specific E2E test
|
||||
|
||||
## 📚 Detailed Documentation
|
||||
|
||||
- **[.agents/README.md](.agents/README.md)** - Complete development guide
|
||||
- **[.agents/commands.md](.agents/commands.md)** - All build, test & dev commands
|
||||
- **[.agents/knowledge-base.md](.agents/knowledge-base.md)** - Knowledge base & best practices
|
||||
+2
-2
@@ -90,7 +90,7 @@ function getBaseProperties(field: InputBookingField): CustomField | SystemField
|
||||
}
|
||||
|
||||
if (fieldIsCustomSystemName(field)) {
|
||||
const systemName = { ...systemBeforeFieldName };
|
||||
const systemName = structuredClone(systemBeforeFieldName);
|
||||
if (systemName.variantsConfig?.variants?.fullName?.fields?.[0]) {
|
||||
systemName.variantsConfig.variants.fullName.fields[0].label = field.label;
|
||||
}
|
||||
@@ -110,7 +110,7 @@ function getBaseProperties(field: InputBookingField): CustomField | SystemField
|
||||
}
|
||||
|
||||
if (fieldIsCustomSystemNameSplit(field)) {
|
||||
const systemNameSplit = { ...systemBeforeFieldNameSplit };
|
||||
const systemNameSplit = structuredClone(systemBeforeFieldNameSplit);
|
||||
|
||||
const firstNameField = systemNameSplit.variantsConfig?.variants?.firstAndLastName?.fields?.find(
|
||||
(field) => field.name === "firstName"
|
||||
|
||||
+1
-2
@@ -520,8 +520,7 @@ export const systemBeforeFieldName: NameSystemField = {
|
||||
name: "fullName",
|
||||
type: "text",
|
||||
required: true,
|
||||
label: "",
|
||||
placeholder: "",
|
||||
label: "your_name",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+2
-2
@@ -292,8 +292,8 @@ describe("transformBookingFieldsInternalToApi", () => {
|
||||
isDefault: true,
|
||||
required: true,
|
||||
disableOnPrefill: false,
|
||||
label: "",
|
||||
placeholder: "",
|
||||
label: "your_name",
|
||||
placeholder: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -107,6 +107,10 @@ export class BillingService implements OnModuleDestroy {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
const customerId = teamWithBilling?.platformBilling?.customerId;
|
||||
|
||||
if (!customerId) {
|
||||
throw new NotFoundException("No customer id associated with the team.");
|
||||
}
|
||||
|
||||
const { url } = await this.stripeService.getStripe().checkout.sessions.create({
|
||||
customer: customerId,
|
||||
success_url: `${this.webAppUrl}/settings/platform/`,
|
||||
@@ -116,6 +120,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
teamId: teamId.toString(),
|
||||
plan: plan.toString(),
|
||||
},
|
||||
currency: "usd",
|
||||
});
|
||||
|
||||
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
|
||||
|
||||
+2
-4
@@ -725,8 +725,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
isDefault: true,
|
||||
type: "name",
|
||||
slug: "name",
|
||||
label: "",
|
||||
placeholder: "",
|
||||
label: "your_name",
|
||||
required: true,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
@@ -823,9 +822,8 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
isDefault: true,
|
||||
type: "name",
|
||||
slug: "name",
|
||||
label: "",
|
||||
placeholder: "",
|
||||
required: true,
|
||||
label: "your_name",
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
|
||||
+16
-12
@@ -181,14 +181,18 @@ const organizationAdminKeys = [
|
||||
"delegation_credential",
|
||||
];
|
||||
|
||||
export interface SettingsPermissions {
|
||||
canViewRoles?: boolean;
|
||||
}
|
||||
|
||||
const useTabs = ({
|
||||
isDelegationCredentialEnabled,
|
||||
isPbacEnabled,
|
||||
canViewRoles,
|
||||
permissions,
|
||||
}: {
|
||||
isDelegationCredentialEnabled: boolean;
|
||||
isPbacEnabled: boolean;
|
||||
canViewRoles?: boolean;
|
||||
permissions?: SettingsPermissions;
|
||||
}) => {
|
||||
const session = useSession();
|
||||
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
|
||||
@@ -227,7 +231,7 @@ const useTabs = ({
|
||||
|
||||
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
|
||||
// This prevents showing the menu item when user has no organization permissions
|
||||
if (isPbacEnabled && canViewRoles) {
|
||||
if (isPbacEnabled && permissions?.canViewRoles) {
|
||||
newArray.push({
|
||||
name: "roles_and_permissions",
|
||||
href: "/settings/organizations/roles",
|
||||
@@ -294,7 +298,7 @@ interface SettingsSidebarContainerProps {
|
||||
navigationIsOpenedOnMobile?: boolean;
|
||||
bannersHeight?: number;
|
||||
teamFeatures?: Record<number, TeamFeatures>;
|
||||
canViewRoles?: boolean;
|
||||
permissions?: SettingsPermissions;
|
||||
}
|
||||
|
||||
const TeamRolesNavItem = ({
|
||||
@@ -487,7 +491,7 @@ const SettingsSidebarContainer = ({
|
||||
navigationIsOpenedOnMobile,
|
||||
bannersHeight,
|
||||
teamFeatures,
|
||||
canViewRoles,
|
||||
permissions,
|
||||
}: SettingsSidebarContainerProps) => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const orgBranding = useOrgBranding();
|
||||
@@ -517,7 +521,7 @@ const SettingsSidebarContainer = ({
|
||||
const tabsWithPermissions = useTabs({
|
||||
isDelegationCredentialEnabled,
|
||||
isPbacEnabled,
|
||||
canViewRoles,
|
||||
permissions,
|
||||
});
|
||||
|
||||
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
|
||||
@@ -792,13 +796,13 @@ export type SettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
teamFeatures?: Record<number, TeamFeatures>;
|
||||
canViewRoles?: boolean;
|
||||
permissions?: SettingsPermissions;
|
||||
} & ComponentProps<typeof Shell>;
|
||||
|
||||
export default function SettingsLayoutAppDirClient({
|
||||
children,
|
||||
teamFeatures,
|
||||
canViewRoles,
|
||||
permissions,
|
||||
...rest
|
||||
}: SettingsLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
@@ -833,7 +837,7 @@ export default function SettingsLayoutAppDirClient({
|
||||
sideContainerOpen={sideContainerOpen}
|
||||
setSideContainerOpen={setSideContainerOpen}
|
||||
teamFeatures={teamFeatures}
|
||||
canViewRoles={canViewRoles}
|
||||
permissions={permissions}
|
||||
/>
|
||||
}
|
||||
drawerState={state}
|
||||
@@ -856,7 +860,7 @@ type SidebarContainerElementProps = {
|
||||
bannersHeight?: number;
|
||||
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
teamFeatures?: Record<number, TeamFeatures>;
|
||||
canViewRoles?: boolean;
|
||||
permissions?: SettingsPermissions;
|
||||
};
|
||||
|
||||
const SidebarContainerElement = ({
|
||||
@@ -864,7 +868,7 @@ const SidebarContainerElement = ({
|
||||
bannersHeight,
|
||||
setSideContainerOpen,
|
||||
teamFeatures,
|
||||
canViewRoles,
|
||||
permissions,
|
||||
}: SidebarContainerElementProps) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
@@ -881,7 +885,7 @@ const SidebarContainerElement = ({
|
||||
navigationIsOpenedOnMobile={sideContainerOpen}
|
||||
bannersHeight={bannersHeight}
|
||||
teamFeatures={teamFeatures}
|
||||
canViewRoles={canViewRoles}
|
||||
permissions={permissions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -67,7 +67,11 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} canViewRoles={canViewRoles} />
|
||||
<SettingsLayoutAppDirClient
|
||||
{...props}
|
||||
teamFeatures={teamFeatures ?? {}}
|
||||
permissions={{ canViewRoles }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { SkeletonLoader } from "@calcom/atoms/connect/conferencing-apps/ConferencingAppsViewWebWrapper";
|
||||
|
||||
export default function Loading() {
|
||||
return <SkeletonLoader />;
|
||||
}
|
||||
+21
-1
@@ -1,6 +1,9 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import { ConferencingAppsViewWebWrapper } from "@calcom/atoms/connect/conferencing-apps/ConferencingAppsViewWebWrapper";
|
||||
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
|
||||
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -12,7 +15,24 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
return <ConferencingAppsViewWebWrapper />;
|
||||
const [appsCaller, eventTypesCaller] = await Promise.all([
|
||||
createRouterCaller(appsRouter),
|
||||
createRouterCaller(eventTypesRouter),
|
||||
]);
|
||||
|
||||
const [integrations, defaultConferencingApp, eventTypesQueryData] = await Promise.all([
|
||||
appsCaller.integrations({ variant: "conferencing", onlyInstalled: true }),
|
||||
appsCaller.getUsersDefaultConferencingApp(),
|
||||
eventTypesCaller.bulkEventFetch(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<ConferencingAppsViewWebWrapper
|
||||
integrations={integrations}
|
||||
defaultConferencingApp={defaultConferencingApp}
|
||||
eventTypes={eventTypesQueryData?.eventTypes ?? []}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
+66
-50
@@ -3,7 +3,11 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import {
|
||||
Scope,
|
||||
CrudAction,
|
||||
getPermissionsForScope,
|
||||
} from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Checkbox, Label } from "@calcom/ui/components/form";
|
||||
@@ -17,6 +21,7 @@ interface AdvancedPermissionGroupProps {
|
||||
selectedPermissions: string[];
|
||||
onChange: (permissions: string[]) => void;
|
||||
disabled?: boolean;
|
||||
scope?: Scope;
|
||||
}
|
||||
|
||||
const INTERNAL_DATAACCESS_KEY = "_resource";
|
||||
@@ -26,21 +31,31 @@ export function AdvancedPermissionGroup({
|
||||
selectedPermissions,
|
||||
onChange,
|
||||
disabled,
|
||||
scope = Scope.Organization,
|
||||
}: AdvancedPermissionGroupProps) {
|
||||
const { t } = useLocale();
|
||||
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions();
|
||||
const resourceConfig = PERMISSION_REGISTRY[resource];
|
||||
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions(scope);
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
const resourceConfig = scopedRegistry[resource];
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const isAllResources = resource === "*";
|
||||
|
||||
// Early return if resource is not in the scoped registry (and not the special "*" resource)
|
||||
if (!isAllResources && !resourceConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allResourcesSelected = selectedPermissions.includes("*.*");
|
||||
|
||||
// Get all possible permissions for this resource
|
||||
const allPermissions = isAllResources
|
||||
? ["*.*"]
|
||||
: Object.entries(resourceConfig)
|
||||
: resourceConfig
|
||||
? Object.entries(resourceConfig)
|
||||
.filter(([action]) => action !== INTERNAL_DATAACCESS_KEY)
|
||||
.map(([action]) => `${resource}.${action}`);
|
||||
.map(([action]) => `${resource}.${action}`)
|
||||
: [];
|
||||
|
||||
// Check if all permissions for this resource are selected
|
||||
const isAllSelected = isAllResources
|
||||
@@ -99,7 +114,7 @@ export function AdvancedPermissionGroup({
|
||||
<span
|
||||
className="text-default cursor-pointer text-sm font-medium leading-none"
|
||||
onClick={() => setIsExpanded(!isExpanded)}>
|
||||
{t(resourceConfig._resource?.i18nKey || "")}
|
||||
{t(resourceConfig?._resource?.i18nKey || "")}
|
||||
</span>
|
||||
<span
|
||||
className="text-muted cursor-pointer text-sm font-medium leading-none"
|
||||
@@ -113,56 +128,57 @@ export function AdvancedPermissionGroup({
|
||||
className="bg-default border-muted m-1 flex flex-col gap-2.5 rounded-xl border p-3"
|
||||
onClick={(e) => e.stopPropagation()} // Stop clicks in the permission list from affecting parent
|
||||
>
|
||||
{Object.entries(resourceConfig).map(([action, actionConfig]) => {
|
||||
const permission = `${resource}.${action}`;
|
||||
{resourceConfig &&
|
||||
Object.entries(resourceConfig).map(([action, actionConfig]) => {
|
||||
const permission = `${resource}.${action}`;
|
||||
|
||||
if (action === INTERNAL_DATAACCESS_KEY) {
|
||||
return null;
|
||||
}
|
||||
if (action === INTERNAL_DATAACCESS_KEY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isChecked = selectedPermissions.includes(permission);
|
||||
const isReadPermission = action === CrudAction.Read;
|
||||
const isAutoEnabled = isReadAutoEnabled(action);
|
||||
const isChecked = selectedPermissions.includes(permission);
|
||||
const isReadPermission = action === CrudAction.Read;
|
||||
const isAutoEnabled = isReadAutoEnabled(action);
|
||||
|
||||
return (
|
||||
<div key={action} className="flex items-center">
|
||||
<Checkbox
|
||||
id={permission}
|
||||
checked={isChecked}
|
||||
className="mr-2"
|
||||
onCheckedChange={(checked) => {
|
||||
if (!disabled) {
|
||||
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()} // Stop label clicks from affecting parent
|
||||
>
|
||||
<Label htmlFor={permission} className="mb-0">
|
||||
<span className={classNames(isAutoEnabled && "text-muted-foreground")}>
|
||||
{t(actionConfig?.i18nKey || "")}
|
||||
return (
|
||||
<div key={action} className="flex items-center">
|
||||
<Checkbox
|
||||
id={permission}
|
||||
checked={isChecked}
|
||||
className="mr-2"
|
||||
onCheckedChange={(checked) => {
|
||||
if (!disabled) {
|
||||
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()} // Stop label clicks from affecting parent
|
||||
>
|
||||
<Label htmlFor={permission} className="mb-0">
|
||||
<span className={classNames(isAutoEnabled && "text-muted-foreground")}>
|
||||
{t(actionConfig?.i18nKey || "")}
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-sm text-gray-500">
|
||||
{t(
|
||||
actionConfig && "descriptionI18nKey" in actionConfig
|
||||
? actionConfig.descriptionI18nKey
|
||||
: ""
|
||||
)}
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-sm text-gray-500">
|
||||
{t(
|
||||
actionConfig && "descriptionI18nKey" in actionConfig
|
||||
? actionConfig.descriptionI18nKey
|
||||
: ""
|
||||
{isAutoEnabled && (
|
||||
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
|
||||
<Icon name="info" className="text-muted-foreground h-3 w-3" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
{isAutoEnabled && (
|
||||
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
|
||||
<Icon name="info" className="text-muted-foreground h-3 w-3" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}{" "}
|
||||
);
|
||||
})}{" "}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+2
-4
@@ -145,10 +145,6 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
});
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
// Store the color in localStorage
|
||||
const roleKey = isEditing && role ? role.id : `new_role_${values.name}`;
|
||||
localStorage.setItem(`role_color_${roleKey}`, values.color);
|
||||
|
||||
if (isEditing && role) {
|
||||
updateMutation.mutate({
|
||||
teamId,
|
||||
@@ -224,6 +220,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
selectedPermissions={permissions}
|
||||
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
|
||||
disabled={isSystemRole}
|
||||
scope={scope}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -247,6 +244,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
permissions={permissions}
|
||||
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
|
||||
disabled={isSystemRole}
|
||||
scope={scope}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import type { Resource, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
@@ -13,6 +13,7 @@ interface SimplePermissionItemProps {
|
||||
permissions: string[];
|
||||
onChange: (permissions: string[]) => void;
|
||||
disabled?: boolean;
|
||||
scope?: Scope;
|
||||
}
|
||||
|
||||
export function SimplePermissionItem({
|
||||
@@ -20,9 +21,10 @@ export function SimplePermissionItem({
|
||||
permissions,
|
||||
onChange,
|
||||
disabled,
|
||||
scope,
|
||||
}: SimplePermissionItemProps) {
|
||||
const { t } = useLocale();
|
||||
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions();
|
||||
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);
|
||||
|
||||
const isAllResources = resource === "*";
|
||||
const options = isAllResources
|
||||
|
||||
+12
-6
@@ -1,5 +1,8 @@
|
||||
import { CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { CrudAction, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import {
|
||||
PERMISSION_REGISTRY,
|
||||
getPermissionsForScope,
|
||||
} from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import {
|
||||
getTransitiveDependencies,
|
||||
getTransitiveDependents,
|
||||
@@ -18,10 +21,11 @@ interface UsePermissionsReturn {
|
||||
toggleSinglePermission: (permission: string, enabled: boolean, currentPermissions: string[]) => string[];
|
||||
}
|
||||
|
||||
export function usePermissions(): UsePermissionsReturn {
|
||||
export function usePermissions(scope: Scope = Scope.Organization): UsePermissionsReturn {
|
||||
const getAllPossiblePermissions = () => {
|
||||
const permissions: string[] = [];
|
||||
Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
Object.entries(scopedRegistry).forEach(([resource, config]) => {
|
||||
if (resource !== "*") {
|
||||
Object.keys(config)
|
||||
.filter((action) => !action.startsWith("_"))
|
||||
@@ -34,7 +38,8 @@ export function usePermissions(): UsePermissionsReturn {
|
||||
};
|
||||
|
||||
const hasAllPermissions = (permissions: string[]) => {
|
||||
return Object.entries(PERMISSION_REGISTRY).every(([resource, config]) => {
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
return Object.entries(scopedRegistry).every(([resource, config]) => {
|
||||
if (resource === "*") return true;
|
||||
return Object.keys(config)
|
||||
.filter((action) => !action.startsWith("_"))
|
||||
@@ -85,7 +90,8 @@ export function usePermissions(): UsePermissionsReturn {
|
||||
} else {
|
||||
// Filter out current resource permissions
|
||||
newPermissions = newPermissions.filter((p) => !p.startsWith(`${resource}.`));
|
||||
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
const resourceConfig = scopedRegistry[resource as keyof typeof scopedRegistry];
|
||||
|
||||
if (!resourceConfig) return currentPermissions;
|
||||
|
||||
|
||||
+69
-2
@@ -1,13 +1,21 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { TeamMembersView } from "~/teams/team-members-view";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
|
||||
@@ -54,6 +62,12 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params;
|
||||
const teamId = parseInt(id);
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const teamCaller = await createRouterCaller(viewerTeamsRouter);
|
||||
const team = await teamCaller.get({ teamId });
|
||||
|
||||
@@ -70,6 +84,54 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
|
||||
getCachedTeamAttributes(organizationId),
|
||||
]);
|
||||
|
||||
const fallbackRolesCanListMembers: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
|
||||
|
||||
// If the team is not private we allow members to list other members
|
||||
if (!team.isPrivate) {
|
||||
fallbackRolesCanListMembers.push(MembershipRole.MEMBER);
|
||||
}
|
||||
|
||||
// Get specific PBAC permissions for team member actions
|
||||
const permissions = await getSpecificPermissions({
|
||||
userId: session.user.id,
|
||||
teamId: teamId,
|
||||
resource: Resource.Team,
|
||||
userRole: team.membership.role,
|
||||
actions: [
|
||||
CustomAction.Invite,
|
||||
CustomAction.ChangeMemberRole,
|
||||
CustomAction.Remove,
|
||||
CustomAction.ListMembers,
|
||||
CustomAction.Impersonate,
|
||||
],
|
||||
fallbackRoles: {
|
||||
[CustomAction.Invite]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.ChangeMemberRole]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.Remove]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.ListMembers]: {
|
||||
roles: fallbackRolesCanListMembers,
|
||||
},
|
||||
[CustomAction.Impersonate]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Map specific permissions to member actions
|
||||
const memberPermissions = {
|
||||
canListMembers: permissions[CustomAction.ListMembers],
|
||||
canInvite: permissions[CustomAction.Invite],
|
||||
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
|
||||
canRemove: permissions[CustomAction.Remove],
|
||||
canImpersonate: permissions[CustomAction.Impersonate],
|
||||
};
|
||||
|
||||
const facetedTeamValues = {
|
||||
roles,
|
||||
teams: [team],
|
||||
@@ -84,7 +146,12 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("team_members")} description={t("members_team_description")}>
|
||||
<TeamMembersView team={team} facetedTeamValues={facetedTeamValues} attributes={attributes} />
|
||||
<TeamMembersView
|
||||
team={team}
|
||||
facetedTeamValues={facetedTeamValues}
|
||||
attributes={attributes}
|
||||
permissions={memberPermissions}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+69
-2
@@ -1,12 +1,20 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
|
||||
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { MembersView } from "~/members/members-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
@@ -38,10 +46,63 @@ const getCachedRoles = unstable_cache(
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
|
||||
return redirect("/settings/profile");
|
||||
}
|
||||
|
||||
const orgCaller = await createRouterCaller(viewerOrganizationsRouter);
|
||||
const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]);
|
||||
const [attributes, roles] = await Promise.all([getCachedAttributes(org.id), getCachedRoles(org.id)]);
|
||||
|
||||
const fallbackRolesThatCanSeeMembers: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
|
||||
|
||||
if (!org?.isPrivate) {
|
||||
fallbackRolesThatCanSeeMembers.push(MembershipRole.MEMBER);
|
||||
}
|
||||
|
||||
// Get specific PBAC permissions for organization member actions
|
||||
const permissions = await getSpecificPermissions({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.profile.organizationId,
|
||||
resource: Resource.Organization,
|
||||
userRole: session.user.org.role,
|
||||
actions: [
|
||||
CustomAction.ListMembers,
|
||||
CustomAction.Invite,
|
||||
CustomAction.ChangeMemberRole,
|
||||
CustomAction.Remove,
|
||||
CustomAction.Impersonate,
|
||||
],
|
||||
fallbackRoles: {
|
||||
[CustomAction.ListMembers]: {
|
||||
roles: fallbackRolesThatCanSeeMembers,
|
||||
},
|
||||
[CustomAction.Invite]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.ChangeMemberRole]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.Remove]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.Impersonate]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Map specific permissions to member actions
|
||||
const memberPermissions = {
|
||||
canListMembers: permissions[CustomAction.ListMembers],
|
||||
canInvite: permissions[CustomAction.Invite],
|
||||
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
|
||||
canRemove: permissions[CustomAction.Remove],
|
||||
canImpersonate: permissions[CustomAction.Impersonate],
|
||||
};
|
||||
|
||||
const facetedTeamValues = {
|
||||
roles,
|
||||
teams,
|
||||
@@ -55,7 +116,13 @@ const Page = async () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<MembersView org={org} teams={teams} facetedTeamValues={facetedTeamValues} attributes={attributes} />
|
||||
<MembersView
|
||||
org={org}
|
||||
teams={teams}
|
||||
facetedTeamValues={facetedTeamValues}
|
||||
attributes={attributes}
|
||||
permissions={memberPermissions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { NextRequest } from "next/server";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||
import { bookingCancelInput } from "@calcom/prisma/zod-utils";
|
||||
import { bookingCancelWithCsrfSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -16,8 +16,16 @@ async function handler(req: NextRequest) {
|
||||
} catch (error) {
|
||||
return NextResponse.json({ success: false, message: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
const bookingData = bookingCancelWithCsrfSchema.parse(appDirRequestBody);
|
||||
const cookieStore = await cookies();
|
||||
const cookieToken = cookieStore.get("calcom.csrf_token")?.value;
|
||||
|
||||
if (!cookieToken || cookieToken !== bookingData.csrfToken) {
|
||||
return NextResponse.json({ success: false, message: "Invalid CSRF token" }, { status: 403 });
|
||||
}
|
||||
cookieStore.delete("calcom.csrf_token");
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const bookingData = bookingCancelInput.parse(appDirRequestBody);
|
||||
const result = await handleCancelBooking({
|
||||
bookingData,
|
||||
userId: session?.user?.id || -1,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
|
||||
const res = NextResponse.json({ csrfToken: token });
|
||||
|
||||
res.cookies.set("calcom.csrf_token", token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useRouter } from "next/navigation";
|
||||
import type { IframeHTMLAttributes } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { isRedirectApp } from "@calcom/app-store/_utils/redirectApps";
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components";
|
||||
import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils";
|
||||
@@ -112,6 +113,11 @@ export const AppPage = ({
|
||||
});
|
||||
|
||||
const handleAppInstall = () => {
|
||||
if (isRedirectApp(slug)) {
|
||||
setIsLoading(true);
|
||||
mutation.mutate({ type, variant, slug });
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
if (isConferencing(categories) && !concurrentMeetings) {
|
||||
mutation.mutate({
|
||||
@@ -185,6 +191,19 @@ export const AppPage = ({
|
||||
}, []);
|
||||
|
||||
const installOrDisconnectAppButton = () => {
|
||||
if (isRedirectApp(slug)) {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => handleAppInstall()}
|
||||
className="mt-2"
|
||||
StartIcon="external-link"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}>
|
||||
{t("visit")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (appDbQuery.isPending) {
|
||||
return <SkeletonButton className="h-10 w-24" />;
|
||||
}
|
||||
|
||||
@@ -206,6 +206,9 @@ export default function CancelBooking(props: Props) {
|
||||
|
||||
telemetry.event(telemetryEventTypes.bookingCancelled, collectPageParameters());
|
||||
|
||||
const response = await fetch("/api/csrf", { cache: "no-store" });
|
||||
const { csrfToken } = await response.json();
|
||||
|
||||
const res = await fetch("/api/cancel", {
|
||||
body: JSON.stringify({
|
||||
uid: booking?.uid,
|
||||
@@ -215,6 +218,7 @@ export default function CancelBooking(props: Props) {
|
||||
seatReferenceUid,
|
||||
cancelledBy: currentUserEmail,
|
||||
internalNote: internalNote,
|
||||
csrfToken,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { FileUploader, type FileData } from "@calcom/ui/components/file-uploader";
|
||||
@@ -9,8 +9,7 @@ import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@calcom/ui/components/popover";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
const PlainContactForm = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const PlainContactForm = ({ open, setIsOpen }: { open: boolean, setIsOpen: Dispatch<SetStateAction<boolean>> }) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
@@ -55,6 +54,7 @@ const PlainContactForm = () => {
|
||||
setIsUploadingImage(false);
|
||||
|
||||
const formData = new FormData();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
uploadFormData.forEach(({ key, value }: any) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
@@ -138,7 +138,7 @@ const PlainContactForm = () => {
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-[1rem] right-[1rem] z-50">
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Popover open={open} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild className="enabled:hover:bg-subtle bg-subtle shadow-none">
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
|
||||
@@ -17,11 +17,15 @@ vi.mock("@calcom/ui/components/toast", () => ({
|
||||
const mockUseSession = vi.mocked(useSession);
|
||||
const mockShowToast = vi.mocked(showToast);
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
describe("PlainContactForm", () => {
|
||||
let mockSetIsOpen: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSetIsOpen = vi.fn();
|
||||
|
||||
mockUseSession.mockReturnValue({
|
||||
data: {
|
||||
hasValidLicense: true,
|
||||
@@ -35,42 +39,42 @@ describe("PlainContactForm", () => {
|
||||
},
|
||||
status: "authenticated",
|
||||
update: vi.fn(),
|
||||
});
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("should render contact button when closed", () => {
|
||||
render(<PlainContactForm />);
|
||||
it("renders the contact button when closed", () => {
|
||||
render(<PlainContactForm open={false} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open contact form when button is clicked", () => {
|
||||
render(<PlainContactForm />);
|
||||
it("opens when the launcher button is clicked (calls setIsOpen(true))", () => {
|
||||
render(<PlainContactForm open={false} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockSetIsOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("shows the form when open=true", () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
expect(screen.getByText("Contact support")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Describe the issue")).toBeInTheDocument();
|
||||
expect(screen.getByText("Attachments (optional)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show empty form initially", () => {
|
||||
render(<PlainContactForm />);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
it("shows empty form initially", () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
|
||||
expect(messageInput.value).toBe("");
|
||||
});
|
||||
|
||||
it("should close form when X button is clicked", () => {
|
||||
render(<PlainContactForm />);
|
||||
|
||||
const openButton = screen.getByRole("button");
|
||||
fireEvent.click(openButton);
|
||||
it("closes when X button is clicked (calls setIsOpen(false))", () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const closeButton = buttons.find((button) => button.querySelector('svg use[href="#x"]'));
|
||||
@@ -78,19 +82,16 @@ describe("PlainContactForm", () => {
|
||||
|
||||
fireEvent.click(closeButton!);
|
||||
|
||||
expect(screen.queryByText("Contact support")).not.toBeInTheDocument();
|
||||
expect(mockSetIsOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should handle form submission successfully", async () => {
|
||||
it("handles form submission successfully", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
});
|
||||
|
||||
render(<PlainContactForm />);
|
||||
|
||||
const openButton = screen.getByRole("button");
|
||||
fireEvent.click(openButton);
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Describe the issue"), {
|
||||
target: { value: "Test message" },
|
||||
@@ -115,14 +116,11 @@ describe("PlainContactForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should show loading state during submission", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
it("shows loading state during submission", async () => {
|
||||
// never resolves → stays loading
|
||||
mockFetch.mockImplementation(() => new Promise((_resolve) => {}));
|
||||
|
||||
render(<PlainContactForm />);
|
||||
|
||||
const openButton = screen.getByRole("button");
|
||||
fireEvent.click(openButton);
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Describe the issue"), {
|
||||
target: { value: "Test message" },
|
||||
@@ -135,23 +133,19 @@ describe("PlainContactForm", () => {
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should reset form after successful submission", async () => {
|
||||
it("resets form after successful submission", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
});
|
||||
|
||||
render(<PlainContactForm />);
|
||||
|
||||
const openButton = screen.getByRole("button");
|
||||
fireEvent.click(openButton);
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Describe the issue"), {
|
||||
target: { value: "Test message" },
|
||||
});
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /send message/i });
|
||||
fireEvent.click(submitButton);
|
||||
fireEvent.click(screen.getByRole("button", { name: /send message/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Message Sent")).toBeInTheDocument();
|
||||
@@ -166,27 +160,21 @@ describe("PlainContactForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle missing user session", () => {
|
||||
it("handles missing user session", () => {
|
||||
mockUseSession.mockReturnValue({
|
||||
data: null,
|
||||
status: "unauthenticated",
|
||||
update: vi.fn(),
|
||||
});
|
||||
} as any);
|
||||
|
||||
render(<PlainContactForm />);
|
||||
|
||||
const openButton = screen.getByRole("button");
|
||||
fireEvent.click(openButton);
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
|
||||
expect(messageInput.value).toBe("");
|
||||
});
|
||||
|
||||
it("should require message field", async () => {
|
||||
render(<PlainContactForm />);
|
||||
|
||||
const openButton = screen.getByRole("button");
|
||||
fireEvent.click(openButton);
|
||||
it("requires message field", async () => {
|
||||
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
|
||||
|
||||
await expect(screen.getByLabelText("Describe the issue")).toHaveAttribute("required");
|
||||
});
|
||||
|
||||
@@ -83,6 +83,7 @@ const PlainChat = IS_PLAIN_CHAT_ENABLED
|
||||
? ({ nonce }: { nonce: string | undefined }) => {
|
||||
const [config, setConfig] = useState<PlainChatConfig | null>(null);
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: session } = useSession();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -269,10 +270,19 @@ const PlainChat = IS_PLAIN_CHAT_ENABLED
|
||||
|
||||
checkScreenSize();
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
initConfig();
|
||||
|
||||
if (isPaidUser) {
|
||||
initConfig();
|
||||
} else if (typeof window !== "undefined" && !window.Plain) {
|
||||
window.Plain = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
init: () => {},
|
||||
open: () => {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
return () => window.removeEventListener("resize", checkScreenSize);
|
||||
}, [isAppDomain, checkScreenSize, initConfig, userEmail]);
|
||||
}, [isAppDomain, checkScreenSize, initConfig, userEmail, isPaidUser]);
|
||||
|
||||
const plainChatScript = `
|
||||
window.plainScriptLoaded = function() {
|
||||
@@ -289,7 +299,7 @@ const PlainChat = IS_PLAIN_CHAT_ENABLED
|
||||
if (!isAppDomain || isSmallScreen || typeof window === "undefined") return null;
|
||||
|
||||
if (!isPaidUser) {
|
||||
return <PlainContactForm />;
|
||||
return <PlainContactForm open={isOpen} setIsOpen={setIsOpen} />;
|
||||
}
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
@@ -14,10 +14,11 @@ export default function useIsBookingPage(): boolean {
|
||||
"/apps/routing-forms/routing-link", // Routing Form page
|
||||
"/forms/", // Rewrites to /apps/routing-forms/routing-link
|
||||
].some((route) => pathname?.startsWith(route));
|
||||
const isBookingsListPage = ["/upcoming", "/unconfirmed", "/recurring", "/cancelled", "/past"].some((route) => pathname?.endsWith(route));
|
||||
|
||||
const searchParams = useCompatSearchParams();
|
||||
const isUserBookingPage = Boolean(searchParams?.get("user"));
|
||||
const isUserBookingTypePage = Boolean(searchParams?.get("user") && searchParams?.get("type"));
|
||||
|
||||
return isBookingPage || isUserBookingPage || isUserBookingTypePage;
|
||||
return (isBookingPage && !isBookingsListPage) || isUserBookingPage || isUserBookingTypePage;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/client";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
|
||||
|
||||
const verifySchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email";
|
||||
|
||||
// TODO: To be unit tested
|
||||
export async function moveUserToMatchingOrg({ email }: { email: string }) {
|
||||
const org = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email });
|
||||
|
||||
if (!org) {
|
||||
return;
|
||||
}
|
||||
|
||||
await inviteMembersWithNoInviterPermissionCheck({
|
||||
inviterName: null,
|
||||
teamId: org.id,
|
||||
language: "en",
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
invitations: [
|
||||
{
|
||||
usernameOrEmail: email,
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
],
|
||||
orgSlug: org.slug || org.requestedSlug,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { token } = verifySchema.parse(req.query);
|
||||
const billingService = new StripeBillingService();
|
||||
|
||||
const foundToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!foundToken) {
|
||||
return res.status(401).json({ message: "No token found" });
|
||||
}
|
||||
|
||||
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
|
||||
return res.status(401).json({ message: "Token expired" });
|
||||
}
|
||||
|
||||
// The user is verifying the secondary email
|
||||
if (foundToken?.secondaryEmailId) {
|
||||
await prisma.secondaryEmail.update({
|
||||
where: {
|
||||
id: foundToken.secondaryEmailId,
|
||||
email: foundToken?.identifier,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await cleanUpVerificationTokens(foundToken.id);
|
||||
|
||||
return res.redirect(`${WEBAPP_URL}/settings/my-account/profile`);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: foundToken?.identifier,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: "Cannot find a user attached to this token" });
|
||||
}
|
||||
|
||||
const userMetadataParsed = userMetadata.parse(user.metadata);
|
||||
// Attach the new email and verify
|
||||
if (userMetadataParsed?.emailChangeWaitingForVerification) {
|
||||
// Ensure this email isn't in use
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: userMetadataParsed?.emailChangeWaitingForVerification },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (existingUser) {
|
||||
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
|
||||
}
|
||||
|
||||
// Ensure this email isn't being added by another user as secondary email
|
||||
const existingSecondaryUser = await prisma.secondaryEmail.findUnique({
|
||||
where: {
|
||||
email: userMetadataParsed?.emailChangeWaitingForVerification,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingSecondaryUser && existingSecondaryUser.userId !== user.id) {
|
||||
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
|
||||
}
|
||||
|
||||
const oldEmail = user.email;
|
||||
const updatedEmail = userMetadataParsed.emailChangeWaitingForVerification;
|
||||
delete userMetadataParsed.emailChangeWaitingForVerification;
|
||||
|
||||
// Update and re-verify
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
email: updatedEmail,
|
||||
metadata: userMetadataParsed,
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_STRIPE_ENABLED && userMetadataParsed.stripeCustomerId) {
|
||||
await billingService.updateCustomer({
|
||||
customerId: userMetadataParsed.stripeCustomerId,
|
||||
email: updatedEmail,
|
||||
});
|
||||
}
|
||||
|
||||
// The user is trying to update the email to an already existing unverified secondary email of his
|
||||
// so we swap the emails and its verified status
|
||||
if (existingSecondaryUser?.userId === user.id) {
|
||||
await prisma.secondaryEmail.update({
|
||||
where: {
|
||||
id: existingSecondaryUser.id,
|
||||
userId: user.id,
|
||||
},
|
||||
data: {
|
||||
email: oldEmail,
|
||||
emailVerified: user.emailVerified,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await cleanUpVerificationTokens(foundToken.id);
|
||||
|
||||
return res.status(200).json({
|
||||
updatedEmail,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const hasCompletedOnboarding = user.completedOnboarding;
|
||||
|
||||
await moveUserToMatchingOrg({ email: user.email });
|
||||
|
||||
return res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`);
|
||||
}
|
||||
|
||||
export async function cleanUpVerificationTokens(id: number) {
|
||||
// Delete token from DB after it has been used
|
||||
await prisma.verificationToken.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,17 +4,22 @@ import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { UserListTable } from "@calcom/features/users/components/UserTable/UserListTable";
|
||||
import type { UserListTableProps } from "@calcom/features/users/components/UserTable/UserListTable";
|
||||
import type { MemberPermissions } from "@calcom/features/users/components/UserTable/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export const MembersView = (props: UserListTableProps) => {
|
||||
export const MembersView = (props: UserListTableProps & { permissions?: MemberPermissions }) => {
|
||||
const { t } = useLocale();
|
||||
const { permissions, ...tableProps } = props;
|
||||
|
||||
// Use PBAC permissions if available, otherwise fall back to role-based check
|
||||
const isOrgAdminOrOwner = props.org && checkAdminOrOwner(props.org.user.role);
|
||||
const canLoggedInUserSeeMembers =
|
||||
(props.org?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !props.org?.isPrivate;
|
||||
permissions?.canListMembers ??
|
||||
((props.org?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !props.org?.isPrivate);
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<div>{canLoggedInUserSeeMembers && <UserListTable {...props} />}</div>
|
||||
<div>{canLoggedInUserSeeMembers && <UserListTable {...tableProps} permissions={permissions} />}</div>
|
||||
{!canLoggedInUserSeeMembers && (
|
||||
<div className="border-subtle rounded-xl border p-6" data-testid="members-privacy-warning">
|
||||
<h2 className="text-default">{t("only_admin_can_see_members_of_org")}</h2>
|
||||
|
||||
@@ -83,6 +83,17 @@ const FEATURES = [
|
||||
},
|
||||
];
|
||||
|
||||
function truncateDomain(domain: string) {
|
||||
const maxLength = 25;
|
||||
const cleanDomain = domain.replace(URL_PROTOCOL_REGEX, "");
|
||||
|
||||
if (cleanDomain.length <= maxLength) {
|
||||
return cleanDomain;
|
||||
}
|
||||
|
||||
return `${cleanDomain.substring(0, maxLength - 3)}.../`;
|
||||
}
|
||||
|
||||
function UsernameField({
|
||||
username,
|
||||
setPremium,
|
||||
@@ -398,11 +409,15 @@ export default function Signup({
|
||||
setPremium={(value) => setPremiumUsername(value)}
|
||||
addOnLeading={
|
||||
orgSlug
|
||||
? `${getOrgFullOrigin(orgSlug, { protocol: true }).replace(
|
||||
URL_PROTOCOL_REGEX,
|
||||
""
|
||||
)}/`
|
||||
: `${process.env.NEXT_PUBLIC_WEBSITE_URL.replace(URL_PROTOCOL_REGEX, "")}/`
|
||||
? truncateDomain(
|
||||
`${getOrgFullOrigin(orgSlug, { protocol: true }).replace(
|
||||
URL_PROTOCOL_REGEX,
|
||||
""
|
||||
)}/`
|
||||
)
|
||||
: truncateDomain(
|
||||
`${process.env.NEXT_PUBLIC_WEBSITE_URL.replace(URL_PROTOCOL_REGEX, "")}/`
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
|
||||
import MemberList from "@calcom/features/ee/teams/components/MemberList";
|
||||
import type { MemberPermissions } from "@calcom/features/users/components/UserTable/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
@@ -23,15 +24,17 @@ interface TeamMembersViewProps {
|
||||
}[];
|
||||
};
|
||||
attributes?: any[];
|
||||
permissions: MemberPermissions;
|
||||
}
|
||||
|
||||
export const TeamMembersView = ({ team, facetedTeamValues }: TeamMembersViewProps) => {
|
||||
export const TeamMembersView = ({ team, facetedTeamValues, permissions }: TeamMembersViewProps) => {
|
||||
const { t } = useLocale();
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const [showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);
|
||||
|
||||
// Use PBAC permissions if available, otherwise fall back to role-based check
|
||||
const isTeamAdminOrOwner = checkAdminOrOwner(team.membership.role);
|
||||
const canLoggedInUserSeeMembers = !team.isPrivate || isTeamAdminOrOwner;
|
||||
const canLoggedInUserSeeMembers = permissions?.canListMembers ?? (!team.isPrivate || isTeamAdminOrOwner);
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
@@ -43,6 +46,7 @@ export const TeamMembersView = ({ team, facetedTeamValues }: TeamMembersViewProp
|
||||
isOrgAdminOrOwner={false}
|
||||
setShowMemberInvitationModal={setShowMemberInvitationModal}
|
||||
facetedTeamValues={facetedTeamValues}
|
||||
permissions={permissions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.6.10",
|
||||
"version": "5.6.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -56,9 +56,8 @@
|
||||
"@googleapis/oauth2": "^1.0.7",
|
||||
"@hookform/error-message": "^2.0.0",
|
||||
"@hookform/resolvers": "^2.9.7",
|
||||
"@lingo.dev/_sdk": "^0.7.27",
|
||||
"@next-auth/prisma-adapter": "^1.0.4",
|
||||
"@next/bundle-analyzer": "^13.1.6",
|
||||
"@next/bundle-analyzer": "^15.5.2",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
|
||||
@@ -6,7 +6,7 @@ import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import { applyTheme, applyToDesktopClass } from "./_applyThemeForDocument";
|
||||
import { applyTheme, applyToDesktopClass } from "./../lib/pages/document/_applyThemeForDocument";
|
||||
|
||||
type Props = Record<string, unknown> & DocumentProps & { newLocale: string };
|
||||
|
||||
|
||||
@@ -1,185 +1 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/client";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
|
||||
|
||||
const verifySchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email";
|
||||
|
||||
// TODO: To be unit tested
|
||||
export async function moveUserToMatchingOrg({ email }: { email: string }) {
|
||||
const org = await OrganizationRepository.findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail({ email });
|
||||
|
||||
if (!org) {
|
||||
return;
|
||||
}
|
||||
|
||||
await inviteMembersWithNoInviterPermissionCheck({
|
||||
inviterName: null,
|
||||
teamId: org.id,
|
||||
language: "en",
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
invitations: [
|
||||
{
|
||||
usernameOrEmail: email,
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
],
|
||||
orgSlug: org.slug || org.requestedSlug,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { token } = verifySchema.parse(req.query);
|
||||
const billingService = new StripeBillingService();
|
||||
|
||||
const foundToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!foundToken) {
|
||||
return res.status(401).json({ message: "No token found" });
|
||||
}
|
||||
|
||||
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
|
||||
return res.status(401).json({ message: "Token expired" });
|
||||
}
|
||||
|
||||
// The user is verifying the secondary email
|
||||
if (foundToken?.secondaryEmailId) {
|
||||
await prisma.secondaryEmail.update({
|
||||
where: {
|
||||
id: foundToken.secondaryEmailId,
|
||||
email: foundToken?.identifier,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await cleanUpVerificationTokens(foundToken.id);
|
||||
|
||||
return res.redirect(`${WEBAPP_URL}/settings/my-account/profile`);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: foundToken?.identifier,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: "Cannot find a user attached to this token" });
|
||||
}
|
||||
|
||||
const userMetadataParsed = userMetadata.parse(user.metadata);
|
||||
// Attach the new email and verify
|
||||
if (userMetadataParsed?.emailChangeWaitingForVerification) {
|
||||
// Ensure this email isn't in use
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: userMetadataParsed?.emailChangeWaitingForVerification },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (existingUser) {
|
||||
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
|
||||
}
|
||||
|
||||
// Ensure this email isn't being added by another user as secondary email
|
||||
const existingSecondaryUser = await prisma.secondaryEmail.findUnique({
|
||||
where: {
|
||||
email: userMetadataParsed?.emailChangeWaitingForVerification,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingSecondaryUser && existingSecondaryUser.userId !== user.id) {
|
||||
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
|
||||
}
|
||||
|
||||
const oldEmail = user.email;
|
||||
const updatedEmail = userMetadataParsed.emailChangeWaitingForVerification;
|
||||
delete userMetadataParsed.emailChangeWaitingForVerification;
|
||||
|
||||
// Update and re-verify
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
email: updatedEmail,
|
||||
metadata: userMetadataParsed,
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_STRIPE_ENABLED && userMetadataParsed.stripeCustomerId) {
|
||||
await billingService.updateCustomer({
|
||||
customerId: userMetadataParsed.stripeCustomerId,
|
||||
email: updatedEmail,
|
||||
});
|
||||
}
|
||||
|
||||
// The user is trying to update the email to an already existing unverified secondary email of his
|
||||
// so we swap the emails and its verified status
|
||||
if (existingSecondaryUser?.userId === user.id) {
|
||||
await prisma.secondaryEmail.update({
|
||||
where: {
|
||||
id: existingSecondaryUser.id,
|
||||
userId: user.id,
|
||||
},
|
||||
data: {
|
||||
email: oldEmail,
|
||||
emailVerified: user.emailVerified,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await cleanUpVerificationTokens(foundToken.id);
|
||||
|
||||
return res.status(200).json({
|
||||
updatedEmail,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const hasCompletedOnboarding = user.completedOnboarding;
|
||||
|
||||
await moveUserToMatchingOrg({ email: user.email });
|
||||
|
||||
return res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`);
|
||||
}
|
||||
|
||||
export async function cleanUpVerificationTokens(id: number) {
|
||||
// Delete token from DB after it has been used
|
||||
await prisma.verificationToken.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
export { handler as default } from "@lib/pages/auth/verify-email";
|
||||
|
||||
@@ -4,10 +4,10 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
const userIp = getIP(req);
|
||||
@@ -37,7 +37,24 @@ async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
hostname: req.headers.host || "",
|
||||
forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
|
||||
});
|
||||
// const booking = await createBookingThroughFactory();
|
||||
return booking;
|
||||
|
||||
// To be added in the follow-up PR
|
||||
// async function createBookingThroughFactory() {
|
||||
// console.log("Creating booking through factory");
|
||||
// const regularBookingService = getRegularBookingService();
|
||||
|
||||
// const booking = await regularBookingService.createBooking({
|
||||
// bookingData: req.body,
|
||||
// bookingMeta: {
|
||||
// userId: session?.user?.id || -1,
|
||||
// hostname: req.headers.host || "",
|
||||
// forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
|
||||
// },
|
||||
// });
|
||||
// return booking;
|
||||
// }
|
||||
}
|
||||
|
||||
export default defaultResponder(handler, "/api/book/event");
|
||||
|
||||
@@ -4,9 +4,9 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import handleInstantMeeting from "@calcom/features/instant-meeting/handleInstantMeeting";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
|
||||
async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
const userIp = getIP(req);
|
||||
|
||||
@@ -5,9 +5,9 @@ import { handleNewRecurringBooking } from "@calcom/features/bookings/lib/handleN
|
||||
import type { BookingResponse } from "@calcom/features/bookings/types";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
|
||||
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
|
||||
|
||||
|
||||
@@ -548,10 +548,11 @@ test.describe("Booking round robin event", () => {
|
||||
users,
|
||||
}) => {
|
||||
const [testUser] = users.get();
|
||||
await testUser.apiLogin();
|
||||
|
||||
const team = await testUser.getFirstTeamMembership();
|
||||
|
||||
await testUser.apiLogin(`/team/${team.team.slug}`);
|
||||
|
||||
// Click first event type (round robin)
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
|
||||
@@ -655,9 +656,12 @@ test.describe("Event type with disabled cancellation and rescheduling", () => {
|
||||
});
|
||||
|
||||
test("Should prevent cancellation and show an error message", async ({ page }) => {
|
||||
const csrfTokenResponse = await page.request.get("/api/csrf");
|
||||
const { csrfToken } = await csrfTokenResponse.json();
|
||||
const response = await page.request.post("/api/cancel", {
|
||||
data: {
|
||||
uid: bookingId,
|
||||
csrfToken,
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -657,8 +657,8 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
eventTypes: user.eventTypes,
|
||||
routingForms: user.routingForms,
|
||||
self,
|
||||
apiLogin: async (password?: string) =>
|
||||
apiLogin({ ...(await self()), password: password || user.username }, store.page),
|
||||
apiLogin: async (navigateToUrl?: string, password?: string) =>
|
||||
apiLogin({ ...(await self()), password: password || user.username }, store.page, navigateToUrl),
|
||||
/** Don't forget to close context at the end */
|
||||
apiLoginOnNewBrowser: async (browser: Browser, password?: string) => {
|
||||
const newContext = await browser.newContext();
|
||||
@@ -969,14 +969,18 @@ export async function login(
|
||||
|
||||
export async function apiLogin(
|
||||
user: Pick<Prisma.User, "username"> & Partial<Pick<Prisma.User, "email">> & { password: string | null },
|
||||
page: Page
|
||||
page: Page,
|
||||
navigateToUrl?: string
|
||||
) {
|
||||
// Get CSRF token
|
||||
const csrfToken = await page
|
||||
.context()
|
||||
.request.get("/api/auth/csrf")
|
||||
.then((response) => response.json())
|
||||
.then((json) => json.csrfToken);
|
||||
const data = {
|
||||
|
||||
// Make the login request
|
||||
const loginData = {
|
||||
email: user.email ?? `${user.username}@example.com`,
|
||||
password: user.password ?? user.username,
|
||||
callbackURL: WEBAPP_URL,
|
||||
@@ -984,10 +988,30 @@ export async function apiLogin(
|
||||
json: "true",
|
||||
csrfToken,
|
||||
};
|
||||
|
||||
const response = await page.context().request.post("/api/auth/callback/credentials", {
|
||||
data,
|
||||
data: loginData,
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
/**
|
||||
* Critical: Navigate to a protected page to trigger NextAuth session loading
|
||||
* This forces NextAuth to run the jwt and session callbacks that populate
|
||||
* the session with profile, org, and other important data
|
||||
* We picked /settings/my-account/profile due to it being one of
|
||||
* our lighest protected pages and doesnt do anything other than load the user profile
|
||||
*/
|
||||
await page.goto(navigateToUrl || "/settings/my-account/profile");
|
||||
|
||||
// Wait for the session API call to complete to ensure session is fully established
|
||||
// Only wait if we're on a protected page that would trigger the session API call
|
||||
try {
|
||||
await page.waitForResponse("/api/auth/session", { timeout: 2000 });
|
||||
} catch (error) {
|
||||
// Session API call not made (likely on a public page), continue anyway
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -3403,6 +3403,7 @@
|
||||
"pbac_action_read_team_bookings": "View Team Bookings",
|
||||
"pbac_action_read_org_bookings": "View Organization Bookings",
|
||||
"pbac_action_read_recordings": "View Recordings",
|
||||
"pbac_action_impersonate": "Impersonate",
|
||||
"role_created_successfully": "Role created successfully",
|
||||
"role_updated_successfully": "Role updated successfully",
|
||||
"delete_role": "Delete role",
|
||||
@@ -3437,7 +3438,8 @@
|
||||
"pbac_desc_invite_team_members": "Invite team members",
|
||||
"pbac_desc_remove_team_members": "Remove team members",
|
||||
"pbac_desc_change_team_member_role": "Change role of team members",
|
||||
"pbac_desc_manage_teams": "All actions on teams",
|
||||
"pbac_desc_impersonate_team_members": "Impersonate team members",
|
||||
"pbac_desc_manage_teams": "All actions on teams across organization teams",
|
||||
"pbac_desc_create_organization": "Create organization",
|
||||
"pbac_desc_view_organization_details": "View organization details",
|
||||
"pbac_desc_list_organization_members": "List organization members",
|
||||
@@ -3445,6 +3447,7 @@
|
||||
"pbac_desc_remove_organization_members": "Remove organization members",
|
||||
"pbac_desc_manage_organization_billing": "Manage organization billing",
|
||||
"pbac_desc_change_organization_member_role": "Change role of organization members",
|
||||
"pbac_desc_impersonate_organization_members": "Impersonate organization members",
|
||||
"pbac_desc_edit_organization_settings": "Edit organization settings",
|
||||
"pbac_desc_manage_organizations": "All actions on organizations",
|
||||
"pbac_desc_view_bookings": "View bookings",
|
||||
@@ -3573,6 +3576,7 @@
|
||||
"webhook_metadata": "Metadata",
|
||||
"stats": "Stats",
|
||||
"booking_status": "Booking status",
|
||||
"visit": "Visit",
|
||||
"location_custom_label_input_label": "Custom label on booking page",
|
||||
"meeting_link": "Meeting link",
|
||||
"my_bookings": "My Bookings",
|
||||
@@ -3580,6 +3584,8 @@
|
||||
"free": "Free",
|
||||
"user_email": "User Email",
|
||||
"user_name": "User Name",
|
||||
"expand_panel": "Expand Panel",
|
||||
"collapse_panel": "Collapse Panel",
|
||||
"you_have_one_team": "You have one team",
|
||||
"consider_consolidating_one_team_org": "Consider setting up an organization to unify billing, admin tools, and analytics across your team.",
|
||||
"consider_consolidating_multi_team_org": "Consider setting up an organization to unify billing, admin tools, and analytics across your teams.",
|
||||
|
||||
+1
-1
@@ -9,7 +9,6 @@
|
||||
"packages/embeds/*",
|
||||
"packages/features/*",
|
||||
"packages/app-store/*",
|
||||
"packages/app-store/ee/*",
|
||||
"packages/platform/*",
|
||||
"packages/platform/examples/base",
|
||||
"example-apps/*"
|
||||
@@ -96,6 +95,7 @@
|
||||
"c8": "^7.13.0",
|
||||
"checkly": "latest",
|
||||
"dotenv-checker": "^1.1.5",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"husky": "^8.0.0",
|
||||
"i18n-unused": "^0.13.0",
|
||||
"jest-diff": "^29.5.0",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export const REDIRECT_APPS = [
|
||||
"amie",
|
||||
"autocheckin",
|
||||
"bolna",
|
||||
"chatbase",
|
||||
"clic",
|
||||
"deel",
|
||||
"elevenlabs",
|
||||
"granola",
|
||||
"greetmate-ai",
|
||||
"lindy",
|
||||
"linear",
|
||||
"millis-ai",
|
||||
"monobot",
|
||||
"retell-ai",
|
||||
"synthflow",
|
||||
"telli",
|
||||
"vimcal",
|
||||
"wordpress",
|
||||
];
|
||||
|
||||
export const isRedirectApp = (slug: string): boolean => {
|
||||
return REDIRECT_APPS.includes(slug);
|
||||
};
|
||||
@@ -0,0 +1,451 @@
|
||||
// This script is used to delete recordings older than 6 months from Daily.co
|
||||
//
|
||||
// Commands to run:
|
||||
// 1. Fetch recordings older than 6 months and save to JSON:
|
||||
// ts-node packages/app-store/dailyvideo/lib/scripts/deleteRecordings.ts
|
||||
//
|
||||
// 2. Delete recordings from the JSON file (shows preview first, then prompts for confirmation):
|
||||
// ts-node packages/app-store/dailyvideo/lib/scripts/deleteRecordings.ts delete
|
||||
|
||||
interface Recording {
|
||||
id: string;
|
||||
room_name: string;
|
||||
mtgSessionId: string;
|
||||
status: string;
|
||||
start_ts: number;
|
||||
duration: number;
|
||||
max_participants?: number;
|
||||
share_token?: string;
|
||||
s3key?: string;
|
||||
isVttEnabled?: boolean;
|
||||
tracks?: unknown[];
|
||||
}
|
||||
|
||||
interface RecordingsResponse {
|
||||
data: Recording[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
async function getAllRecordingsOlderThan6Months(): Promise<Recording[]> {
|
||||
const apiKey = process.env.DAILY_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("DAILY_API_KEY environment variable is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const baseUrl = "https://api.daily.co/v1/recordings";
|
||||
const allRecordings: Recording[] = [];
|
||||
const limit = 100;
|
||||
|
||||
// Calculate date 6 months ago
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setUTCMonth(cutoffDate.getUTCMonth() - 6);
|
||||
cutoffDate.setUTCHours(0, 0, 0, 0);
|
||||
const cutoffTimestamp = Math.floor(cutoffDate.getTime() / 1000);
|
||||
|
||||
console.log(
|
||||
`Fetching all recordings older than 6 months (before ${
|
||||
cutoffDate.toISOString().split("T")[0]
|
||||
}, timestamp: ${cutoffTimestamp})...`
|
||||
);
|
||||
|
||||
let hasMoreRecordings = true;
|
||||
let requestCount = 0;
|
||||
const startTime = Date.now();
|
||||
let endingBefore = "OLDEST";
|
||||
|
||||
while (hasMoreRecordings) {
|
||||
requestCount++;
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
const expectedMinTime = (requestCount - 1) * 50;
|
||||
|
||||
if (elapsedTime < expectedMinTime) {
|
||||
const delayTime = expectedMinTime - elapsedTime;
|
||||
console.log(`Rate limiting: waiting ${delayTime}ms before next request...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayTime));
|
||||
}
|
||||
|
||||
const url = new URL(baseUrl);
|
||||
url.searchParams.append("limit", limit.toString());
|
||||
|
||||
if (endingBefore) {
|
||||
url.searchParams.append("ending_before", endingBefore);
|
||||
}
|
||||
|
||||
let retries = 0;
|
||||
const maxRetries = 5;
|
||||
let response: Response | undefined;
|
||||
|
||||
while (retries <= maxRetries) {
|
||||
try {
|
||||
console.log("url", url.toString());
|
||||
response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
console.log("No recordings found or endpoint not available");
|
||||
return [];
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
if (retries < maxRetries) {
|
||||
const backoffDelay = Math.pow(2, retries) * 1000;
|
||||
console.log(
|
||||
`Rate limit exceeded (429). Retrying in ${backoffDelay / 1000}s... (attempt ${retries + 1}/${
|
||||
maxRetries + 1
|
||||
})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
|
||||
retries++;
|
||||
continue;
|
||||
} else {
|
||||
throw new Error(`Rate limit exceeded after ${maxRetries + 1} attempts`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
} catch (error) {
|
||||
if (retries < maxRetries && (error as Error).message.includes("fetch")) {
|
||||
const backoffDelay = Math.pow(2, retries) * 1000;
|
||||
console.log(
|
||||
`Network error. Retrying in ${backoffDelay / 1000}s... (attempt ${retries + 1}/${maxRetries + 1})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
|
||||
retries++;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response?.ok) {
|
||||
throw new Error(`Failed to fetch recordings after ${maxRetries + 1} attempts`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as RecordingsResponse;
|
||||
|
||||
if (!data.data || data.data.length === 0) {
|
||||
console.log("No more recordings available, ending pagination");
|
||||
hasMoreRecordings = false;
|
||||
break;
|
||||
}
|
||||
|
||||
const filteredRecordings = data.data.filter((recording) => {
|
||||
return recording.start_ts < cutoffTimestamp;
|
||||
});
|
||||
|
||||
allRecordings.push(...filteredRecordings);
|
||||
console.log(
|
||||
`Fetched ${data.data.length} recordings, ${filteredRecordings.length} older than 6 months (total: ${allRecordings.length})`
|
||||
);
|
||||
|
||||
endingBefore = data.data[0].id;
|
||||
console.log("endingBefore", endingBefore);
|
||||
console.log("first recording in batch", data.data[0]);
|
||||
console.log("last recording in batch", data.data[data.data.length - 1]);
|
||||
|
||||
if (data.data.length < limit) {
|
||||
console.log("Received fewer results than limit, reached end of data");
|
||||
hasMoreRecordings = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (filteredRecordings.length === 0 && data.data.every((r) => r.start_ts >= cutoffTimestamp)) {
|
||||
console.log("Reached recordings newer than 6 months, stopping");
|
||||
hasMoreRecordings = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allRecordings;
|
||||
}
|
||||
|
||||
async function saveRecordingsToJson(recordings: Recording[]): Promise<void> {
|
||||
const fs = await import("fs/promises");
|
||||
const path = await import("path");
|
||||
|
||||
const simplifiedRecordings = recordings.map((recording) => ({
|
||||
id: recording.id,
|
||||
room_name: recording.room_name,
|
||||
start_ts: recording.start_ts,
|
||||
start_date: new Date(recording.start_ts * 1000).toISOString(),
|
||||
duration: recording.duration,
|
||||
status: recording.status,
|
||||
mtgSessionId: recording.mtgSessionId,
|
||||
max_participants: recording.max_participants,
|
||||
share_token: recording.share_token,
|
||||
s3key: recording.s3key,
|
||||
isVttEnabled: recording.isVttEnabled,
|
||||
}));
|
||||
|
||||
const outputPath = path.join(process.cwd(), "recordings_older_than_6_months.json");
|
||||
|
||||
try {
|
||||
await fs.writeFile(outputPath, JSON.stringify(simplifiedRecordings, null, 2), "utf-8");
|
||||
console.log(`\nRecordings saved to: ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error("Error saving recordings to JSON:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("Fetching all recordings older than 6 months...");
|
||||
|
||||
const recordings = await getAllRecordingsOlderThan6Months();
|
||||
|
||||
const totalDurationSeconds = recordings.reduce(
|
||||
(total: number, recording: Recording) => total + recording.duration,
|
||||
0
|
||||
);
|
||||
|
||||
const totalHours = Math.floor(totalDurationSeconds / 3600);
|
||||
const remainingMinutes = Math.floor((totalDurationSeconds % 3600) / 60);
|
||||
const remainingSeconds = totalDurationSeconds % 60;
|
||||
|
||||
console.log(`\nFinal result: ${recordings.length} recordings found older than 6 months`);
|
||||
console.log(
|
||||
`Total duration: ${totalDurationSeconds} seconds (${totalHours}h ${remainingMinutes}m ${remainingSeconds}s)`
|
||||
);
|
||||
|
||||
await saveRecordingsToJson(recordings);
|
||||
|
||||
console.log("\nLast 10 recordings (oldest first):");
|
||||
|
||||
const lastTen = recordings.slice(-10);
|
||||
lastTen.forEach((recording: Recording, index: number) => {
|
||||
const startTime = new Date(recording.start_ts * 1000).toISOString();
|
||||
const durationMinutes = Math.floor(recording.duration / 60);
|
||||
const durationSeconds = recording.duration % 60;
|
||||
|
||||
console.log(
|
||||
`${index + 1}. ${recording.room_name} (${
|
||||
recording.id
|
||||
}) - Started: ${startTime} - Duration: ${durationMinutes}m ${durationSeconds}s - Status: ${
|
||||
recording.status
|
||||
}`
|
||||
);
|
||||
});
|
||||
|
||||
if (recordings.length > 10) {
|
||||
console.log(`\n... and ${recordings.length - 10} more recordings before these`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecording(recordingId: string, apiKey: string): Promise<boolean> {
|
||||
const url = `https://api.daily.co/v1/recordings/${recordingId}`;
|
||||
const maxRetries = 5;
|
||||
let retries = 0;
|
||||
|
||||
while (retries <= maxRetries) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
return result.deleted === true;
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
console.log(`Recording ${recordingId} not found (may have been already deleted)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
if (retries < maxRetries) {
|
||||
const backoffDelay = Math.pow(2, retries) * 1000;
|
||||
console.log(
|
||||
`Rate limit for ${recordingId}. Retrying in ${backoffDelay / 1000}s... (attempt ${retries + 1}/${
|
||||
maxRetries + 1
|
||||
})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
|
||||
retries++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Failed to delete recording ${recordingId}: HTTP ${response.status}`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
|
||||
if (
|
||||
retries < maxRetries &&
|
||||
(errorMessage.includes("fetch failed") || errorMessage.includes("TIMEOUT"))
|
||||
) {
|
||||
const backoffDelay = Math.pow(2, retries) * 1000;
|
||||
console.log(
|
||||
`Network error for ${recordingId}. Retrying in ${backoffDelay / 1000}s... (attempt ${retries + 1}/${
|
||||
maxRetries + 1
|
||||
})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
|
||||
retries++;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.error(`Error deleting recording ${recordingId} after ${retries + 1} attempts:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Failed to delete recording ${recordingId} after ${maxRetries + 1} attempts`);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function deleteRecordingsFromJson(dryRun = false): Promise<void> {
|
||||
const apiKey = process.env.DAILY_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("DAILY_API_KEY environment variable is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fs = await import("fs/promises");
|
||||
const path = await import("path");
|
||||
|
||||
const filePath = path.join(process.cwd(), "recordings_older_than_6_months.json");
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(filePath, "utf-8");
|
||||
const recordings = JSON.parse(fileContent) as Array<{
|
||||
id: string;
|
||||
room_name: string;
|
||||
start_date: string;
|
||||
duration: number;
|
||||
status: string;
|
||||
}>;
|
||||
|
||||
console.log(`\nFound ${recordings.length} recordings to delete`);
|
||||
|
||||
if (recordings.length === 0) {
|
||||
console.log("No recordings to delete");
|
||||
return;
|
||||
}
|
||||
|
||||
const totalDuration = recordings.reduce((sum, rec) => sum + rec.duration, 0);
|
||||
const totalHours = Math.floor(totalDuration / 3600);
|
||||
const totalMinutes = Math.floor((totalDuration % 3600) / 60);
|
||||
|
||||
console.log(`Total duration: ${totalHours}h ${totalMinutes}m`);
|
||||
console.log(`Date range: ${recordings[0].start_date} to ${recordings[recordings.length - 1].start_date}`);
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n🔸 DRY RUN MODE - No recordings will be deleted");
|
||||
console.log("First 5 recordings that would be deleted:");
|
||||
recordings.slice(0, 5).forEach((rec, idx) => {
|
||||
console.log(` ${idx + 1}. ${rec.id} - ${rec.room_name} (${rec.start_date})`);
|
||||
});
|
||||
if (recordings.length > 5) {
|
||||
console.log(` ... and ${recordings.length - 5} more`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const startTime = Date.now();
|
||||
const batchSize = 10;
|
||||
|
||||
for (let i = 0; i < recordings.length; i += batchSize) {
|
||||
const batch = recordings.slice(i, Math.min(i + batchSize, recordings.length));
|
||||
const batchPromises = [];
|
||||
|
||||
for (const recording of batch) {
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
const expectedMinTime = (successCount + failCount) * 50;
|
||||
|
||||
if (elapsedTime < expectedMinTime) {
|
||||
const delayTime = expectedMinTime - elapsedTime;
|
||||
await new Promise((resolve) => setTimeout(resolve, delayTime));
|
||||
}
|
||||
|
||||
const deletePromise = deleteRecording(recording.id, apiKey).then((success) => {
|
||||
if (success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
|
||||
if ((successCount + failCount) % 10 === 0) {
|
||||
const progress = (((successCount + failCount) / recordings.length) * 100).toFixed(1);
|
||||
console.log(
|
||||
`Progress: ${progress}% (${successCount + failCount}/${recordings.length}), recording-id:${
|
||||
recording.id
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
return success;
|
||||
});
|
||||
|
||||
batchPromises.push(deletePromise);
|
||||
}
|
||||
|
||||
await Promise.all(batchPromises);
|
||||
}
|
||||
|
||||
console.log("\n✅ Deletion complete!");
|
||||
console.log(`Successfully deleted: ${successCount}`);
|
||||
console.log(`Failed to delete: ${failCount}`);
|
||||
console.log(`Total processed: ${successCount + failCount}`);
|
||||
|
||||
if (failCount > 0) {
|
||||
console.log("\n⚠️ Some recordings failed to delete. Check the logs above for details.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error reading or processing recordings file:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function mainDelete(): Promise<void> {
|
||||
const readline = (await import("readline")).createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
console.log("🗑️ Daily Video Recording Deletion Tool");
|
||||
console.log("=====================================");
|
||||
console.log("This tool will delete all recordings listed in recordings_older_than_6_months.json");
|
||||
console.log("\n⚠️ WARNING: This action cannot be undone!");
|
||||
console.log("Note: Recordings in custom S3 buckets will NOT be deleted from S3.\n");
|
||||
|
||||
await deleteRecordingsFromJson(true);
|
||||
|
||||
const question = (query: string): Promise<string> =>
|
||||
new Promise((resolve) => readline.question(query, resolve));
|
||||
|
||||
const answer = await question("\nDo you want to proceed with deletion? (yes/no): ");
|
||||
|
||||
if (answer.toLowerCase() === "yes") {
|
||||
console.log("\nStarting deletion process...\n");
|
||||
await deleteRecordingsFromJson(false);
|
||||
} else {
|
||||
console.log("\nDeletion cancelled.");
|
||||
}
|
||||
|
||||
readline.close();
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === "delete") {
|
||||
mainDelete();
|
||||
} else {
|
||||
main();
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import { z, ZodError } from "zod";
|
||||
|
||||
import { GiphyManager } from "../lib";
|
||||
|
||||
const giphyUrlRegexp = new RegExp("^https://(.*).giphy.com/media/(.*)/giphy.gif(.*)");
|
||||
const giphyUrlRegexp = new RegExp(
|
||||
"^https?:\\/\\/(?:[\\w.-]+\\.)?giphy\\.com\\/media\\/(?:[^/]+\\/)*([a-zA-Z0-9_-]+)\\/giphy\\.gif(?:\\?.*)?$"
|
||||
);
|
||||
|
||||
const getSchema = z.object({
|
||||
url: z.string().regex(giphyUrlRegexp, "Giphy URL is invalid"),
|
||||
@@ -26,10 +28,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const sanitisedUrl = parsedUrl.origin + parsedUrl.pathname;
|
||||
// Extract Giphy ID from embed url
|
||||
const matches = giphyUrlRegexp.exec(sanitisedUrl);
|
||||
if (!matches || matches.length < 3) {
|
||||
if (!matches || matches.length < 2) {
|
||||
return res.status(400).json({ message: "Giphy URL is invalid" });
|
||||
}
|
||||
const giphyId = matches[2];
|
||||
const giphyId = matches[1];
|
||||
const gifImageUrl = await GiphyManager.getGiphyById(giphyId);
|
||||
return res.status(200).json({ image: gifImageUrl });
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
@@ -10,6 +11,21 @@ import { getStripeCustomerIdFromUserId } from "../lib/customer";
|
||||
import stripe from "../lib/server";
|
||||
import { getSubscriptionFromId } from "../lib/subscriptions";
|
||||
|
||||
const getBillingPortalUrl = async (customerId: string, return_url: string) => {
|
||||
const log = logger.getSubLogger({ prefix: ["getBillingPortalUrl"] });
|
||||
try {
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url,
|
||||
});
|
||||
|
||||
return portalSession.url;
|
||||
} catch (e) {
|
||||
log.error(`Failed to create billing portal session for ${customerId}: ${e}`);
|
||||
throw new Error("Failed to create billing portal session");
|
||||
}
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST" && req.method !== "GET")
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
@@ -18,9 +34,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
const userId = req.session.user.id;
|
||||
const teamId = req.query.teamId ? parseInt(req.query.teamId as string) : null;
|
||||
|
||||
let return_url = `${WEBAPP_URL}/settings/billing`;
|
||||
if (!teamId) {
|
||||
return res.status(400).json({ message: "Team ID is required" });
|
||||
const customerId = await getStripeCustomerIdFromUserId(userId);
|
||||
if (!customerId) return res.status(404).json({ message: "CustomerId not found" });
|
||||
|
||||
const billingPortalUrl = await getBillingPortalUrl(customerId, return_url);
|
||||
|
||||
return res.redirect(302, billingPortalUrl);
|
||||
}
|
||||
|
||||
const teamRepository = new TeamRepository(prisma);
|
||||
@@ -28,25 +49,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
teamId,
|
||||
userId,
|
||||
});
|
||||
let return_url = `${WEBAPP_URL}/settings/billing`;
|
||||
|
||||
if (!team) return res.status(404).json({ message: "Team not found" });
|
||||
|
||||
if (typeof req.query.returnTo === "string") {
|
||||
const safeRedirectUrl = getSafeRedirectUrl(req.query.returnTo);
|
||||
if (safeRedirectUrl) return_url = safeRedirectUrl;
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
const customerId = await getStripeCustomerIdFromUserId(userId);
|
||||
if (!customerId) return res.status(404).json({ message: "CustomerId not found" });
|
||||
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url,
|
||||
});
|
||||
|
||||
return res.status(200).json({ url: portalSession.url });
|
||||
}
|
||||
|
||||
const teamMetadataParsed = teamMetadataSchema.safeParse(team.metadata);
|
||||
|
||||
if (!teamMetadataParsed.success) {
|
||||
@@ -71,10 +81,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
if (!customerId) return res.status(400).json({ message: "CustomerId not found in stripe" });
|
||||
|
||||
const stripeSession = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url,
|
||||
});
|
||||
const billingPortalUrl = await getBillingPortalUrl(customerId, return_url);
|
||||
|
||||
res.redirect(302, stripeSession.url);
|
||||
res.redirect(302, billingPortalUrl);
|
||||
}
|
||||
|
||||
@@ -358,9 +358,14 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
paymentData: Payment,
|
||||
eventTypeMetadata?: EventTypeMetadata
|
||||
): Promise<void> {
|
||||
const attendeesToEmail = event.attendeeSeatId
|
||||
? event.attendees.filter(attendee => attendee.bookingSeat?.referenceUid === event.attendeeSeatId)
|
||||
: event.attendees;
|
||||
|
||||
await sendAwaitingPaymentEmailAndSMS(
|
||||
{
|
||||
...event,
|
||||
attendees: attendeesToEmail,
|
||||
paymentInfo: {
|
||||
link: createPaymentLink({
|
||||
paymentUid: paymentData.uid,
|
||||
|
||||
@@ -579,10 +579,13 @@ function main() {
|
||||
const url = new URL(document.URL);
|
||||
embedStore.theme = window?.getEmbedTheme?.();
|
||||
|
||||
const autoScrollFromParam = url.searchParams.get("ui.autoscroll");
|
||||
const shouldDisableAutoScroll = autoScrollFromParam === "false";
|
||||
embedStore.uiConfig = {
|
||||
// TODO: Add theme as well here
|
||||
colorScheme: url.searchParams.get("ui.color-scheme"),
|
||||
layout: url.searchParams.get("layout") as BookerLayouts,
|
||||
disableAutoScroll: shouldDisableAutoScroll,
|
||||
};
|
||||
|
||||
actOnColorScheme(embedStore.uiConfig.colorScheme);
|
||||
|
||||
@@ -79,6 +79,7 @@ function listKnownConfigProps() {
|
||||
"ui.color-scheme",
|
||||
"theme",
|
||||
"cal.embed.pageType",
|
||||
"ui.autoscroll",
|
||||
];
|
||||
return knownConfigProps;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export type UiConfig = {
|
||||
cssVarsPerTheme?: Record<Theme, Record<string, string>>;
|
||||
layout?: BookerLayouts;
|
||||
colorScheme?: string | null;
|
||||
disableAutoScroll?: boolean;
|
||||
};
|
||||
|
||||
declare global {
|
||||
@@ -63,6 +64,7 @@ export type KnownConfig = {
|
||||
layout?: BookerLayouts;
|
||||
// TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time.
|
||||
"ui.color-scheme"?: string;
|
||||
"ui.autoscroll"?: "true" | "false";
|
||||
theme?: EmbedThemeConfig;
|
||||
// Prefixing with cal.embed because there could be more query params that aren't required by embed and are used for things like prefilling booking form, configuring dry run, and some other params simply to be forwarded to the booking success redirect URL.
|
||||
// There are some cal. prefixed query params as well, not meant for embed specifically, but in general for cal.com
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { isRedirectApp } from "@calcom/app-store/_utils/redirectApps";
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils";
|
||||
@@ -56,6 +57,10 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
|
||||
}, [app.name, searchText]);
|
||||
|
||||
const handleAppInstall = () => {
|
||||
if (isRedirectApp(app.slug)) {
|
||||
mutation.mutate({ type: app.type, variant: app.variant, slug: app.slug });
|
||||
return;
|
||||
}
|
||||
if (isConferencing(app.categories) && !app.concurrentMeetings) {
|
||||
mutation.mutate({
|
||||
type: app.type,
|
||||
@@ -148,7 +153,9 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
|
||||
loading: mutation.isPending,
|
||||
};
|
||||
}
|
||||
return <InstallAppButtonChild paid={app.paid} {...props} />;
|
||||
return (
|
||||
<InstallAppButtonChild paid={app.paid} isRedirect={isRedirectApp(app.slug)} {...props} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -170,7 +177,9 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
|
||||
loading: mutation.isPending,
|
||||
};
|
||||
}
|
||||
return <InstallAppButtonChild paid={app.paid} {...props} />;
|
||||
return (
|
||||
<InstallAppButtonChild paid={app.paid} isRedirect={isRedirectApp(app.slug)} {...props} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -192,11 +201,25 @@ export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCar
|
||||
|
||||
const InstallAppButtonChild = ({
|
||||
paid,
|
||||
isRedirect = false,
|
||||
...props
|
||||
}: {
|
||||
paid: App["paid"];
|
||||
isRedirect?: boolean;
|
||||
} & ButtonProps) => {
|
||||
const { t } = useLocale();
|
||||
if (isRedirect) {
|
||||
return (
|
||||
<Button
|
||||
color="secondary"
|
||||
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
|
||||
StartIcon="external-link"
|
||||
{...props}
|
||||
size="base">
|
||||
{t("visit")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
// Paid apps don't support team installs at the moment
|
||||
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
|
||||
if (paid) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBookerEmbed";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import PoweredBy from "@calcom/ee/components/PoweredBy";
|
||||
import { useEmbedUiConfig } from "@calcom/embed-core/embed-iframe";
|
||||
import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe";
|
||||
import TurnstileCaptcha from "@calcom/features/auth/Turnstile";
|
||||
import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider";
|
||||
@@ -165,9 +166,14 @@ const BookerComponent = ({
|
||||
} = calendars;
|
||||
|
||||
const scrolledToTimeslotsOnce = useRef(false);
|
||||
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
const scrollToTimeSlots = () => {
|
||||
if (isMobile && !scrolledToTimeslotsOnce.current && timeslotsRef.current) {
|
||||
if (
|
||||
isMobile &&
|
||||
!scrolledToTimeslotsOnce.current &&
|
||||
timeslotsRef.current &&
|
||||
!embedUiConfig.disableAutoScroll
|
||||
) {
|
||||
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- We are allowing it here because scrollToTimeSlots is called on explicit user action where it makes sense to scroll, remember that the goal is to not do auto-scroll on embed load because that ends up scrolling the embedding webpage too
|
||||
scrollIntoViewSmooth(timeslotsRef.current, isEmbed);
|
||||
scrolledToTimeslotsOnce.current = true;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createContainer } from "@evyweb/ioctopus";
|
||||
|
||||
import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
|
||||
import type { InstantBookingCreateService } from "../modules/InstantBookingCreateServiceModule";
|
||||
import { instantBookingCreateServiceModule } from "../modules/InstantBookingCreateServiceModule";
|
||||
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.INSTANT_BOOKING_CREATE_SERVICE_MODULE, instantBookingCreateServiceModule);
|
||||
|
||||
export function getInstantBookingCreateService(): InstantBookingCreateService {
|
||||
return container.get<InstantBookingCreateService>(DI_TOKENS.INSTANT_BOOKING_CREATE_SERVICE);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createContainer } from "@evyweb/ioctopus";
|
||||
|
||||
import { bookingRepositoryModule } from "@calcom/lib/di/modules/Booking";
|
||||
import { cacheModule } from "@calcom/lib/di/modules/Cache";
|
||||
import { checkBookingAndDurationLimitsModule } from "@calcom/lib/di/modules/CheckBookingAndDurationLimits";
|
||||
import { checkBookingLimitsModule } from "@calcom/lib/di/modules/CheckBookingLimits";
|
||||
import { featuresRepositoryModule } from "@calcom/lib/di/modules/Features";
|
||||
import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
|
||||
import type { RecurringBookingService } from "../modules/RecurringBookingServiceModule";
|
||||
import { recurringBookingServiceModule } from "../modules/RecurringBookingServiceModule";
|
||||
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
||||
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
|
||||
container.load(DI_TOKENS.CACHE_SERVICE_MODULE, cacheModule);
|
||||
container.load(DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE_MODULE, checkBookingLimitsModule);
|
||||
container.load(DI_TOKENS.FEATURES_REPOSITORY_MODULE, featuresRepositoryModule);
|
||||
container.load(
|
||||
DI_TOKENS.CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE_MODULE,
|
||||
checkBookingAndDurationLimitsModule
|
||||
);
|
||||
container.load(DI_TOKENS.RECURRING_BOOKING_SERVICE_MODULE, recurringBookingServiceModule);
|
||||
|
||||
export function getRecurringBookingService(): RecurringBookingService {
|
||||
return container.get<RecurringBookingService>(DI_TOKENS.RECURRING_BOOKING_SERVICE);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createContainer } from "@evyweb/ioctopus";
|
||||
|
||||
import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
|
||||
import type { RegularBookingService } from "../modules/RegularBookingServiceModule";
|
||||
import {
|
||||
regularBookingServiceModule,
|
||||
loadModuleDeps,
|
||||
moduleToken,
|
||||
} from "../modules/RegularBookingServiceModule";
|
||||
|
||||
const regularBookingServiceContainer = createContainer();
|
||||
|
||||
regularBookingServiceContainer.load(DI_TOKENS.REGULAR_BOOKING_SERVICE_MODULE, regularBookingServiceModule);
|
||||
|
||||
export function getRegularBookingService(): RegularBookingService {
|
||||
loadModuleDeps(regularBookingServiceContainer);
|
||||
|
||||
return regularBookingServiceContainer.get<RegularBookingService>(moduleToken);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createModule } from "@evyweb/ioctopus";
|
||||
|
||||
import { InstantBookingCreateService } from "@calcom/features/instant-meeting/handleInstantMeeting";
|
||||
import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
|
||||
export const instantBookingCreateServiceModule = createModule();
|
||||
|
||||
instantBookingCreateServiceModule
|
||||
.bind(DI_TOKENS.INSTANT_BOOKING_CREATE_SERVICE)
|
||||
.toClass(InstantBookingCreateService);
|
||||
export type { InstantBookingCreateService };
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createModule } from "@evyweb/ioctopus";
|
||||
|
||||
import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
|
||||
import { RecurringBookingService } from "../../handleNewRecurringBooking";
|
||||
|
||||
export const recurringBookingServiceModule = createModule();
|
||||
|
||||
recurringBookingServiceModule.bind(DI_TOKENS.RECURRING_BOOKING_SERVICE).toClass(RecurringBookingService, {
|
||||
regularBookingService: DI_TOKENS.REGULAR_BOOKING_SERVICE,
|
||||
});
|
||||
|
||||
export type { RecurringBookingService };
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Container } from "@evyweb/ioctopus";
|
||||
import { createModule } from "@evyweb/ioctopus";
|
||||
|
||||
import { bookingRepositoryModule } from "@calcom/lib/di/modules/Booking";
|
||||
import { cacheModule } from "@calcom/lib/di/modules/Cache";
|
||||
import { checkBookingAndDurationLimitsModule } from "@calcom/lib/di/modules/CheckBookingAndDurationLimits";
|
||||
import { checkBookingLimitsModule } from "@calcom/lib/di/modules/CheckBookingLimits";
|
||||
import { featuresRepositoryModule } from "@calcom/lib/di/modules/Features";
|
||||
import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
|
||||
import { RegularBookingService } from "../../handleNewBooking";
|
||||
|
||||
export const regularBookingServiceModule = createModule();
|
||||
const moduleToken = DI_TOKENS.REGULAR_BOOKING_SERVICE;
|
||||
regularBookingServiceModule.bind(moduleToken).toClass(RegularBookingService, {
|
||||
cacheService: DI_TOKENS.CACHE_SERVICE,
|
||||
checkBookingAndDurationLimitsService: DI_TOKENS.CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE,
|
||||
prismaClient: DI_TOKENS.PRISMA_CLIENT,
|
||||
bookingRepository: DI_TOKENS.BOOKING_REPOSITORY,
|
||||
featuresRepository: DI_TOKENS.FEATURES_REPOSITORY,
|
||||
checkBookingLimitsService: DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE,
|
||||
});
|
||||
|
||||
// Load the dependencies defined for the module above
|
||||
function loadModuleDeps(container: Container) {
|
||||
container.load(DI_TOKENS.CACHE_SERVICE_MODULE, cacheModule);
|
||||
container.load(
|
||||
DI_TOKENS.CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE_MODULE,
|
||||
checkBookingAndDurationLimitsModule
|
||||
);
|
||||
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
||||
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
|
||||
container.load(DI_TOKENS.FEATURES_REPOSITORY_MODULE, featuresRepositoryModule);
|
||||
container.load(DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE_MODULE, checkBookingLimitsModule);
|
||||
}
|
||||
|
||||
export { loadModuleDeps, moduleToken };
|
||||
export type { RegularBookingService };
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Domain types for BookingCreateService
|
||||
* These types are framework-agnostic and contain only the data required for booking operations
|
||||
*/
|
||||
import type { z } from "zod";
|
||||
|
||||
import type getBookingDataSchema from "@calcom/features/bookings/lib/getBookingDataSchema";
|
||||
import type getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi";
|
||||
import type { BookingCreateBody as BaseCreateBookingData } from "@calcom/prisma/zod/custom/booking";
|
||||
import type { extendedBookingCreateBody } from "@calcom/prisma/zod/custom/booking";
|
||||
|
||||
export type ExtendedBookingCreateData = z.input<typeof extendedBookingCreateBody>;
|
||||
export type BookingDataSchemaGetter = typeof getBookingDataSchema | typeof getBookingDataSchemaForApi;
|
||||
|
||||
export type CreateRegularBookingData = BaseCreateBookingData;
|
||||
|
||||
export type CreateInstantBookingData = BaseCreateBookingData;
|
||||
|
||||
export type CreateRecurringBookingData = (BaseCreateBookingData & {
|
||||
schedulingType?: SchedulingType;
|
||||
})[];
|
||||
|
||||
export type PlatformParams = {
|
||||
platformClientId?: string;
|
||||
platformCancelUrl?: string;
|
||||
platformBookingUrl?: string;
|
||||
platformRescheduleUrl?: string;
|
||||
platformBookingLocation?: string;
|
||||
areCalendarEventsEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type CreateBookingMeta = {
|
||||
userId?: number;
|
||||
// These used to come from headers but now we're passing them as params
|
||||
hostname?: string;
|
||||
forcedSlug?: string;
|
||||
noEmail?: boolean;
|
||||
} & PlatformParams;
|
||||
|
||||
export type BookingHandlerInput = {
|
||||
bookingData: CreateRegularBookingData;
|
||||
} & CreateBookingMeta;
|
||||
|
||||
export type CreateInstantBookingResponse = {
|
||||
message: string;
|
||||
meetingTokenId: number;
|
||||
bookingId: number;
|
||||
bookingUid: string;
|
||||
expires: Date;
|
||||
userId: number | null;
|
||||
};
|
||||
@@ -25,14 +25,19 @@ import {
|
||||
} from "@calcom/emails";
|
||||
import getICalUID from "@calcom/emails/lib/getICalUID";
|
||||
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
|
||||
import type { BookingDataSchemaGetter } from "@calcom/features/bookings/lib/dto/types";
|
||||
import type { CreateRegularBookingData, CreateBookingMeta } from "@calcom/features/bookings/lib/dto/types";
|
||||
import type { CheckBookingAndDurationLimitsService } from "@calcom/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits";
|
||||
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
|
||||
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
|
||||
import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
|
||||
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
|
||||
import {
|
||||
allowDisablingAttendeeConfirmationEmails,
|
||||
allowDisablingHostConfirmationEmails,
|
||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { getFullName } from "@calcom/features/form-builder/utils";
|
||||
import { UsersRepository } from "@calcom/features/users/users.repository";
|
||||
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
@@ -64,6 +69,7 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
||||
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
|
||||
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import type { CheckBookingLimitsService } from "@calcom/lib/intervalLimits/server/checkBookingLimits";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { handlePayment } from "@calcom/lib/payment/handlePayment";
|
||||
import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData";
|
||||
@@ -73,6 +79,7 @@ import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
|
||||
import { HashedLinkService } from "@calcom/lib/server/service/hashedLinkService";
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { AssignmentReasonEnum } from "@calcom/prisma/enums";
|
||||
import { BookingStatus, SchedulingType, WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
@@ -121,6 +128,7 @@ import type { IEventTypePaymentCredentialType, Invitee, IsFixedAwareUser } from
|
||||
import { validateBookingTimeIsNotOutOfBounds } from "./handleNewBooking/validateBookingTimeIsNotOutOfBounds";
|
||||
import { validateEventLength } from "./handleNewBooking/validateEventLength";
|
||||
import handleSeats from "./handleSeats/handleSeats";
|
||||
import type { IBookingService } from "./interfaces/IBookingService";
|
||||
|
||||
const translator = short();
|
||||
const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
|
||||
@@ -150,10 +158,6 @@ function getICalSequence(originalRescheduledBooking: BookingType | null) {
|
||||
return originalRescheduledBooking.iCalSequence + 1;
|
||||
}
|
||||
|
||||
type BookingDataSchemaGetter =
|
||||
| typeof getBookingDataSchema
|
||||
| typeof import("@calcom/features/bookings/lib/getBookingDataSchemaForApi").default;
|
||||
|
||||
type CreatedBooking = Booking & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number };
|
||||
type ReturnTypeCreateBooking = Awaited<ReturnType<typeof createBooking>>;
|
||||
export const buildDryRunBooking = ({
|
||||
@@ -2455,3 +2459,38 @@ async function handler(
|
||||
}
|
||||
|
||||
export default handler;
|
||||
|
||||
export interface IBookingServiceDependencies {
|
||||
cacheService: CacheService;
|
||||
checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService;
|
||||
prismaClient: PrismaClient;
|
||||
bookingRepository: BookingRepository;
|
||||
featuresRepository: FeaturesRepository;
|
||||
checkBookingLimitsService: CheckBookingLimitsService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes care of creating/rescheduling non-recurring, non-instant bookings. Such bookings could be TeamBooking, UserBooking, SeatedUserBooking, SeatedTeamBooking, etc.
|
||||
* We can't name it CoreBookingService because non-instant booking also creates a booking but it is entirely different from the regular booking.
|
||||
* We are open to renaming it to something more descriptive.
|
||||
*/
|
||||
export class RegularBookingService implements IBookingService {
|
||||
constructor(private readonly deps: IBookingServiceDependencies) {}
|
||||
|
||||
async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
|
||||
// deps to be passed to handler in follow-up PR
|
||||
return handler({ bookingData: input.bookingData, ...input.bookingMeta });
|
||||
}
|
||||
|
||||
async rescheduleBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
|
||||
return handler({ bookingData: input.bookingData, ...input.bookingMeta });
|
||||
}
|
||||
|
||||
async rescheduleBookingForApiV1(input: {
|
||||
bookingData: CreateRegularBookingData;
|
||||
bookingMeta?: CreateBookingMeta;
|
||||
bookingDataSchemaGetter: BookingDataSchemaGetter;
|
||||
}) {
|
||||
return handler({ bookingData: input.bookingData, ...input.bookingMeta }, input.bookingDataSchemaGetter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ function buildNewBookingData(params: CreateBookingParams) {
|
||||
dynamicEventSlugRef: !eventType.id ? eventType.slug : null,
|
||||
dynamicGroupSlugRef: !eventType.id ? (reqBody.user as string).toLowerCase() : null,
|
||||
iCalUID: evt.iCalUID ?? "",
|
||||
iCalSequence: originalRescheduledBooking ? evt.iCalSequence || 1 : 0,
|
||||
user: {
|
||||
connect: {
|
||||
id: eventType.organizerUser.id,
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
import type { CreateBookingMeta, CreateRecurringBookingData } from "@calcom/features/bookings/lib/dto/types";
|
||||
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import type { BookingResponse } from "@calcom/features/bookings/types";
|
||||
import { SchedulingType } from "@calcom/prisma/client";
|
||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
export type PlatformParams = {
|
||||
platformClientId?: string;
|
||||
platformCancelUrl?: string;
|
||||
platformBookingUrl?: string;
|
||||
platformRescheduleUrl?: string;
|
||||
platformBookingLocation?: string;
|
||||
areCalendarEventsEnabled?: boolean;
|
||||
};
|
||||
import type { RegularBookingService } from "./handleNewBooking";
|
||||
import type { IBookingService } from "./interfaces/IBookingService";
|
||||
|
||||
export type BookingHandlerInput = {
|
||||
bookingData: Record<string, any>[];
|
||||
userId?: number;
|
||||
// These used to come from headers but now we're passing them as params
|
||||
hostname?: string;
|
||||
forcedSlug?: string;
|
||||
noEmail?: boolean;
|
||||
} & PlatformParams;
|
||||
bookingData: CreateRecurringBookingData;
|
||||
} & CreateBookingMeta;
|
||||
|
||||
export const handleNewRecurringBooking = async (input: BookingHandlerInput): Promise<BookingResponse[]> => {
|
||||
const data = input.bookingData;
|
||||
@@ -115,7 +105,7 @@ export const handleNewRecurringBooking = async (input: BookingHandlerInput): Pro
|
||||
|
||||
if (!thirdPartyRecurringEventId) {
|
||||
if (eachRecurringBooking.references && eachRecurringBooking.references.length > 0) {
|
||||
for (const reference of eachRecurringBooking.references!) {
|
||||
for (const reference of eachRecurringBooking.references) {
|
||||
if (reference.thirdPartyRecurringEventId) {
|
||||
thirdPartyRecurringEventId = reference.thirdPartyRecurringEventId;
|
||||
break;
|
||||
@@ -126,3 +116,32 @@ export const handleNewRecurringBooking = async (input: BookingHandlerInput): Pro
|
||||
}
|
||||
return createdBookings;
|
||||
};
|
||||
|
||||
export interface IRecurringBookingServiceDependencies {
|
||||
regularBookingService: RegularBookingService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recurring Booking Service takes care of creating/rescheduling recurring bookings.
|
||||
*/
|
||||
export class RecurringBookingService implements IBookingService {
|
||||
constructor(private readonly deps: IRecurringBookingServiceDependencies) {}
|
||||
|
||||
async createBooking(input: {
|
||||
bookingData: CreateRecurringBookingData;
|
||||
bookingMeta?: CreateBookingMeta;
|
||||
}): Promise<BookingResponse[]> {
|
||||
const handlerInput = { bookingData: input.bookingData, ...(input.bookingMeta || {}) };
|
||||
// FOLLOW-UP: Pass on dependencies to the handler
|
||||
return handleNewRecurringBooking(handlerInput);
|
||||
}
|
||||
|
||||
async rescheduleBooking(input: {
|
||||
bookingData: CreateRecurringBookingData;
|
||||
bookingMeta?: CreateBookingMeta;
|
||||
}): Promise<BookingResponse[]> {
|
||||
const handlerInput = { bookingData: input.bookingData, ...(input.bookingMeta || {}) };
|
||||
// FOLLOW-UP: Pass on dependencies to the handler
|
||||
return handleNewRecurringBooking(handlerInput);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Defines an interface that could be used by any type of Booking Service
|
||||
// "Any" types are used because this inteface is used by RecurringBookingService which accepts an array of bookingData and RegularBookingService which accepts a single bookingData
|
||||
// So, this interface enforces just the methods that must be present but not their parameters and return types
|
||||
export interface IBookingCreateService {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
createBooking: (...args: any[]) => Promise<any>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Defines an interface that could be used by any type of Booking Service
|
||||
// "Any" types are used because this inteface is used by RecurringBookingService which accepts an array of bookingData and RegularBookingService which accepts a single bookingData
|
||||
// So, this interface enforces just the methods that must be present but not their parameters and return types
|
||||
export interface IBookingService {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
createBooking: (...args: any[]) => Promise<any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
rescheduleBooking: (...args: any[]) => Promise<any>;
|
||||
}
|
||||
@@ -31,6 +31,22 @@ function BasePhoneInput({
|
||||
}: PhoneInputProps) {
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
// This is to trigger validation on prefill value changes
|
||||
useEffect(() => {
|
||||
if (!value) return;
|
||||
|
||||
const sanitized = value
|
||||
.trim()
|
||||
.replace(/[^\d+]/g, "")
|
||||
.replace(/^\+?/, "+");
|
||||
|
||||
if (sanitized === "+" || sanitized === "") return;
|
||||
|
||||
if (value !== sanitized) {
|
||||
onChange(sanitized);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!isPlatform) {
|
||||
return (
|
||||
<BasePhoneInputWeb name={name} className={className} onChange={onChange} value={value} {...rest} />
|
||||
@@ -45,12 +61,12 @@ function BasePhoneInput({
|
||||
disableSearchIcon
|
||||
country={defaultCountry}
|
||||
inputProps={{
|
||||
name: name,
|
||||
name,
|
||||
required: rest.required,
|
||||
placeholder: rest.placeholder,
|
||||
}}
|
||||
onChange={(value) => {
|
||||
onChange(`+${value}`);
|
||||
onChange={(val: string) => {
|
||||
onChange(`+${val}`);
|
||||
}}
|
||||
containerClass={classNames(
|
||||
"hover:border-emphasis dark:focus:border-emphasis border-default !bg-default rounded-md border focus-within:outline-none focus-within:ring-2 focus-within:ring-brand-default disabled:cursor-not-allowed",
|
||||
@@ -84,6 +100,7 @@ function BasePhoneInputWeb({
|
||||
...rest
|
||||
}: Omit<PhoneInputProps, "defaultCountry">) {
|
||||
const defaultCountry = useDefaultCountry();
|
||||
|
||||
return (
|
||||
<PhoneInput
|
||||
{...rest}
|
||||
@@ -92,12 +109,12 @@ function BasePhoneInputWeb({
|
||||
enableSearch
|
||||
disableSearchIcon
|
||||
inputProps={{
|
||||
name: name,
|
||||
name,
|
||||
required: rest.required,
|
||||
placeholder: rest.placeholder,
|
||||
}}
|
||||
onChange={(value) => {
|
||||
onChange(`+${value}`);
|
||||
onChange={(val: string) => {
|
||||
onChange(`+${val}`);
|
||||
}}
|
||||
containerClass={classNames(
|
||||
"hover:border-emphasis dark:focus:border-emphasis border-default !bg-default rounded-md border focus-within:outline-none focus-within:ring-2 focus-within:ring-brand-default disabled:cursor-not-allowed",
|
||||
|
||||
@@ -2,12 +2,14 @@ import type { Session } from "next-auth";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import {
|
||||
parseTeamId,
|
||||
checkSelfImpersonation,
|
||||
checkUserIdentifier,
|
||||
checkGlobalPermission,
|
||||
checkPBACImpersonationPermission,
|
||||
} from "./ImpersonationProvider";
|
||||
|
||||
const session: Session = {
|
||||
@@ -79,3 +81,41 @@ describe("checkPermission", () => {
|
||||
expect(() => checkGlobalPermission(session)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkPBACImpersonationPermission", () => {
|
||||
it("should return true for admin users", async () => {
|
||||
const result = await checkPBACImpersonationPermission({
|
||||
userId: 123,
|
||||
teamId: 456,
|
||||
userRole: MembershipRole.ADMIN,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for owner users", async () => {
|
||||
const result = await checkPBACImpersonationPermission({
|
||||
userId: 123,
|
||||
teamId: 456,
|
||||
userRole: MembershipRole.OWNER,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for member users", async () => {
|
||||
const result = await checkPBACImpersonationPermission({
|
||||
userId: 123,
|
||||
teamId: 456,
|
||||
userRole: MembershipRole.MEMBER,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle organization context", async () => {
|
||||
const result = await checkPBACImpersonationPermission({
|
||||
userId: 123,
|
||||
organizationId: 789,
|
||||
userRole: MembershipRole.ADMIN,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,16 @@ import { z } from "zod";
|
||||
|
||||
import { ensureOrganizationIsReviewed } from "@calcom/ee/organizations/lib/ensureOrganizationIsReviewed";
|
||||
import { getSession } from "@calcom/features/auth/lib/getSession";
|
||||
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import type { Membership } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { OrgProfile, PersonalProfile, UserAsPersonalProfile } from "@calcom/types/UserProfile";
|
||||
|
||||
import { Resource, CustomAction } from "../../../pbac/domain/types/permission-registry";
|
||||
|
||||
const teamIdschema = z.object({
|
||||
teamId: z.preprocess((a) => parseInt(z.string().parse(a), 10), z.number().positive()),
|
||||
});
|
||||
@@ -113,6 +117,69 @@ export function checkGlobalPermission(session: Session | null) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check PBAC permissions for impersonation
|
||||
* This function integrates with the new PBAC system to determine impersonation permissions
|
||||
*/
|
||||
export async function checkPBACImpersonationPermission({
|
||||
userId,
|
||||
teamId,
|
||||
userRole,
|
||||
organizationId,
|
||||
}: {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
userRole: MembershipRole;
|
||||
organizationId?: number | null;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
// For organization-level impersonation
|
||||
if (organizationId) {
|
||||
const orgPermissions = await getSpecificPermissions({
|
||||
userId,
|
||||
teamId: organizationId,
|
||||
resource: Resource.Organization,
|
||||
userRole,
|
||||
actions: [CustomAction.Impersonate],
|
||||
fallbackRoles: {
|
||||
[CustomAction.Impersonate]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (orgPermissions[CustomAction.Impersonate]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For team-level impersonation
|
||||
if (teamId) {
|
||||
const teamPermissions = await getSpecificPermissions({
|
||||
userId,
|
||||
teamId,
|
||||
resource: Resource.Team,
|
||||
userRole,
|
||||
actions: [CustomAction.Impersonate],
|
||||
fallbackRoles: {
|
||||
[CustomAction.Impersonate]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return teamPermissions[CustomAction.Impersonate] ?? false;
|
||||
}
|
||||
|
||||
// Fallback to role-based check if no team/org context
|
||||
return userRole === MembershipRole.ADMIN || userRole === MembershipRole.OWNER;
|
||||
} catch (error) {
|
||||
console.error("Error checking PBAC impersonation permission:", error);
|
||||
// Fallback to role-based check on error
|
||||
return userRole === MembershipRole.ADMIN || userRole === MembershipRole.OWNER;
|
||||
}
|
||||
}
|
||||
|
||||
async function getImpersonatedUser({
|
||||
session,
|
||||
teamId,
|
||||
@@ -295,11 +362,6 @@ const ImpersonationProvider = CredentialsProvider({
|
||||
teams: {
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
role: {
|
||||
in: ["ADMIN", "OWNER"],
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
id: teamId,
|
||||
@@ -318,6 +380,19 @@ const ImpersonationProvider = CredentialsProvider({
|
||||
throw new Error("Error-UserHasNoTeams: You do not have permission to do this.");
|
||||
}
|
||||
|
||||
// Check PBAC permissions for impersonation
|
||||
const hasImpersonationPermission = await checkPBACImpersonationPermission({
|
||||
userId: session?.user.id as number,
|
||||
teamId,
|
||||
userRole: sessionUserFromDb?.teams[0].role as MembershipRole,
|
||||
organizationId: session?.user.org?.id,
|
||||
});
|
||||
|
||||
if (!hasImpersonationPermission) {
|
||||
throw new Error("You do not have permission to impersonate this user.");
|
||||
}
|
||||
|
||||
// Legacy role check as additional safeguard (PBAC should handle this but keeping for backwards compatibility)
|
||||
// We find team by ID so we know there is only one team in the array
|
||||
if (sessionUserFromDb?.teams[0].role === "ADMIN" && impersonatedUser.teams[0].role === "OWNER") {
|
||||
throw new Error("You do not have permission to do this.");
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useQueryState, parseAsBoolean } from "nuqs";
|
||||
import { useMemo, useReducer, useRef, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import {
|
||||
DataTableProvider,
|
||||
@@ -30,10 +29,10 @@ import {
|
||||
} from "@calcom/features/data-table";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { DynamicLink } from "@calcom/features/users/components/UserTable/BulkActions/DynamicLink";
|
||||
import type { MemberPermissions } from "@calcom/features/users/components/UserTable/types";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
@@ -163,6 +162,7 @@ interface Props {
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
permissions: MemberPermissions;
|
||||
}
|
||||
|
||||
export default function MemberList(props: Props) {
|
||||
@@ -292,8 +292,6 @@ function MemberListContent(props: Props) {
|
||||
// return owners.length;
|
||||
// };
|
||||
|
||||
const isAdminOrOwner = checkAdminOrOwner(props.team.membership.role);
|
||||
|
||||
const removeMember = () =>
|
||||
removeMemberMutation.mutate({
|
||||
teamIds: [props.team?.id],
|
||||
@@ -429,17 +427,19 @@ function MemberListContent(props: Props) {
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
const isSelf = user.id === session?.user.id;
|
||||
// TODO(SEAN) In a follow up can we rename canChangeMemberRole to canEditMembers - role is a bit specific.
|
||||
const canChangeRole = props.permissions?.canChangeMemberRole ?? false;
|
||||
const canRemove = props.permissions?.canRemove ?? false;
|
||||
const canImpersonate = props.permissions?.canImpersonate ?? false;
|
||||
const canResendInvitation = props.permissions?.canInvite ?? false;
|
||||
const editMode =
|
||||
(props.team.membership?.role === MembershipRole.OWNER &&
|
||||
(user.role !== MembershipRole.OWNER || !isSelf)) ||
|
||||
(props.team.membership?.role === MembershipRole.ADMIN && user.role !== MembershipRole.OWNER) ||
|
||||
props.isOrgAdminOrOwner;
|
||||
[canChangeRole, canRemove, canImpersonate, canResendInvitation].some(Boolean) && !isSelf;
|
||||
|
||||
const impersonationMode =
|
||||
editMode &&
|
||||
canImpersonate &&
|
||||
!user.disableImpersonation &&
|
||||
user.accepted &&
|
||||
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true";
|
||||
const resendInvitation = editMode && !user.accepted;
|
||||
return (
|
||||
<>
|
||||
{props.team.membership?.accepted && (
|
||||
@@ -495,22 +495,24 @@ function MemberListContent(props: Props) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "EDIT_USER_SHEET",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{canChangeRole ? (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "EDIT_USER_SHEET",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{impersonationMode && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
@@ -532,7 +534,7 @@ function MemberListContent(props: Props) {
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{resendInvitation && (
|
||||
{canResendInvitation && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
@@ -548,23 +550,25 @@ function MemberListContent(props: Props) {
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_DELETE_ID",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
color="destructive"
|
||||
StartIcon="user-x">
|
||||
{t("remove")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{canRemove ? (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "SET_DELETE_ID",
|
||||
payload: {
|
||||
user,
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
color="destructive"
|
||||
StartIcon="user-x">
|
||||
{t("remove")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
@@ -713,7 +717,7 @@ function MemberListContent(props: Props) {
|
||||
ToolbarRight={
|
||||
<>
|
||||
<DataTableFilters.ClearFiltersButton />
|
||||
{isAdminOrOwner && (
|
||||
{props.permissions.canInvite && (
|
||||
<DataTableToolbar.CTA
|
||||
type="button"
|
||||
color="primary"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import generateIcsString from "@calcom/emails/lib/generateIcsString";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { SENDER_NAME } from "@calcom/lib/constants";
|
||||
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
@@ -352,7 +353,9 @@ export async function handler(req: NextRequest) {
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
sender: reminder.workflowStep.sender,
|
||||
sender: reminder.booking?.eventType?.hideOrganizerEmail
|
||||
? SENDER_NAME
|
||||
: reminder.workflowStep.sender,
|
||||
...(!reminder.booking?.eventType?.hideOrganizerEmail && {
|
||||
replyTo:
|
||||
reminder.booking?.eventType?.customReplyToEmail ??
|
||||
@@ -441,7 +444,9 @@ export async function handler(req: NextRequest) {
|
||||
subject: emailContent.emailSubject,
|
||||
to: [sendTo],
|
||||
html: emailContent.emailBody,
|
||||
sender: reminder.workflowStep?.sender,
|
||||
sender: reminder.booking?.eventType?.hideOrganizerEmail
|
||||
? SENDER_NAME
|
||||
: reminder.workflowStep?.sender,
|
||||
...(!reminder.booking?.eventType?.hideOrganizerEmail && {
|
||||
replyTo:
|
||||
reminder.booking?.eventType?.customReplyToEmail ||
|
||||
|
||||
@@ -6,7 +6,7 @@ import generateIcsString from "@calcom/emails/lib/generateIcsString";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { preprocessNameFieldDataWithVariant } from "@calcom/features/form-builder/utils";
|
||||
import tasker from "@calcom/features/tasker";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { SENDER_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -270,7 +270,7 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
|
||||
html: emailContent.emailBody,
|
||||
...(!evt.hideOrganizerEmail && { replyTo: evt?.eventType?.customReplyToEmail || evt.organizer.email }),
|
||||
attachments,
|
||||
sender,
|
||||
sender: evt.hideOrganizerEmail ? SENDER_NAME : sender,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import { Fragment, useMemo, type ReactNode } from "react";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { PanelCard } from "@calcom/ui/components/card";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
type PanelCardProps = React.ComponentProps<typeof PanelCard>;
|
||||
|
||||
type LegendItem = {
|
||||
label: string;
|
||||
color: string; // hex format
|
||||
@@ -14,35 +16,36 @@ type LegendItem = {
|
||||
export type LegendSize = "sm" | "default";
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
subtitle,
|
||||
cta,
|
||||
legend,
|
||||
legendSize,
|
||||
children,
|
||||
className,
|
||||
titleTooltip,
|
||||
}: {
|
||||
title: string | ReactNode;
|
||||
subtitle?: string;
|
||||
cta?: { label: string; onClick: () => void };
|
||||
enabledLegend,
|
||||
onSeriesToggle,
|
||||
...panelCardProps
|
||||
}: PanelCardProps & {
|
||||
legend?: Array<LegendItem>;
|
||||
legendSize?: LegendSize;
|
||||
className?: string;
|
||||
titleTooltip?: string;
|
||||
children: ReactNode;
|
||||
enabledLegend?: Array<LegendItem>;
|
||||
onSeriesToggle?: (label: string) => void;
|
||||
}) {
|
||||
const legendComponent = legend && legend.length > 0 ? <Legend items={legend} size={legendSize} /> : null;
|
||||
const legendComponent =
|
||||
legend && legend.length > 0 ? (
|
||||
<Legend items={legend} size={legendSize} enabledItems={enabledLegend} onItemToggle={onSeriesToggle} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
cta={cta}
|
||||
headerContent={legendComponent}
|
||||
className={className}
|
||||
titleTooltip={titleTooltip}>
|
||||
{children}
|
||||
{...panelCardProps}
|
||||
headerContent={
|
||||
panelCardProps.headerContent ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{panelCardProps.headerContent}
|
||||
{legendComponent}
|
||||
</div>
|
||||
) : (
|
||||
legendComponent
|
||||
)
|
||||
}>
|
||||
{panelCardProps.children}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
@@ -68,28 +71,54 @@ export function ChartCardItem({
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ items, size = "default" }: { items: LegendItem[]; size?: LegendSize }) {
|
||||
function Legend({
|
||||
items,
|
||||
size = "default",
|
||||
enabledItems,
|
||||
onItemToggle,
|
||||
}: {
|
||||
items: LegendItem[];
|
||||
size?: LegendSize;
|
||||
enabledItems?: LegendItem[];
|
||||
onItemToggle?: (label: string) => void;
|
||||
}) {
|
||||
const enabledSet = useMemo(() => new Set((enabledItems ?? []).map((i) => i.label)), [enabledItems]);
|
||||
const isClickable = Boolean(onItemToggle);
|
||||
|
||||
return (
|
||||
<div className="bg-default flex items-center gap-2 rounded-lg px-1.5 py-1">
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.label}>
|
||||
<div
|
||||
className="relative flex items-center gap-2 rounded-md px-1.5 py-0.5"
|
||||
style={{ backgroundColor: `${item.color}33` }}>
|
||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<Tooltip content={item.label}>
|
||||
<p
|
||||
className={classNames(
|
||||
"text-default truncate py-0.5 text-sm font-medium leading-none",
|
||||
size === "sm" ? "w-16" : ""
|
||||
)}>
|
||||
{item.label}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{index < items.length - 1 && <div className="bg-muted h-5 w-[1px]" />}
|
||||
</Fragment>
|
||||
))}
|
||||
{items.map((item, index) => {
|
||||
const isEnabled = enabledItems ? enabledSet.has(item.label) : true;
|
||||
|
||||
return (
|
||||
<Fragment key={item.label}>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
"relative flex items-center gap-2 rounded-md px-1.5 py-0.5 transition-opacity",
|
||||
isClickable && "cursor-pointer hover:bg-gray-100",
|
||||
!isEnabled && "opacity-25"
|
||||
)}
|
||||
style={{ backgroundColor: `${item.color}33` }}
|
||||
aria-pressed={isClickable ? isEnabled : undefined}
|
||||
aria-label={`Toggle ${item.label}`}
|
||||
disabled={!isClickable}
|
||||
onClick={isClickable ? () => onItemToggle?.(item.label) : undefined}>
|
||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<Tooltip content={item.label}>
|
||||
<span
|
||||
className={classNames(
|
||||
"text-default truncate py-0.5 text-sm font-medium leading-none",
|
||||
size === "sm" ? "w-16" : ""
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</button>
|
||||
{index < items.length - 1 && <div className="bg-muted h-5 w-[1px]" />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { useToggleableLegend } from "../../hooks/useToggleableLegend";
|
||||
import { valueFormatter } from "../../lib/valueFormatter";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
@@ -66,6 +67,7 @@ const CustomTooltip = ({
|
||||
export const EventTrendsChart = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
const { enabledLegend, toggleSeries } = useToggleableLegend(legend);
|
||||
|
||||
const {
|
||||
data: eventTrends,
|
||||
@@ -84,7 +86,11 @@ export const EventTrendsChart = () => {
|
||||
if (!isSuccess) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("event_trends")} legend={legend}>
|
||||
<ChartCard
|
||||
title={t("event_trends")}
|
||||
legend={legend}
|
||||
enabledLegend={enabledLegend}
|
||||
onSeriesToggle={toggleSeries}>
|
||||
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={eventTrends ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
|
||||
@@ -98,7 +104,7 @@ export const EventTrendsChart = () => {
|
||||
tickFormatter={valueFormatter}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{legend.map((item) => (
|
||||
{enabledLegend.map((item) => (
|
||||
<Line
|
||||
key={item.label}
|
||||
type="linear"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/us
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useToggleableLegend } from "../../hooks/useToggleableLegend";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { RoutingFunnelContent, legend } from "./RoutingFunnelContent";
|
||||
import { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton";
|
||||
@@ -11,6 +12,7 @@ import { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton";
|
||||
export function RoutingFunnel() {
|
||||
const { t } = useLocale();
|
||||
const insightsRoutingParams = useInsightsRoutingParameters();
|
||||
const { enabledLegend, toggleSeries } = useToggleableLegend(legend);
|
||||
const { data, isSuccess, isLoading } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
|
||||
insightsRoutingParams,
|
||||
{
|
||||
@@ -23,15 +25,23 @@ export function RoutingFunnel() {
|
||||
|
||||
if (isLoading || !isSuccess || !data) {
|
||||
return (
|
||||
<ChartCard title={t("routing_funnel")} legend={legend}>
|
||||
<ChartCard
|
||||
title={t("routing_funnel")}
|
||||
legend={legend}
|
||||
enabledLegend={enabledLegend}
|
||||
onSeriesToggle={toggleSeries}>
|
||||
<RoutingFunnelSkeleton />
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard title={t("routing_funnel")} legend={legend}>
|
||||
<RoutingFunnelContent data={data} />
|
||||
<ChartCard
|
||||
title={t("routing_funnel")}
|
||||
legend={legend}
|
||||
enabledLegend={enabledLegend}
|
||||
onSeriesToggle={toggleSeries}>
|
||||
<RoutingFunnelContent data={data} enabledLegend={enabledLegend} />
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface RoutingFunnelData {
|
||||
|
||||
interface RoutingFunnelContentProps {
|
||||
data: RoutingFunnelData[];
|
||||
enabledLegend?: Array<{ label: string; color: string }>;
|
||||
}
|
||||
|
||||
const COLOR = {
|
||||
@@ -28,8 +29,9 @@ export const legend = [
|
||||
{ label: "Accepted Bookings", color: COLOR.ACCEPTED },
|
||||
];
|
||||
|
||||
export function RoutingFunnelContent({ data }: RoutingFunnelContentProps) {
|
||||
export function RoutingFunnelContent({ data, enabledLegend }: RoutingFunnelContentProps) {
|
||||
const { t } = useLocale();
|
||||
const activeAreas = enabledLegend || legend;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
@@ -38,30 +40,36 @@ export function RoutingFunnelContent({ data }: RoutingFunnelContentProps) {
|
||||
<XAxis dataKey="name" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis allowDecimals={false} className="text-xs opacity-50" axisLine={false} tickLine={false} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="linear"
|
||||
name={t("routing_funnel_total_submissions")}
|
||||
dataKey="totalSubmissions"
|
||||
stroke={COLOR.TOTAL}
|
||||
fill={COLOR.TOTAL}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
<Area
|
||||
type="linear"
|
||||
name={t("routing_funnel_successful_routings")}
|
||||
dataKey="successfulRoutings"
|
||||
stroke={COLOR.SUCCESFUL}
|
||||
fill={COLOR.SUCCESFUL}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
<Area
|
||||
type="linear"
|
||||
name={t("routing_funnel_accepted_bookings")}
|
||||
dataKey="acceptedBookings"
|
||||
stroke={COLOR.ACCEPTED}
|
||||
fill={COLOR.ACCEPTED}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
{activeAreas.some((area) => area.label === "Total Submissions") && (
|
||||
<Area
|
||||
type="linear"
|
||||
name={t("routing_funnel_total_submissions")}
|
||||
dataKey="totalSubmissions"
|
||||
stroke={COLOR.TOTAL}
|
||||
fill={COLOR.TOTAL}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
)}
|
||||
{activeAreas.some((area) => area.label === "Successful Routings") && (
|
||||
<Area
|
||||
type="linear"
|
||||
name={t("routing_funnel_successful_routings")}
|
||||
dataKey="successfulRoutings"
|
||||
stroke={COLOR.SUCCESFUL}
|
||||
fill={COLOR.SUCCESFUL}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
)}
|
||||
{activeAreas.some((area) => area.label === "Accepted Bookings") && (
|
||||
<Area
|
||||
type="linear"
|
||||
name={t("routing_funnel_accepted_bookings")}
|
||||
dataKey="acceptedBookings"
|
||||
stroke={COLOR.ACCEPTED}
|
||||
fill={COLOR.ACCEPTED}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
|
||||
export const useToggleableLegend = <T extends { label: string }>(legend: T[], initialEnabled?: string[]) => {
|
||||
const [enabledSeries, setEnabledSeries] = useState<string[]>(
|
||||
initialEnabled ?? legend.map((item) => item.label)
|
||||
);
|
||||
|
||||
const enabledLegend = useMemo(
|
||||
() => legend.filter((item) => enabledSeries.includes(item.label)),
|
||||
[legend, enabledSeries]
|
||||
);
|
||||
|
||||
const toggleSeries = useCallback(
|
||||
(label: string) => {
|
||||
setEnabledSeries((prev) => (prev.includes(label) ? prev.filter((s) => s !== label) : [...prev, label]));
|
||||
},
|
||||
[setEnabledSeries]
|
||||
);
|
||||
|
||||
return {
|
||||
enabledLegend,
|
||||
toggleSeries,
|
||||
};
|
||||
};
|
||||
@@ -5,11 +5,16 @@ import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type {
|
||||
CreateInstantBookingData,
|
||||
CreateInstantBookingResponse,
|
||||
} from "@calcom/features/bookings/lib/dto/types";
|
||||
import getBookingDataSchema from "@calcom/features/bookings/lib/getBookingDataSchema";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { getBookingData } from "@calcom/features/bookings/lib/handleNewBooking/getBookingData";
|
||||
import { getCustomInputsResponses } from "@calcom/features/bookings/lib/handleNewBooking/getCustomInputsResponses";
|
||||
import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB";
|
||||
import type { IBookingCreateService } from "@calcom/features/bookings/lib/interfaces/IBookingCreateService";
|
||||
import { getFullName } from "@calcom/features/form-builder/utils";
|
||||
import { sendNotification } from "@calcom/features/notifications/sendNotification";
|
||||
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
@@ -151,17 +156,8 @@ const triggerBrowserNotifications = async (args: {
|
||||
await Promise.allSettled(promises);
|
||||
};
|
||||
|
||||
export type HandleInstantMeetingResponse = {
|
||||
message: string;
|
||||
meetingTokenId: number;
|
||||
bookingId: number;
|
||||
bookingUid: string;
|
||||
expires: Date;
|
||||
userId: number | null;
|
||||
};
|
||||
|
||||
async function handler(req: NextApiRequest) {
|
||||
let eventType = await getEventTypesFromDB(req.body.eventTypeId);
|
||||
async function _handler(bookingData: CreateInstantBookingData) {
|
||||
let eventType = await getEventTypesFromDB(bookingData.eventTypeId);
|
||||
const isOrgTeamEvent = !!eventType?.team && !!eventType?.team?.parentId;
|
||||
eventType = {
|
||||
...eventType,
|
||||
@@ -173,11 +169,11 @@ async function handler(req: NextApiRequest) {
|
||||
}
|
||||
|
||||
const schema = getBookingDataSchema({
|
||||
view: req.body?.rescheduleUid ? "reschedule" : "booking",
|
||||
view: bookingData?.rescheduleUid ? "reschedule" : "booking",
|
||||
bookingFields: eventType.bookingFields,
|
||||
});
|
||||
const reqBody = await getBookingData({
|
||||
reqBody: req.body,
|
||||
reqBody: bookingData,
|
||||
eventType,
|
||||
schema,
|
||||
});
|
||||
@@ -257,7 +253,7 @@ async function handler(req: NextApiRequest) {
|
||||
data: attendeesList,
|
||||
},
|
||||
},
|
||||
creationSource: req.body.creationSource,
|
||||
creationSource: bookingData.creationSource,
|
||||
};
|
||||
|
||||
const createBookingObj = {
|
||||
@@ -275,7 +271,7 @@ async function handler(req: NextApiRequest) {
|
||||
|
||||
const eventTypeWithExpiryTimeOffset = await prisma.eventType.findUniqueOrThrow({
|
||||
where: {
|
||||
id: req.body.eventTypeId,
|
||||
id: bookingData.eventTypeId,
|
||||
},
|
||||
select: {
|
||||
instantMeetingExpiryTimeOffsetInSeconds: true,
|
||||
@@ -334,7 +330,21 @@ async function handler(req: NextApiRequest) {
|
||||
bookingUid: newBooking.uid,
|
||||
expires: instantMeetingToken.expires,
|
||||
userId: newBooking.userId,
|
||||
} satisfies HandleInstantMeetingResponse;
|
||||
} satisfies CreateInstantBookingResponse;
|
||||
}
|
||||
|
||||
export default handler;
|
||||
/**
|
||||
* Instant booking service that handles instant/immediate bookings
|
||||
*/
|
||||
export class InstantBookingCreateService implements IBookingCreateService {
|
||||
async createBooking(input: {
|
||||
bookingData: CreateInstantBookingData;
|
||||
}): Promise<CreateInstantBookingResponse> {
|
||||
return _handler(input.bookingData);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Remove it in a follow-up PR
|
||||
export default async function handler(req: NextApiRequest) {
|
||||
return _handler(req.body);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export enum CustomAction {
|
||||
ReadTeamBookings = "readTeamBookings",
|
||||
ReadOrgBookings = "readOrgBookings",
|
||||
ReadRecordings = "readRecordings",
|
||||
Impersonate = "impersonate",
|
||||
}
|
||||
|
||||
export enum Scope {
|
||||
@@ -263,21 +264,34 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
|
||||
category: "team",
|
||||
i18nKey: "pbac_action_invite",
|
||||
descriptionI18nKey: "pbac_desc_invite_team_members",
|
||||
dependsOn: ["team.read"],
|
||||
dependsOn: ["team.read", "team.listMembers"],
|
||||
},
|
||||
[CustomAction.Remove]: {
|
||||
description: "Remove team members",
|
||||
category: "team",
|
||||
i18nKey: "pbac_action_remove",
|
||||
descriptionI18nKey: "pbac_desc_remove_team_members",
|
||||
dependsOn: ["team.read"],
|
||||
dependsOn: ["team.read", "team.listMembers"],
|
||||
},
|
||||
[CustomAction.ListMembers]: {
|
||||
description: "List team members",
|
||||
category: "team",
|
||||
i18nKey: "pbac_action_list_members",
|
||||
descriptionI18nKey: "pbac_desc_list_team_members",
|
||||
},
|
||||
[CustomAction.ChangeMemberRole]: {
|
||||
description: "Change role of team members",
|
||||
category: "team",
|
||||
i18nKey: "pbac_action_change_member_role",
|
||||
descriptionI18nKey: "pbac_desc_change_team_member_role",
|
||||
dependsOn: ["team.read"],
|
||||
dependsOn: ["team.read", "team.listMembers"],
|
||||
},
|
||||
[CustomAction.Impersonate]: {
|
||||
description: "Impersonate team members",
|
||||
category: "team",
|
||||
i18nKey: "pbac_action_impersonate",
|
||||
descriptionI18nKey: "pbac_desc_impersonate_team_members",
|
||||
dependsOn: ["team.read", "team.listMembers"],
|
||||
},
|
||||
},
|
||||
[Resource.Organization]: {
|
||||
@@ -338,6 +352,13 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
|
||||
scope: [Scope.Organization],
|
||||
dependsOn: ["organization.listMembers", "role.read"],
|
||||
},
|
||||
[CustomAction.Impersonate]: {
|
||||
description: "Impersonate organization members",
|
||||
category: "org",
|
||||
i18nKey: "pbac_action_impersonate",
|
||||
descriptionI18nKey: "pbac_desc_impersonate_organization_members",
|
||||
scope: [Scope.Organization],
|
||||
},
|
||||
[CrudAction.Update]: {
|
||||
description: "Edit organization settings",
|
||||
category: "org",
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { PermissionMapper } from "../domain/mappers/PermissionMapper";
|
||||
import type { Resource } from "../domain/types/permission-registry";
|
||||
import { CrudAction } from "../domain/types/permission-registry";
|
||||
import { CrudAction, CustomAction } from "../domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "../services/permission-check.service";
|
||||
|
||||
interface RoleMapping {
|
||||
@@ -85,3 +85,58 @@ export const getResourcePermissions = async ({
|
||||
canCreate: roleActions[CrudAction.Create] ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
// Enhanced function to get specific custom action permissions
|
||||
type ActionType = CrudAction | CustomAction;
|
||||
|
||||
interface SpecificActionMapping {
|
||||
[key: string]: RoleMapping;
|
||||
}
|
||||
|
||||
interface SpecificPermissionsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
resource: Resource;
|
||||
userRole: MembershipRole;
|
||||
actions: ActionType[];
|
||||
fallbackRoles?: SpecificActionMapping;
|
||||
}
|
||||
|
||||
export const getSpecificPermissions = async ({
|
||||
userId,
|
||||
teamId,
|
||||
resource,
|
||||
userRole,
|
||||
actions,
|
||||
fallbackRoles = {},
|
||||
}: SpecificPermissionsOptions): Promise<Record<string, boolean>> => {
|
||||
const featureRepo = new FeaturesRepository(prisma);
|
||||
const permissionService = new PermissionCheckService();
|
||||
|
||||
const pbacEnabled = await featureRepo.checkIfTeamHasFeature(teamId, "pbac");
|
||||
|
||||
// If PBAC is disabled, use fallback role configuration
|
||||
if (!pbacEnabled) {
|
||||
const permissions: Record<string, boolean> = {};
|
||||
for (const action of actions) {
|
||||
permissions[action] = checkRoleAccess(userRole, fallbackRoles[action]);
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
// PBAC is enabled, get permissions from the service
|
||||
const resourcePermissions = await permissionService.getResourcePermissions({
|
||||
userId,
|
||||
teamId,
|
||||
resource,
|
||||
});
|
||||
|
||||
const roleActions = PermissionMapper.toActionMap(resourcePermissions, resource);
|
||||
|
||||
const permissions: Record<string, boolean> = {};
|
||||
for (const action of actions) {
|
||||
permissions[action] = roleActions[action] ?? false;
|
||||
}
|
||||
|
||||
return permissions;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { usePathname } from "next/navigation";
|
||||
import React, { Fragment, useState, useEffect } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import type { IconName } from "@calcom/ui/components/icon";
|
||||
@@ -74,6 +75,9 @@ export const NavigationItem: React.FC<{
|
||||
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item);
|
||||
const [isExpanded, setIsExpanded] = usePersistedExpansionState(item.name);
|
||||
|
||||
const isTablet = useMediaQuery("(max-width: 1024px)");
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
|
||||
if (!shouldDisplayNavigationItem) return null;
|
||||
|
||||
const hasChildren = item.child && item.child.length > 0;
|
||||
@@ -86,13 +90,55 @@ export const NavigationItem: React.FC<{
|
||||
return (
|
||||
<Fragment>
|
||||
{isParentNavigationItem ? (
|
||||
<Tooltip side="right" content={t(item.name)} className="lg:hidden">
|
||||
<Tooltip
|
||||
side="right"
|
||||
open={isTooltipOpen}
|
||||
content={
|
||||
hasChildren ? (
|
||||
<div className="pointer-events-auto flex flex-col space-y-1 p-1">
|
||||
<span className="text-subtle px-2 text-xs font-semibold uppercase tracking-wide">
|
||||
{t(item.name)}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
{item.child?.map((childItem) => {
|
||||
const childIsCurrent =
|
||||
typeof childItem.isCurrent === "function"
|
||||
? childItem.isCurrent({ isChild: true, item: childItem, pathname })
|
||||
: defaultIsCurrent({ isChild: true, item: childItem, pathname });
|
||||
return (
|
||||
<Link
|
||||
key={childItem.name}
|
||||
href={childItem.href}
|
||||
aria-current={childIsCurrent ? "page" : undefined}
|
||||
onClick={() => setIsTooltipOpen(false)}
|
||||
className={classNames(
|
||||
"group relative block rounded-md px-3 py-1 text-sm font-medium",
|
||||
childIsCurrent
|
||||
? "bg-emphasis text-white"
|
||||
: "hover:bg-emphasis text-mute hover:text-emphasis"
|
||||
)}>
|
||||
{t(childItem.name)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
t(item.name)
|
||||
)
|
||||
}
|
||||
className="lg:hidden">
|
||||
<button
|
||||
data-test-id={item.name}
|
||||
aria-label={t(item.name)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-current={current ? "page" : undefined}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onClick={() => {
|
||||
setIsExpanded(!isExpanded);
|
||||
if (isTablet && hasChildren) {
|
||||
setIsTooltipOpen(!isTooltipOpen);
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
"todesktop:py-[7px] text-default group flex w-full items-center rounded-md px-2 py-1.5 text-sm font-medium transition",
|
||||
"[&[aria-current='page']]:!bg-transparent",
|
||||
|
||||
@@ -56,11 +56,19 @@ export function UserDropdown({ small }: UserDropdownProps) {
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const handleHelpClick = () => {
|
||||
if (window.Plain) {
|
||||
window.Plain.open();
|
||||
}
|
||||
const handleHelpClick = (e?: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
|
||||
setMenuOpen(false);
|
||||
|
||||
// Defer to next frame to avoid the originating click closing the dialog
|
||||
requestAnimationFrame(() => {
|
||||
// double RAF is extra-safe if your dropdown unmounts with a transition
|
||||
requestAnimationFrame(() => {
|
||||
if (window.Plain) window.Plain.open();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Prevent rendering dropdown if user isn't available.
|
||||
|
||||
@@ -13,20 +13,31 @@ export function CellHighlightContainer({ children }: { children: React.ReactNode
|
||||
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const componentContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { x, y, height, isHover, updateDimensions, setContainerRef } = useStore(store, (state) => state);
|
||||
const { x, y, height, isHover, updateDimensions, setContainerRef, browsingDate, emitCellPosition } =
|
||||
useStore(store, (state) => state);
|
||||
const prevBrowsingDateRef = useRef(browsingDate);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
if (isHover) {
|
||||
setIsAnimating(true);
|
||||
} else {
|
||||
timeout = setTimeout(() => setIsAnimating(false), 1000);
|
||||
timeout = setTimeout(() => setIsAnimating(false), 300);
|
||||
}
|
||||
return () => {
|
||||
timeout && clearTimeout(timeout);
|
||||
};
|
||||
}, [isHover]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevBrowsingDateRef.current?.getTime() !== browsingDate.getTime()) {
|
||||
setIsAnimating(false);
|
||||
// Clear hover state and position when date changes
|
||||
emitCellPosition(-1);
|
||||
prevBrowsingDateRef.current = browsingDate;
|
||||
}
|
||||
}, [browsingDate, emitCellPosition]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const handleUpdate = () => {
|
||||
updateDimensions();
|
||||
@@ -50,13 +61,16 @@ export function CellHighlightContainer({ children }: { children: React.ReactNode
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div
|
||||
className="relative -mx-2 w-[calc(100%+16px)] overflow-x-scroll px-2 lg:-mx-6 lg:w-[calc(100%+48px)] lg:px-6"
|
||||
className="relative -mx-2 w-[calc(100%+16px)] overflow-hidden overflow-x-scroll px-2 lg:-mx-6 lg:w-[calc(100%+48px)] lg:px-6"
|
||||
ref={componentContainerRef}>
|
||||
{children}
|
||||
|
||||
<m.div
|
||||
className="border-subtle opcaity-0 pointer-events-none absolute left-0 top-0 h-full rounded-lg border-2"
|
||||
animate={{ x: [null, x], opacity: isAnimating || isHover ? 1 : 0.1 }}
|
||||
className="border-subtle pointer-events-none absolute left-0 top-0 h-full rounded-lg border-2 opacity-0"
|
||||
animate={{
|
||||
x: [null, x],
|
||||
opacity: isAnimating || isHover ? 1 : 0,
|
||||
}}
|
||||
style={{ y, height, width: DAY_CELL_WIDTH }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
|
||||
@@ -50,7 +50,7 @@ import { EditUserSheet } from "./EditSheet/EditUserSheet";
|
||||
import { ImpersonationMemberModal } from "./ImpersonationMemberModal";
|
||||
import { InviteMemberModal } from "./InviteMemberModal";
|
||||
import { TableActions } from "./UserTableActions";
|
||||
import type { UserTableState, UserTableAction, UserTableUser } from "./types";
|
||||
import type { UserTableState, UserTableAction, UserTableUser, MemberPermissions } from "./types";
|
||||
|
||||
const initialState: UserTableState = {
|
||||
changeMemberRole: {
|
||||
@@ -122,6 +122,7 @@ export type UserListTableProps = {
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
permissions?: MemberPermissions;
|
||||
};
|
||||
|
||||
export function UserListTable(props: UserListTableProps) {
|
||||
@@ -132,7 +133,13 @@ export function UserListTable(props: UserListTableProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function UserListTableContent({ org, attributes, teams, facetedTeamValues }: UserListTableProps) {
|
||||
function UserListTableContent({
|
||||
org,
|
||||
attributes,
|
||||
teams,
|
||||
facetedTeamValues,
|
||||
permissions,
|
||||
}: UserListTableProps) {
|
||||
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
|
||||
const orgBranding = useOrgBranding();
|
||||
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
|
||||
@@ -169,11 +176,12 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
|
||||
const flatData = useMemo<UserTableUser[]>(() => data?.rows ?? [], [data]);
|
||||
|
||||
const memorisedColumns = useMemo(() => {
|
||||
const permissions = {
|
||||
canEdit: adminOrOwner,
|
||||
canRemove: adminOrOwner,
|
||||
canResendInvitation: adminOrOwner,
|
||||
canImpersonate: false,
|
||||
// Use PBAC permissions if available, otherwise fall back to role-based check
|
||||
const tablePermissions = {
|
||||
canEdit: permissions?.canChangeMemberRole ?? adminOrOwner,
|
||||
canRemove: permissions?.canRemove ?? adminOrOwner,
|
||||
canResendInvitation: permissions?.canInvite ?? adminOrOwner,
|
||||
canImpersonate: permissions?.canImpersonate ?? adminOrOwner,
|
||||
};
|
||||
const generateAttributeColumns = () => {
|
||||
if (!attributes?.length) {
|
||||
@@ -445,14 +453,18 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
|
||||
size: 80,
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
const permissionsRaw = permissions;
|
||||
const permissionsRaw = tablePermissions;
|
||||
const isSelf = user.id === session?.user.id;
|
||||
|
||||
const permissionsForUser = {
|
||||
canEdit: permissionsRaw.canEdit && user.accepted && !isSelf,
|
||||
canRemove: permissionsRaw.canRemove && !isSelf,
|
||||
canImpersonate:
|
||||
user.accepted && !user.disableImpersonation && !isSelf && !!org?.canAdminImpersonate,
|
||||
user.accepted &&
|
||||
!user.disableImpersonation &&
|
||||
!isSelf &&
|
||||
!!org?.canAdminImpersonate &&
|
||||
permissionsRaw.canImpersonate,
|
||||
canLeave: user.accepted && isSelf,
|
||||
canResendInvitation: permissionsRaw.canResendInvitation && !user.accepted,
|
||||
};
|
||||
@@ -470,7 +482,7 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
|
||||
];
|
||||
|
||||
return cols;
|
||||
}, [session?.user.id, adminOrOwner, dispatch, domain, attributes, org?.canAdminImpersonate]);
|
||||
}, [session?.user.id, adminOrOwner, dispatch, domain, attributes, org?.canAdminImpersonate, permissions]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: flatData,
|
||||
@@ -619,7 +631,7 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
|
||||
</p>
|
||||
{!isPlatformUser ? (
|
||||
<>
|
||||
{adminOrOwner && <TeamListBulkAction table={table} />}
|
||||
{permissions?.canChangeMemberRole && <TeamListBulkAction table={table} />}
|
||||
{numberOfSelectedRows >= 2 && (
|
||||
<DataTableSelectionBar.Button
|
||||
color="secondary"
|
||||
@@ -628,11 +640,15 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
|
||||
{t("group_meeting")}
|
||||
</DataTableSelectionBar.Button>
|
||||
)}
|
||||
{adminOrOwner && <MassAssignAttributesBulkAction table={table} filters={columnFilters} />}
|
||||
{adminOrOwner && <EventTypesList table={table} orgTeams={teams} />}
|
||||
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
|
||||
<MassAssignAttributesBulkAction table={table} filters={columnFilters} />
|
||||
)}
|
||||
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
|
||||
<EventTypesList table={table} orgTeams={teams} />
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{adminOrOwner && (
|
||||
{(permissions?.canRemove ?? adminOrOwner) && (
|
||||
<DeleteBulkUsers
|
||||
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
|
||||
onRemove={() => table.toggleAllPageRowsSelected(false)}
|
||||
@@ -660,7 +676,7 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
|
||||
data-testid="export-members-button">
|
||||
{t("download")}
|
||||
</DataTableToolbar.CTA>
|
||||
{adminOrOwner && (
|
||||
{(permissions?.canInvite ?? adminOrOwner) && (
|
||||
<DataTableToolbar.CTA
|
||||
type="button"
|
||||
color="primary"
|
||||
|
||||
@@ -52,3 +52,11 @@ export type PlatformManagedUserTableAction =
|
||||
| {
|
||||
type: "CLOSE_MODAL";
|
||||
};
|
||||
|
||||
export interface MemberPermissions {
|
||||
canListMembers: boolean;
|
||||
canInvite: boolean;
|
||||
canChangeMemberRole: boolean;
|
||||
canRemove: boolean;
|
||||
canImpersonate: boolean;
|
||||
}
|
||||
|
||||
@@ -51,4 +51,10 @@ export const DI_TOKENS = {
|
||||
HOST_REPOSITORY_MODULE: Symbol("HostRepositoryModule"),
|
||||
ATTRIBUTE_REPOSITORY: Symbol("AttributeRepository"),
|
||||
ATTRIBUTE_REPOSITORY_MODULE: Symbol("AttributeRepositoryModule"),
|
||||
REGULAR_BOOKING_SERVICE: Symbol("RegularBookingService"),
|
||||
REGULAR_BOOKING_SERVICE_MODULE: Symbol("RegularBookingServiceModule"),
|
||||
RECURRING_BOOKING_SERVICE: Symbol("RecurringBookingService"),
|
||||
RECURRING_BOOKING_SERVICE_MODULE: Symbol("RecurringBookingServiceModule"),
|
||||
INSTANT_BOOKING_CREATE_SERVICE: Symbol("InstantBookingCreateService"),
|
||||
INSTANT_BOOKING_CREATE_SERVICE_MODULE: Symbol("InstantBookingCreateServiceModule"),
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"ical.js": "^1.4.0",
|
||||
"ics": "^2.37.0",
|
||||
"jimp": "^0.16.1",
|
||||
"lingo.dev": "^0.111.1",
|
||||
"next-collect": "^0.2.1",
|
||||
"next-i18next": "^15.4.2",
|
||||
"rrule": "^2.7.1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LingoDotDevEngine } from "@lingo.dev/_sdk";
|
||||
import type { LocaleCode } from "@lingo.dev/_spec";
|
||||
import { LingoDotDevEngine } from "lingo.dev/sdk";
|
||||
|
||||
import { LINGO_DOT_DEV_API_KEY } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
@@ -52,6 +52,7 @@ export class LingoDotDevService {
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const result = await this.engine.batchLocalizeText(text, {
|
||||
// TODO: LocaleCode is hacky, use our locale mapping instead.
|
||||
sourceLocale: sourceLocale as LocaleCode,
|
||||
targetLocales: targetLocales as LocaleCode[],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
## 1.1.2
|
||||
|
||||
## 1.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#23445](https://github.com/calcom/cal.com/pull/23445) [`9724bc0`](https://github.com/calcom/cal.com/commit/9724bc07a6d9bfe3ff32fa4e5657de2da3068d85) Thanks [@supalarry](https://github.com/supalarry)! - feat: italian language support
|
||||
|
||||
## 1.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
+46
-27
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, Suspense } from "react";
|
||||
import { useReducer } from "react";
|
||||
|
||||
import { AppList } from "@calcom/features/apps/components/AppList";
|
||||
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton";
|
||||
@@ -22,39 +23,46 @@ export type UpdateUsersDefaultConferencingAppParams = {
|
||||
export type BulkUpdatParams = { eventTypeIds: number[]; callback: () => void };
|
||||
type RemoveAppParams = { credentialId: number; teamId?: number; callback: () => void };
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
export const SkeletonLoader = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
<SettingsHeader
|
||||
title={t("conferencing")}
|
||||
description={t("conferencing_description")}
|
||||
CTA={<AddConferencingButton />}
|
||||
borderInShellHeader={true}>
|
||||
<SkeletonContainer>
|
||||
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
const InstalledConferencingApps = ({
|
||||
type IntegrationsOutput = RouterOutputs["viewer"]["apps"]["integrations"];
|
||||
type DefaultConferencingApp = RouterOutputs["viewer"]["apps"]["getUsersDefaultConferencingApp"] | undefined;
|
||||
type EventTypesList = RouterOutputs["viewer"]["eventTypes"]["bulkEventFetch"]["eventTypes"];
|
||||
|
||||
export const InstalledConferencingApps = ({
|
||||
disconnectIntegrationModalCtrl,
|
||||
integrations,
|
||||
defaultConferencingApp,
|
||||
eventTypes,
|
||||
}: {
|
||||
disconnectIntegrationModalCtrl: ReturnType<typeof useDisconnectIntegrationModalController>;
|
||||
integrations: IntegrationsOutput;
|
||||
defaultConferencingApp: DefaultConferencingApp;
|
||||
eventTypes: EventTypesList;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: defaultConferencingApp } = trpc.viewer.apps.getUsersDefaultConferencingApp.useQuery();
|
||||
|
||||
const updateDefaultAppMutation = trpc.viewer.apps.updateUserDefaultConferencingApp.useMutation();
|
||||
|
||||
const updateLocationsMutation = trpc.viewer.eventTypes.bulkUpdateToDefaultLocation.useMutation();
|
||||
|
||||
const { data: eventTypesQueryData, isFetching: isEventTypesFetching } =
|
||||
trpc.viewer.eventTypes.bulkEventFetch.useQuery();
|
||||
|
||||
const [result] = trpc.viewer.apps.integrations.useSuspenseQuery({
|
||||
variant: "conferencing",
|
||||
onlyInstalled: true,
|
||||
});
|
||||
|
||||
const handleConnectDisconnectIntegrationMenuToggle = () => {
|
||||
utils.viewer.apps.integrations.invalidate();
|
||||
};
|
||||
@@ -99,7 +107,7 @@ const InstalledConferencingApps = ({
|
||||
);
|
||||
};
|
||||
|
||||
if (result.items.length === 0) {
|
||||
if (integrations.items.length === 0) {
|
||||
return (
|
||||
<EmptyScreen
|
||||
Icon="calendar"
|
||||
@@ -123,14 +131,14 @@ const InstalledConferencingApps = ({
|
||||
<AppList
|
||||
listClassName="rounded-lg rounded-t-none border-t-0 max-w-full"
|
||||
handleDisconnect={disconnectIntegrationModalCtrl.disconnect}
|
||||
data={result}
|
||||
data={integrations}
|
||||
variant="conferencing"
|
||||
defaultConferencingApp={defaultConferencingApp}
|
||||
handleUpdateUserDefaultConferencingApp={handleUpdateUserDefaultConferencingApp}
|
||||
handleBulkUpdateDefaultLocation={handleBulkUpdateDefaultLocation}
|
||||
isBulkUpdateDefaultLocationPending={updateDefaultAppMutation.isPending}
|
||||
eventTypes={eventTypesQueryData?.eventTypes}
|
||||
isEventTypesFetching={isEventTypesFetching}
|
||||
eventTypes={eventTypes}
|
||||
isEventTypesFetching={false}
|
||||
handleConnectDisconnectIntegrationMenuToggle={handleConnectDisconnectIntegrationMenuToggle}
|
||||
handleBulkEditDialogToggle={handleBulkEditDialogToggle}
|
||||
/>
|
||||
@@ -179,7 +187,15 @@ const AddConferencingButton = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const ConferencingAppsViewWebWrapper = () => {
|
||||
export const ConferencingAppsViewWebWrapper = ({
|
||||
integrations,
|
||||
defaultConferencingApp,
|
||||
eventTypes,
|
||||
}: {
|
||||
integrations: IntegrationsOutput;
|
||||
defaultConferencingApp: DefaultConferencingApp;
|
||||
eventTypes: EventTypesList;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
@@ -213,9 +229,12 @@ export const ConferencingAppsViewWebWrapper = () => {
|
||||
borderInShellHeader={true}>
|
||||
<>
|
||||
<div className="bg-default w-full sm:mx-0 xl:mt-0">
|
||||
<Suspense fallback={<SkeletonLoader />}>
|
||||
<InstalledConferencingApps disconnectIntegrationModalCtrl={disconnectIntegrationModalCtrl} />
|
||||
</Suspense>
|
||||
<InstalledConferencingApps
|
||||
disconnectIntegrationModalCtrl={disconnectIntegrationModalCtrl}
|
||||
integrations={integrations}
|
||||
defaultConferencingApp={defaultConferencingApp}
|
||||
eventTypes={eventTypes}
|
||||
/>
|
||||
</div>
|
||||
<DisconnectIntegrationModal
|
||||
handleModelClose={disconnectIntegrationModalCtrl.close}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"description": "Customizable UI components to integrate scheduling into your product.",
|
||||
"authors": "Cal.com, Inc.",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0",
|
||||
"scripts": {
|
||||
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
|
||||
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
-- Add team.listMembers permission to admin role
|
||||
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
|
||||
SELECT
|
||||
gen_random_uuid(), 'admin_role', resource, action, NOW()
|
||||
FROM (
|
||||
VALUES
|
||||
('team', 'listMembers')
|
||||
) AS permissions(resource, action)
|
||||
ON CONFLICT ("roleId", resource, action) DO NOTHING;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
-- Add impersonate permissions to admin role
|
||||
-- These permissions allow administrators to impersonate team and organization members
|
||||
|
||||
-- Insert impersonate permissions for admin role
|
||||
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
|
||||
SELECT
|
||||
gen_random_uuid(), 'admin_role', resource, action, NOW()
|
||||
FROM (
|
||||
VALUES
|
||||
-- Team impersonate permission
|
||||
('team', 'impersonate'),
|
||||
|
||||
-- Organization impersonate permission
|
||||
('organization', 'impersonate')
|
||||
) AS permissions(resource, action)
|
||||
ON CONFLICT ("roleId", resource, action) DO NOTHING;
|
||||
|
||||
-- Note: Owner role already has wildcard permissions (*.*) so it inherits all impersonate permissions automatically
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
chunk_size INTEGER := 1000;
|
||||
start_id INTEGER := 1; -- Starting ID
|
||||
end_id INTEGER; -- Will be set dynamically
|
||||
current_id INTEGER;
|
||||
|
||||
processed_count INTEGER := 0;
|
||||
chunk_updated_count INTEGER := 0;
|
||||
total_count INTEGER;
|
||||
BEGIN
|
||||
-- Get the maximum ID and total count from the App_RoutingForms_FormResponse table
|
||||
SELECT COALESCE(MAX(id), 0) INTO end_id FROM "App_RoutingForms_FormResponse";
|
||||
SELECT COUNT(*) INTO total_count FROM "App_RoutingForms_FormResponse";
|
||||
|
||||
-- Handle case where there are no records to process
|
||||
IF total_count = 0 THEN
|
||||
RAISE NOTICE 'No records found in App_RoutingForms_FormResponse table. Migration completed.';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Starting migration: processing up to ID % (total responses: %)', end_id, total_count;
|
||||
|
||||
FOR current_id IN SELECT * FROM generate_series(start_id, end_id, chunk_size)
|
||||
LOOP
|
||||
-- Use UPSERT to only update records that are missing or have incorrect data
|
||||
WITH expected_data AS (
|
||||
SELECT
|
||||
r.id,
|
||||
r."formId" as expected_form_id,
|
||||
f.name as expected_form_name,
|
||||
f."teamId" as expected_form_team_id,
|
||||
f."userId" as expected_form_user_id,
|
||||
b.uid as expected_booking_uid,
|
||||
b.id as expected_booking_id,
|
||||
b.status as expected_booking_status,
|
||||
calculate_booking_status_order(b.status::text) as expected_booking_status_order,
|
||||
b."createdAt" as expected_booking_created_at,
|
||||
b."startTime" as expected_booking_start_time,
|
||||
b."endTime" as expected_booking_end_time,
|
||||
b."userId" as expected_booking_user_id,
|
||||
u.name as expected_booking_user_name,
|
||||
u.email as expected_booking_user_email,
|
||||
u."avatarUrl" as expected_booking_user_avatar_url,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT ar."reasonString"
|
||||
FROM "AssignmentReason" ar
|
||||
WHERE ar."bookingId" = b.id
|
||||
LIMIT 1
|
||||
),
|
||||
''
|
||||
) as expected_booking_assignment_reason,
|
||||
et.id as expected_event_type_id,
|
||||
et."parentId" as expected_event_type_parent_id,
|
||||
et."schedulingType"::text as expected_event_type_scheduling_type,
|
||||
r."createdAt" as expected_created_at,
|
||||
t.utm_source as expected_utm_source,
|
||||
t.utm_medium as expected_utm_medium,
|
||||
t.utm_campaign as expected_utm_campaign,
|
||||
t.utm_term as expected_utm_term,
|
||||
t.utm_content as expected_utm_content
|
||||
FROM "App_RoutingForms_FormResponse" r
|
||||
INNER JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
|
||||
LEFT JOIN "Booking" b ON b.uid = r."routedToBookingUid"
|
||||
LEFT JOIN "users" u ON b."userId" = u.id
|
||||
LEFT JOIN "EventType" et ON b."eventTypeId" = et.id
|
||||
LEFT JOIN "Tracking" t ON t."bookingId" = b.id
|
||||
WHERE r.id BETWEEN current_id AND current_id + chunk_size - 1
|
||||
),
|
||||
records_to_update AS (
|
||||
SELECT e.*
|
||||
FROM expected_data e
|
||||
LEFT JOIN "RoutingFormResponseDenormalized" d ON e.id = d.id
|
||||
WHERE CASE
|
||||
WHEN d.id IS NULL THEN true -- Include missing records
|
||||
WHEN d."formId" IS DISTINCT FROM e.expected_form_id THEN true
|
||||
WHEN d."formName" IS DISTINCT FROM e.expected_form_name THEN true
|
||||
WHEN d."formTeamId" IS DISTINCT FROM e.expected_form_team_id THEN true
|
||||
WHEN d."formUserId" IS DISTINCT FROM e.expected_form_user_id THEN true
|
||||
WHEN d."bookingUid" IS DISTINCT FROM e.expected_booking_uid THEN true
|
||||
WHEN d."bookingId" IS DISTINCT FROM e.expected_booking_id THEN true
|
||||
WHEN d."bookingStatus" IS DISTINCT FROM e.expected_booking_status THEN true
|
||||
WHEN d."bookingStatusOrder" IS DISTINCT FROM e.expected_booking_status_order THEN true
|
||||
WHEN d."bookingCreatedAt" IS DISTINCT FROM e.expected_booking_created_at THEN true
|
||||
WHEN d."bookingStartTime" IS DISTINCT FROM e.expected_booking_start_time THEN true
|
||||
WHEN d."bookingEndTime" IS DISTINCT FROM e.expected_booking_end_time THEN true
|
||||
WHEN d."bookingUserId" IS DISTINCT FROM e.expected_booking_user_id THEN true
|
||||
WHEN d."bookingUserName" IS DISTINCT FROM e.expected_booking_user_name THEN true
|
||||
WHEN d."bookingUserEmail" IS DISTINCT FROM e.expected_booking_user_email THEN true
|
||||
WHEN d."bookingUserAvatarUrl" IS DISTINCT FROM e.expected_booking_user_avatar_url THEN true
|
||||
WHEN d."bookingAssignmentReason" IS DISTINCT FROM e.expected_booking_assignment_reason THEN true
|
||||
WHEN d."eventTypeId" IS DISTINCT FROM e.expected_event_type_id THEN true
|
||||
WHEN d."eventTypeParentId" IS DISTINCT FROM e.expected_event_type_parent_id THEN true
|
||||
WHEN d."eventTypeSchedulingType"::text IS DISTINCT FROM e.expected_event_type_scheduling_type THEN true
|
||||
WHEN d."createdAt" IS DISTINCT FROM e.expected_created_at THEN true
|
||||
WHEN d."utm_source" IS DISTINCT FROM e.expected_utm_source THEN true
|
||||
WHEN d."utm_medium" IS DISTINCT FROM e.expected_utm_medium THEN true
|
||||
WHEN d."utm_campaign" IS DISTINCT FROM e.expected_utm_campaign THEN true
|
||||
WHEN d."utm_term" IS DISTINCT FROM e.expected_utm_term THEN true
|
||||
WHEN d."utm_content" IS DISTINCT FROM e.expected_utm_content THEN true
|
||||
ELSE false -- Don't include valid records
|
||||
END
|
||||
)
|
||||
INSERT INTO "RoutingFormResponseDenormalized" (
|
||||
id,
|
||||
"formId",
|
||||
"formName",
|
||||
"formTeamId",
|
||||
"formUserId",
|
||||
"bookingUid",
|
||||
"bookingId",
|
||||
"bookingStatus",
|
||||
"bookingStatusOrder",
|
||||
"bookingCreatedAt",
|
||||
"bookingStartTime",
|
||||
"bookingEndTime",
|
||||
"bookingUserId",
|
||||
"bookingUserName",
|
||||
"bookingUserEmail",
|
||||
"bookingUserAvatarUrl",
|
||||
"bookingAssignmentReason",
|
||||
"eventTypeId",
|
||||
"eventTypeParentId",
|
||||
"eventTypeSchedulingType",
|
||||
"createdAt",
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"utm_term",
|
||||
"utm_content"
|
||||
)
|
||||
SELECT
|
||||
r.id,
|
||||
r.expected_form_id,
|
||||
r.expected_form_name,
|
||||
r.expected_form_team_id,
|
||||
r.expected_form_user_id,
|
||||
r.expected_booking_uid,
|
||||
r.expected_booking_id,
|
||||
r.expected_booking_status,
|
||||
r.expected_booking_status_order,
|
||||
r.expected_booking_created_at,
|
||||
r.expected_booking_start_time,
|
||||
r.expected_booking_end_time,
|
||||
r.expected_booking_user_id,
|
||||
r.expected_booking_user_name,
|
||||
r.expected_booking_user_email,
|
||||
r.expected_booking_user_avatar_url,
|
||||
r.expected_booking_assignment_reason,
|
||||
r.expected_event_type_id,
|
||||
r.expected_event_type_parent_id,
|
||||
r.expected_event_type_scheduling_type::text,
|
||||
r.expected_created_at,
|
||||
r.expected_utm_source,
|
||||
r.expected_utm_medium,
|
||||
r.expected_utm_campaign,
|
||||
r.expected_utm_term,
|
||||
r.expected_utm_content
|
||||
FROM records_to_update r
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
"formId" = EXCLUDED."formId",
|
||||
"formName" = EXCLUDED."formName",
|
||||
"formTeamId" = EXCLUDED."formTeamId",
|
||||
"formUserId" = EXCLUDED."formUserId",
|
||||
"bookingUid" = EXCLUDED."bookingUid",
|
||||
"bookingId" = EXCLUDED."bookingId",
|
||||
"bookingStatus" = EXCLUDED."bookingStatus",
|
||||
"bookingStatusOrder" = EXCLUDED."bookingStatusOrder",
|
||||
"bookingCreatedAt" = EXCLUDED."bookingCreatedAt",
|
||||
"bookingStartTime" = EXCLUDED."bookingStartTime",
|
||||
"bookingEndTime" = EXCLUDED."bookingEndTime",
|
||||
"bookingUserId" = EXCLUDED."bookingUserId",
|
||||
"bookingUserName" = EXCLUDED."bookingUserName",
|
||||
"bookingUserEmail" = EXCLUDED."bookingUserEmail",
|
||||
"bookingUserAvatarUrl" = EXCLUDED."bookingUserAvatarUrl",
|
||||
"bookingAssignmentReason" = EXCLUDED."bookingAssignmentReason",
|
||||
"eventTypeId" = EXCLUDED."eventTypeId",
|
||||
"eventTypeParentId" = EXCLUDED."eventTypeParentId",
|
||||
"eventTypeSchedulingType" = EXCLUDED."eventTypeSchedulingType",
|
||||
"createdAt" = EXCLUDED."createdAt",
|
||||
"utm_source" = EXCLUDED."utm_source",
|
||||
"utm_medium" = EXCLUDED."utm_medium",
|
||||
"utm_campaign" = EXCLUDED."utm_campaign",
|
||||
"utm_term" = EXCLUDED."utm_term",
|
||||
"utm_content" = EXCLUDED."utm_content";
|
||||
|
||||
GET DIAGNOSTICS chunk_updated_count = ROW_COUNT;
|
||||
processed_count := processed_count + chunk_updated_count;
|
||||
|
||||
RAISE NOTICE 'Chunk processed: IDs %-% (updated/inserted: % records, total updated: %)',
|
||||
current_id, current_id + chunk_size - 1, chunk_updated_count, processed_count;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Migration completed: processed up to ID % (total updated/inserted: % records out of % total records)',
|
||||
end_id, processed_count, total_count;
|
||||
END $$;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user