Compare commits

...
Author SHA1 Message Date
emrysal 47e976df96 chore: release v5.8.11
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-11-07 12:38:40 +00:00
Rajiv Sahal b44efddc0f fix: make sure we update the correct item when generating invoice for PER_ACTIVE_USER plan (#24940) 2025-11-07 11:58:31 +00:00
bc665cbeab fix: APIV2 team membership - Member not getting added to event-type automatically (#24780)
* fix: APIV2 team membership addition

* feat: Add trimming for email domain and orgAutoAcceptEmail in auto-accept logic

- Trim whitespace from both user email domain and orgAutoAcceptEmail
- Ensures consistent matching even with accidental whitespace
- Addresses feedback from PR review

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* simplify

* feat: Use shared OrganizationMembershipService in TRPC for consistent auto-accept logic

- Create OrganizationMembershipService.container.ts for DI in TRPC
- Update getOrgConnectionInfo to apply trimming + case-insensitive comparison
- Precompute auto-accept decisions in createNewUsersConnectToOrgIfExists using the service
- Use service in handleNewUsersInvites for consistent auto-accept determination
- Ensures both API v2 and TRPC paths use identical trimming and normalization logic

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Revert "feat: Use shared OrganizationMembershipService in TRPC for consistent auto-accept logic"

This reverts commit 0b2bd28b6e32e8c1d3dea139ca8ff9cbf402ac00.

* refactor: Unify OrganizationRepository and remove duplicate PrismaOrganizationRepository (#24869)

* refactor: Convert OrganizationRepository from static to instance methods

- Add constructor accepting deps object with prismaClient
- Convert all static methods to instance methods
- Add getOrganizationAutoAcceptSettings method
- Create singleton instance export in repository barrel file
- Update API v2 OrganizationsRepository to extend from OrganizationRepository
- Update all call sites to use singleton instance
- Add platform-libraries organizations.ts export
- Fix mock imports to use repository barrel
- Fix unsafe optional chaining in next-auth-options.ts
- Fix any types in test files with proper type inference

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Update all imports to use OrganizationRepository barrel export

- Update imports from direct OrganizationRepository file to barrel export
- This ensures mocks work correctly in tests
- Fixes 202 failing tests related to organizationRepository mock

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Update test mocks to use partial mock pattern

- Convert organizationMock to partial mock that preserves real class
- Add proper prisma mocks to failing test files
- Remove old OrganizationRepository mocks from test files
- This fixes test failures related to mock interception

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Export mocked singleton and update tests to use it directly

- Export mockedSingleton as organizationRepositoryMock from organizationMock
- Update delegationCredential.test.ts to import and use the exported mock
- This fixes 'vi.mocked(...).mockResolvedValue is not a function' errors

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Use platform-libraries import for API v2 OrganizationRepository

API v2 should import shared features through @calcom/platform-libraries
instead of directly from @calcom/features to maintain proper architectural
boundaries and packaging/licensing separation.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Implement DI pattern for OrganizationRepository

- Create OrganizationRepository.module.ts and .container.ts for DI
- Replace singleton pattern with getOrganizationRepository() across 20 files
- Update platform-libraries to export getOrganizationRepository
- Delete duplicate PrismaOrganizationRepository.ts
- Remove singleton export file (repositories/index.ts)
- Update test mocks to use DI container pattern
- All type checks and unit tests passing

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Implement read/write client separation in OrganizationRepository

- Updated OrganizationRepository constructor to accept optional prismaWriteClient parameter
- Routed all write operations (create, update) through prismaWrite client
- Routed all read operations (find, get) through prismaRead client
- Updated API v2 OrganizationsRepository to pass both dbRead.prisma and dbWrite.prisma to super()
- Optimized getOrganizationRepository() calls by storing in local variables to avoid repeated function calls
- This fixes the critical issue where API v2 was passing read-only client to base class with write methods

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Fix mock setup and optimize getOrganizationRepository() calls

- Fixed verify-email.test.ts mock to return mocked repository instance instead of scenario helper object
- Added mockReset to organizationMock.ts beforeEach to properly reset mock implementations between tests
- Added local variables in page.tsx to store getOrganizationRepository() result for consistency

This fixes the issue where getOrganizationRepository() was returning organizationScenarios.organizationRepository (scenario helper) instead of the actual mocked repository instance, causing findUniqueNonPlatformOrgsByMatchingAutoAcceptEmail to be undefined.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: Simplify OrganizationRepository to use single prismaClient

- Updated base OrganizationRepository constructor to accept only { prismaClient } instead of { prismaClient, prismaWriteClient? }
- Replaced this.prismaRead and this.prismaWrite with single this.prismaClient property
- Updated API v2 OrganizationsRepository to pass only dbWrite.prisma as prismaClient
- Removed unused PrismaReadService import from API v2
- All read and write operations now use the same client instance

This simplifies the architecture as requested - API v2 uses write client for all operations, and apps/web uses the client from DI container.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: Match OrganizationsRepository.findById signature with base class

The findById method in OrganizationsRepository was using a different signature
than the base OrganizationRepository class, causing type errors in CI.

Changed from: findById(organizationId: number)
Changed to: findById({ id }: { id: number })

This matches the base class signature and resolves the CI unit test failures.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Update all findById call sites to use object parameter

Fixed 6 call sites in API v2 that were calling findById with a number
instead of the required { id: number } object parameter:

- is-org.guard.ts
- is-admin-api-enabled.guard.ts
- is-webhook-in-org.guard.ts
- organizations.service.ts
- managed-organizations.service.ts (2 call sites)

This resolves the API v2 build failure caused by the signature change
in OrganizationsRepository.findById to match the base class.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* fix: Remove redundant findById override from OrganizationsRepository

The findById method was duplicating the base class OrganizationRepository
implementation. Both methods had identical logic (filtering by isOrganization: true),
so the override was unnecessary.

Since OrganizationsRepository extends OrganizationRepository and passes
dbWrite.prisma to the base constructor, the base class method already
provides the exact same functionality.

This resolves the API v2 build failure by eliminating the duplicate method
that was causing conflicts.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Remove unncessary changes

* Store in variable

* Revert "Remove unncessary changes"

This reverts commit af9351786a21616c9508c441191c17f2374fb2cc.

* Revert dbRead/dbWrite changes

* Add organizations library to tsconfig.json

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-11-07 10:50:00 +00:00
sean-brydon 02b1393ff4 chore: Update personal and team onboarding flows (remove profile/video steps, add settings, improve team invites) (#24947)
## What does this PR do?

- Redesigns and streamlines the onboarding flow for personal, team, and organization accounts
- Consolidates shared components and improves code organization
- Adds browser preview for better user experience during onboarding
- Simplifies the personal onboarding flow by removing the video integration step
- Enhances team onboarding with CSV upload functionality

## Visual Demo (For contributors especially)

#### Image Demo:
- The PR adds a new browser preview component that shows users how their profile/team will look during onboarding
- Redesigned UI with a more consistent layout across all onboarding steps
- Improved mobile responsiveness with better component organization

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code.
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. If N/A, write N/A here and check the checkbox.
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

1. Test the complete onboarding flow for personal accounts:
   - Start at `/onboarding/getting-started`
   - Proceed through personal details and calendar setup
   - Verify the flow completes successfully

2. Test the team onboarding flow:
   - Start at `/onboarding/getting-started` and select team
   - Complete team details
   - Test the invite options including CSV upload
   - Verify team creation works correctly

3. Test the organization onboarding flow:
   - Start at `/onboarding/getting-started` and select organization
   - Complete organization details and branding
   - Test member invitations
   - Verify organization creation works correctly

4. Verify browser preview functionality:
   - Check that the preview updates in real-time as you enter information
   - Confirm it displays correctly on different screen sizes

## Checklist

- I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code follows the style guidelines of this project
- I have commented my code, particularly in hard-to-understand areas
- I have checked if my changes generate no new warnings
2025-11-07 08:54:55 +00:00
sean-brydon 52e27bab53 chore: Add shared onboarding components (OnboardingCard, OnboardingLayout, browser views) (#24946)
## What does this PR do?

Creates new onboarding UI components for the user onboarding flow, including:

- `OnboardingCard` - A reusable card component with title, subtitle, content, and footer sections
- `OnboardingLayout` - A layout component with progress indicators and sign-out functionality
- `OnboardingBrowserView` - A browser preview showing how the user's booking page will look
- `OnboardingCalendarBrowserView` - A calendar preview showing sample events
- `OnboardingInviteBrowserView` - A preview of the team invitation email
- Enhanced `PlanIcon` component with new variants and animations for organization and team plans

## Visual Demo (For contributors especially)

#### Image Demo:
The PR adds several visual components for the onboarding flow, including browser previews, calendar views, and animated plan icons with concentric rings and user avatars.

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code.
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. If N/A, write N/A here and check the checkbox.
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

- Navigate to the onboarding flow to see the new components in action
- Test the layout with different screen sizes to ensure responsive behavior
- Verify that the browser previews render correctly with sample data
- Check that the plan icon animations work properly for different variants (single, team, organization)
- Ensure the calendar view displays sample events correctly

## Checklist

- I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code follows the style guidelines of this project
- I have commented my code, particularly in hard-to-understand areas
- I have checked if my changes generate no new warnings
2025-11-07 07:58:34 +00:00
Lauris Skraucis 45278a2195 feat: api v2 pbac support (#24402)
* feat: Pbac decorator and guard

* feat: v2 roles endpoints

* fix: test

* fix: starting v2

* fix: test error

* fix: test api keys

* fix fixture

* test permission creation

* feat: permissions endpoints

* refactors

* refactor: project structure

* test: role permissions crud

* test: permissions endpoint negative tests

* docs: org, team permissions swagger

* unit tests for validator

* Update roles.guard.ts

* fix type

* test: error messages

* refactor: dont throw error in pbac

* delete redundant test file

* feedback: logging error

* fix: persist role.permissions when updating role.otherProperty

* refactor: use output service to return permissions

* refactor: service functions return current permissions

* refactor: remove OrganizationsRepository from providers

* refactor: try catch possibly duplicate create

* refactor: require min length name if provided

* refactor: org role has orgId and team role teamId

* fix: pbac guard caching

* fix: e2e tests in parallel

* refactor: use IsTeamInOrg guard for orgs teams roles and permissions endpoints

* refactor: use redis service getter and setter

* refactor: invalidate team permissions cache when permissions change

* refactor: delete keys instead of versioning when caching
2025-11-06 17:14:55 +00:00
Hariom BalharaandUdit Takkar 2e8b89c4ee fix: enhance bot ID initialization check in instrumentation client (#24961)
- Add additional check for the presence of window.crypto.randomUUID function
- Ensures compatibility with environments that support both getRandomValues and randomUUID

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-11-06 12:00:31 +00:00
Joe Au-Yeung 7d17b443cf chore: Add cache log (#24954)
* Add log

* Update getCalendar.ts
2025-11-06 08:24:52 +00:00
Rodrigo EhlersandUdit Takkar c6ab6b3d13 fix: keep dialog state by decoupling it from list (#24914)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-11-06 13:37:26 +05:30
spandev c741d8133e fix: added icon and description in team event type empty page (#24951) 2025-11-06 13:26:06 +05:30
b01cb27c56 feat: improve overlapping events display in weekly calendar view (#24880)
* feat: improve overlapping events display in weekly calendar view

- Add cascading layout algorithm for overlapping events
- First event at 80% width, subsequent events offset by 8%
- Implement hover behavior to bring events to front (z-index 100)
- Extract overlap logic into reusable utility functions
- Add comprehensive unit tests for overlap detection and layout calculation
- Sort events by start time, then by duration for consistent rendering

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: add weekly calendar playground page for testing overlapping events

- Create comprehensive playground page with 8 test scenarios
- Include two overlapping, three cascading, non-overlapping, same start time, chain overlaps, dense day, touching events, and mixed statuses
- Add focused view with scenario selector and grid view for side-by-side comparison
- Update playground index to include weekly calendar link
- Each scenario includes description, expected behavior, and collapsible event data

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* update base props

* remove unneccessary things

* fix: isolate Calendar store instances to prevent shared state

- Refactor useCalendarStore to use Zustand's createStore with React Context
- Each Calendar component now creates its own isolated store instance
- Maintains backward compatibility with global store fallback
- Fixes issue where multiple Calendar instances on same page shared state

This allows the playground page to render multiple Calendar instances
without state conflicts between them.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: prevent event overflow in dense overlap scenarios

- Dynamically compress offset step based on overlap group size
- Ensure leftOffset + width never exceeds 100% to prevent bleeding into next day
- Add width clamping as safety guard against rounding errors
- Add 3 new tests for overflow prevention with dense event scenarios

Fixes issue where 10+ overlapping events would cascade beyond day boundary
and bleed into the next day's column. The algorithm now calculates the
maximum safe step size per overlap group while maintaining visual cascade.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* remove global store

* fix: comprehensive overflow prevention with safety margin and CSS fix

- Add safetyMarginPercent (0.5%) to prevent rounding and CSS box model overflow
- Implement floor3 rounding for width to guarantee left + width <= 100 - safetyMargin
- Calculate width from rounded left offset to avoid rounding mismatch
- Remove inset-x-1 class conflict that was setting both left and right positioning
- Change from marginLeft to left positioning for proper control
- Add test for 20+ overlapping events to verify no overflow
- Update existing tests to verify safety margin is respected

This fixes the slight overflow issue reported by the user where events were
still bleeding into adjacent day columns despite the initial dynamic step
compression fix. The root cause was a combination of:
1. CSS conflict: inset-x-1 class setting both left and right positioning
2. Rounding mismatch: width calculated from unrounded left offset
3. No safety margin for CSS box model effects (borders, padding)

All 21 tests now pass including new safety margin verification.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: update Dense Day scenario to 20+ events

- Increase from 11 to 21 overlapping events in Dense Day scenario
- Update title from '10+ Events' to '20+ Events'
- Add 10 more diverse events with staggered start times
- Better stress test for overflow prevention with safety margin

This provides a more comprehensive test case for the overflow prevention
fix and matches the user's request for 20+ events instead of 10+.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* WIP

* feat: enhance event tooltip to show full event details

- Replace simple title-only tooltip with rich content tooltip
- Display event title, time range, description, and status
- Add color indicator matching the event's visual style
- Set min-width (200px) and max-width (300px) for better readability
- Use inverted theme colors for better contrast in tooltip

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* faster animation

* feat: position tooltip based on day of week

- Show tooltip on right side for Monday-Thursday (days 1-4)
- Show tooltip on left side for Friday-Sunday (days 5-0)
- Calculate day of week using dayjs from event start date

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* add margin for tooltip

* feat: add event duration layout test scenario

- Add new scenario with events of varying durations (3, 7, 15, 20, 30, 53 minutes)
- Test layout logic where eventDuration > 30 changes flex direction
- Events ≤30min show horizontal layout (title and time inline)
- Events >30min show vertical layout (title and time stacked)

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* update styles

* feat: add minimum height of 15px for events

- Ensure very short events (e.g., 3 minutes) are still visible
- Use CSS max() to apply minimum height while respecting duration-based height
- Prevents events from becoming too small to interact with

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* update playground

* do not mutate

* pre-compute some values

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Sean Brydon <sean@cal.com>
2025-11-05 15:56:24 +00:00
c48f2043f2 feat: booking reports table and blocklist table for org (#24736)
* feat: booking reports and blocklist table

* chore: save progress

* chore: save progress

* feat: finish

* fix: types and test

* fix: types and test

* refactor: separate tables

* refactor: ffedbac

* refactor: remove delete report

* refactor: add go back button

* feat: add icon and svg

* chore: nit

* fix: type errror

* fix: type errror

* refactor: code improvements

* chore: add docs url

* chore: feedback

* minor UI fix

* fix: add Is-calcom check

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-11-05 14:48:06 +00:00
emrysal 630eee4fab chore: release v5.8.10
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-11-05 13:06:46 +00:00
Morgan 1ebf553181 refactor: checkBookingRequiresAuthentication with rescheduleUid (#24943) 2025-11-05 13:02:01 +00:00
Hariom Balhara e8a315ac4e Remove cal.cache query parameter to prevent stale calendar data (#24939)
- Remove cal.cache query parameter parsing from booking and slots flows
- Set shouldServeCache to false for all booking availability checks
- Remove _shouldServeCache from schemas and type definitions
- Clean up all references in platform atoms and API endpoints

This ensures fresh Google Calendar data is always fetched for conflict checking,
preventing bookings when there are actual calendar conflicts.
2025-11-05 13:01:22 +00:00
Udit TakkarandVolnei Munhoz c196ff1095 feat: link email to participant (requireEmailForGuests) (#24661)
* feat: link email to participatn

* fix: bugs

* refactor: improve code

* refactor: prevent repload

* chore: remove unued

* fix: type

* refactor

* fix: type

* feat: restrict host

* feat: type

* feat: tests

* fix: don't allow guest

* fix: merk guest

* fix: bugs

* fix: test

* fix: test

* refactor: feedback

* fix: tests

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-11-05 11:15:41 +00:00
bf0fbe4888 feat: skip authentication check for reschedule bookings with validation (#24903)
* feat: skip authentication check for reschedule bookings with validation

- Skip checkBookingRequiresAuthentication when rescheduleUid is present
- Add validation to ensure rescheduleUid points to a real booking
- Verify booking status is ACCEPTED (upcoming)
- Verify booking uses the same event-type
- Throw appropriate errors for invalid reschedule attempts

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: move reschedule validation logic into checkBookingRequiresAuthentication

- Refactor checkBookingRequiresAuthentication to accept optional rescheduleUid parameter
- Move reschedule booking validation logic inside the method
- Simplify createBooking method by removing duplicate validation code
- Maintain same validation logic: check booking exists, is ACCEPTED, and uses same event-type

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: allow PENDING bookings to be rescheduled

- Update status validation to allow both ACCEPTED and PENDING bookings
- Change error message to reflect both allowed statuses
- PENDING bookings can now be rescheduled without authentication check

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: separate reschedule validation from auth check

- Extract validateRescheduleBooking method to handle reschedule-specific validation
- Keep checkBookingRequiresAuthentication strictly for auth checks
- Use conditional logic in createBooking: validate reschedule OR check auth
- Improves code clarity by separating concerns per lauris's feedback

Co-Authored-By: morgan@cal.com <morgan@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-05 11:07:43 +00:00
Syed Ali Shahbaz eb6418c0d5 Add enableOverflow prop to DialogContent (#24935) 2025-11-05 10:46:44 +00:00
Udit TakkarandCarinaWolli 2df2868b20 fix: cal ai webhook (#24368)
* fix: cal ai email

* fix: remove

* fix: org

* replace Cal AI with Cal.ai

* fix: use

* fix: feedback

* fix: types

* fix: types

* fix: types

* fix: tests

* Merge branch 'main' into fix/cal-ai-credits

* refactor: feedback

* refactor: imporvement

* fix: type

* refactor: feedback

* fix: tests

* fix: use pbac

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-11-05 13:31:55 +04:00
Anik Dhabal Babu 7f48c7fdad fix: Ignore userIds form filter segment if no permission (#24194)
* Update get.handler.ts

* fix: Ignore userIds form filter segment if no permission

* add tests

* Update booking-filters.e2e.ts

* Update booking-filters.e2e.ts
2025-11-05 13:09:13 +05:30
Udit Takkar 2101051623 style: fix height of report button (#24918)
* style: fix height

* style: fix height
2025-11-05 07:35:58 +00:00
Alex van Andel a241cb0bfc hotfix: Bring back English translation for cal.ai banner (#24922) 2025-11-04 19:30:29 +00:00
Anik Dhabal Babu 5417012cec Revert "fix: sanitize username input to prevent hyperlinks (#24210)" (#24916)
This reverts commit 7d16da5c98.
2025-11-04 17:01:05 +00:00
Dhairyashil Shinde 8e818e0567 refactor: remove-zapier-setup-screen (#24652)
* refactor: remove-zapier-setup-screen

* remove setup page files, related config and update readme

* make zapier a redirecting app

* update readme

* add check for missing website

* add noopener,noreferrer for _blank links
2025-11-04 15:55:09 +00:00
Morgan 1fdd04215d fix: missing await in updateEventType old endpoint (#24911) 2025-11-04 15:15:19 +00:00
Dhairyashil Shinde 0bf0749b29 only custom template needs upgrade and free users cannot edit email title and body of existing templates (#24907) 2025-11-04 13:43:40 +00:00
Dhairyashil Shinde 4006143e3a refactor: remove cal ai tip (#24904) 2025-11-04 11:56:21 +00:00
c6ad767565 refactor: decouple @calcom/prisma from @calcom/features, @calcom/ee, and @calcom/lib (#24802)
* refactor: decouple @calcom/prisma from @calcom/features, @calcom/ee, and @calcom/lib

- Inline helper functions in zod-utils.ts (emailSchema, slugify, getValidRhfFieldName, isPasswordValid, intervalLimitsType, zodAttributesQueryValue)
- Update schema.prisma @zod.import comments to reference zod-utils instead of @calcom/lib
- Inline idempotency key generation in booking-idempotency-key extension using uuid v5
- Move usage-tracking extension to @calcom/ee/prisma-extensions/
- Remove usage-tracking extension from packages/prisma/index.ts
- Move Prisma DI module from @calcom/prisma to @calcom/features/di/modules/Prisma.ts
- Update 20 import paths in @calcom/features to use new Prisma DI module
- Remove @calcom/lib dependency from @calcom/prisma package.json
- Add uuid dependency to @calcom/prisma package.json

This reduces the dependency footprint of @calcom/prisma and breaks circular dependencies between packages.

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* feat: add EE-specific Prisma DI module with usage tracking

Create packages/ee/di/modules/PrismaEE.ts to apply the usage-tracking extension in EE contexts. This module:
- Imports the base prisma client from @calcom/prisma
- Applies the usageTrackingExtention from @calcom/ee/prisma-extensions/usage-tracking
- Exports the same DI tokens as the base Prisma module for override in EE containers

This ensures usage tracking functionality is preserved in EE builds while keeping the base @calcom/prisma package free of EE dependencies.

Note: EE containers should load this module after the base Prisma module to override the bindings.
Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-04 06:51:13 -03:00
896f8dbd3d fix: align booking limit timezone between availability and validation (#24846)
* fix: align booking limit timezone between availability and validation

- Use eventType.schedule?.timeZone for booking limits in availability calculation
- Previously used user's timezone causing day boundary mismatch
- Add unit tests to verify timezone alignment
- Fixes issue where slots remain available after reaching booking limit

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Rename variable limitsTz to eventTimeZone

* update

* fix: correct timezone fallback to include user timezone without forcing UTC

- Changed fallback chain to: schedule → event → user → undefined
- Treats empty strings as missing timezones
- Does not force UTC when all timezones are missing
- Aligns with validation behavior when timezone is undefined
- Fixes getSchedule.test.ts failures

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Revert "fix: correct timezone fallback to include user timezone without forcing UTC"

This reverts commit 721e0bdfd7b08004e68df6bdf5b97b9bd6cf3d5c.

* test: remove complex unit tests with 'as any' casts

- Removed Booking Limits Timezone Alignment tests that required heavy mocking
- These tests relied on spying private methods and had many 'as any' casts
- Timezone alignment is already covered by integration tests in getSchedule.test.ts
- Kept BookingDateInPastError test which has no type issues

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* address review

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-04 12:13:42 +05:30
emrysal f1db27c0ad chore: release v5.8.9
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-11-04 00:30:52 +00:00
Eunjae LeeandDevin AI 3a1e04bb42 refactor: extract booking actions dropdown to separate component (#24872)
* refactor: extract booking actions dropdown to separate component

- Created BookingActionsDropdown component with all dropdown actions and dialogs
- Component receives booking as prop and handles all action logic internally
- Removed duplicate dialog states and mutations from BookingListItem
- Fixed img tag to use Next.js Image component
- Maintains same functionality and UI while improving code reusability

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: restore cardCharged variable in BookingListItem

The cardCharged variable was accidentally removed during the refactoring
but is still needed for the actionContext in BookingListItem.tsx

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: move BookingItemProps type to separate types.ts file

- Created types.ts to hold shared booking types
- Updated BookingListItem.tsx to import from types.ts
- Updated BookingActionsDropdown.tsx to import from types.ts
- Updated bookingActions.ts to import from types.ts
- Removed unused RouterInputs and RouterOutputs imports from BookingListItem.tsx
- Removes unwanted dependency chain where BookingActionsDropdown imported from BookingListItem

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* remove unnecessary description

* extract states as a zustand store

* fix dialog issue

* fix type errors

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-04 00:25:48 +00:00
Alex van Andel ab37847ea1 chore: User->UUID as a required field, ready for use in foreign keys (#24881) 2025-11-04 00:20:52 +00:00
Dhairyashil Shinde 0a2ddf05ca refactor: remove cal ai banners (#24885) 2025-11-04 01:57:43 +05:30
Lauris Skraucis 04fc0f1d52 docs: event calVideoSettings not available for platform (#24879) 2025-11-03 18:53:40 +00:00
Amit Sharma b76c4c9dcc fix: alby booking page breaking (#24859)
* fix: alby booking page breaking

* remove logs
2025-11-03 14:35:20 -03:00
Vibhor PhalkeandKartik Saini 68b71bfb0a address linting errors (#24171)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-11-03 17:29:24 +00:00
Eunjae Lee 763c568797 fix: existence check of searchParams in NavigationItem (#24877) 2025-11-03 16:42:31 +00:00
Rajiv Sahal 4659d08c07 chore: return back timezone of event owner (#24876) 2025-11-03 16:29:29 +00:00
Sachin Yadav b9c350cc45 fix: Recurring events set to "yearly" don't recur (#24361)
* #fix: Recurring events set to “yearly” don’t recur

* fix: handle yearly recurring events with explicit null check

* fix: use explicit null check for recurring event frequency
2025-11-03 16:20:15 +00:00
an_ifrah24 f63d705523 fix: url truncate properly in the input field on diff screen sizes (#24700) 2025-11-03 16:14:49 +00:00
Dhairyashil Shinde 2907c5e696 fix(ui): interface language toggle (#24371) 2025-11-03 21:20:34 +05:30
sean-brydon dcb47e31ed fix: address onboarding v3 teams redirectfrom onboarding (#24875)
* Address redirect in subdomain for teams v3

* Revert constants
2025-11-03 15:43:55 +00:00
Dhairyashil ShindeandAnik Dhabal Babu cd5b75fe2c fix: hide organizer email on cancellation page (#24616)
* fix: hide organizer email on cancellation page

* if cancelled by any host - hide the cancelled by

* Refactor organizer email logic and clean up code

Removed the isOrganizerEmail function and updated conditions for displaying the cancelledBy information.

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-11-03 14:18:38 +00:00
Steven Adger 97bdf837a9 Include setup_intent events in webhook (#24805)
The setup_intent webhook is needed for held payments / no-show fees to work. 

If it is not included, a booking gets stuck saying "Pending payment" and "Unconfirmed" despite successfully submitting the card info. After including it, the booking status will successfully change to "Card held".

See:
https://github.com/calcom/cal.com/blob/817e20f11e3a75ec0bc62535a77c618bd27eaaf4/packages/features/ee/payments/api/webhook.ts#L323

https://cal.com/blog/cal-com-s-held-payments-a-useful-tool-for-organizations-offering-free-consultatio
2025-11-03 14:16:06 +00:00
Dhairyashil Shinde 5663b2ae59 feat: disable form workflows for free plan, add upgrade badge, make reminder template free (#24839) 2025-11-03 14:08:38 +00:00
Chad RobinsonandPallav 02b57eb82e fix: Add refresh token to refresh-token request response (#24831)
* Add refresh token to refresh-token request response

This change addresses an issue reported in https://github.com/calcom/cal.com/issues/23939#issuecomment-3443480099 in which a new refresh token is not included in the OAuth2 refresh-token endpoint response.

* Address failing type checks

---------

Co-authored-by: Pallav <90088723+Pallava-Joshi@users.noreply.github.com>
2025-11-03 19:10:36 +05:30
sean-brydon b23dfa194e fix: video install step v3 (#24865)
* Add teams i18n

* Fix UI nits in invite flow

* chore: update redirect

* Update personal view settings

* Fix video redirect
2025-11-03 13:25:54 +00:00
Eunjae LeeandDevin AI 2484b6fad2 fix: filter deleted fields from routing form insights headers (#24830)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-11-03 12:57:37 +00:00
Hariom Balhara c154bb31b7 fix: Arrows visible when embed has hidden entire page (#24868) 2025-11-03 12:28:54 +00:00
emrysal 0cf4ebd544 chore: release v5.8.8
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s
2025-11-03 12:03:36 +00:00
Dhairyashil Shinde 805c4d4c09 fix: dont allow email exclusion bypass with capital letters (#24741)
* fix: dont allow email exclusion bypass with capital letters

* trim before lowercasing to filter out empty tokens

* update console message

* use only regex change to implement this

* use only regex change to implement this
2025-11-03 11:59:14 +00:00
sean-brydon 99330b3117 chore: fixes UI nits in onboarding v3 flow (#24863)
* Add teams i18n

* Fix UI nits in invite flow

* chore: update redirect

* Update personal view settings
2025-11-03 11:45:27 +00:00
sean-brydon cc90a62090 feat: add banner for upgrade when using company email (#24829)
* Add banner plus mail icon

* Update MailIcon.tsx

* Update profile-view.tsx

* Fix weird line
2025-11-03 11:16:34 +00:00
sean-brydon 79bcd6dc30 feat: welcome to organizations modal after creation (#24823)
## What does this PR do?

Adds a welcome modal for new organizations that appears after organization creation. The modal showcases key features of the Organizations plan and provides a better onboarding experience.

## Visual Demo (For contributors especially)

#### Image Demo:

![CleanShot 2025-10-31 at 12.19.17.gif](https://app.graphite.dev/user-attachments/assets/4f8c3286-9400-40e6-aeb4-8a012f604c64.gif)

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code.
- [x] I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

1. Create a new organization through either:
   - The onboarding flow
   - The settings/organizations/new page
   - The organization creation form

2. After successful creation and redirect, verify the welcome modal appears showing organization features.

3. Verify the modal can be closed by:
   - Clicking the "Continue" button
   - Clicking outside the modal
   - Pressing ESC key

4. Verify the modal doesn't reappear after being closed (query param and session storage should be cleared).

## Checklist

- I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code follows the style guidelines of this project
- I have commented my code, particularly in hard-to-understand areas
- I have checked if my changes generate no new warnings
2025-11-03 11:11:16 +00:00
chauhan_s c6daa61e5e fix: Maximum update depth exceeded error when entering the 6th digit of the email verification code. (#24857) 2025-11-03 11:07:20 +00:00
Udit TakkarandVolnei Munhoz b693b6898a feat: booking report table backend (#24794)
* feat: report table backend

* fix: types

* fix: types

* fix: bugs

* fix: type error

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-11-03 10:44:09 +00:00
Abir Roy fb55bc2dcd fix(docker): correct build context for calcom-api (#24849) 2025-11-03 10:38:33 +00:00
Udit TakkarandVolnei Munhoz 71bd140fe4 fix: add event type validation (#24429)
* fix: add event type validation

* fix: use isinteger

* fix: use isinteger

* fix: use isNumber

* fix: use Error

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-11-03 10:26:40 +00:00
Dhairyashil Shinde 4c5608147c feat: Add Preview buttons for every private link (#24623) 2025-11-03 08:17:31 +00:00
7d16da5c98 fix: sanitize username input to prevent hyperlinks (#24210)
* fix(username): sanitize input to prevent hyperlinks

* fix(username): sanitize input to prevent hyperlinks

* Implemented CodeRabbits comments

* clean username before saving and keep input consistent

* sanitize initial username on load to avoid mismatch with input

* remove comments

* remove comments

* Used existing slugify lib instead of creating new one

---------

Co-authored-by: Dhairyashil <dhairyashilshinde@xoxo-2.local>
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-11-03 13:16:51 +05:30
spandev 0ae6e92558 fix: round robin settings update only on change (#24853) 2025-11-03 11:54:00 +05:30
Volnei Munhoz 052a38e39b cleanup unused env vars (#24819) 2025-10-31 12:14:54 -03:00
388 changed files with 16078 additions and 3492 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
Fix Booker atom verify email dialog infinite re-render when entering verification code
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- calcom-monorepo
View File
View File
+1 -1
View File
@@ -57,7 +57,7 @@ export const eventTypeSelect = {
},
},
owner: {
select: { username: true, id: true },
select: { username: true, id: true, timeZone: true },
},
children: {
select: {
@@ -205,7 +205,8 @@ export class BookingsController_2024_04_15 {
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
const { orgSlug, locationUrl } = body;
try {
await this.checkBookingRequiresAuthentication(req, body.eventTypeId);
await this.checkBookingRequiresAuthentication(req, body.eventTypeId, body.rescheduleUid);
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
const booking = await this.regularBookingService.createBooking({
bookingData: bookingRequest.body,
@@ -459,7 +460,25 @@ export class BookingsController_2024_04_15 {
return oAuthClientParams.platformClientId;
}
private async checkBookingRequiresAuthentication(req: Request, eventTypeId: number): Promise<void> {
private async isValidRescheduleBooking(rescheduleUid: string, eventTypeId: number): Promise<boolean> {
const { bookingInfo } = await getBookingInfo(rescheduleUid);
if (!bookingInfo) {
return false;
}
if (bookingInfo.status !== "ACCEPTED" && bookingInfo.status !== "PENDING") {
return false;
}
if (bookingInfo.eventTypeId !== eventTypeId) {
return false;
}
return true;
}
private async checkBookingRequiresAuthentication(
req: Request,
eventTypeId: number,
rescheduleUid?: string
): Promise<void> {
const eventType = await this.eventTypeRepository.findByIdIncludeHostsAndTeamMembers({
id: eventTypeId,
});
@@ -468,6 +487,17 @@ export class BookingsController_2024_04_15 {
return;
}
if (rescheduleUid) {
const isValidRescheduleBooking = await this.isValidRescheduleBooking(rescheduleUid, eventTypeId);
if (isValidRescheduleBooking) {
return;
} else {
throw new BadRequestException(
"Trying to reschedule an event-type which requires authentication but provided invalid rescheduleUid."
);
}
}
const userId = await this.getOwnerId(req);
if (!userId) {
@@ -478,8 +508,7 @@ export class BookingsController_2024_04_15 {
const isEventTypeOwner = eventType.userId === userId;
const isHost = eventType.hosts.some((host) => host.userId === userId);
const isTeamAdminOrOwner =
eventType.team?.members.some((member) => member.userId === userId) ?? false;
const isTeamAdminOrOwner = eventType.team?.members.some((member) => member.userId === userId) ?? false;
let isOrgAdminOrOwner = false;
if (eventType.team?.parentId) {
@@ -194,11 +194,6 @@ export class CreateBookingInput_2024_04_15 {
@ApiHideProperty()
skipContactOwner?: boolean;
@IsBoolean()
@IsOptional()
@ApiHideProperty()
_shouldServeCache?: boolean;
@IsBoolean()
@IsOptional()
@ApiHideProperty()
@@ -129,7 +129,7 @@ export class EventTypesService_2024_04_15 {
}
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_04_15, user: UserWithProfile) {
this.checkCanUpdateEventType(user.id, eventTypeId);
await this.checkCanUpdateEventType(user.id, eventTypeId);
const eventTypeUser = await this.getUserToUpdateEvent(user);
const bookingFields = [...(body.bookingFields || [])];
@@ -0,0 +1,12 @@
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { Injectable } from "@nestjs/common";
import { OrganizationMembershipService as BaseOrganizationMembershipService } from "@calcom/platform-libraries/organizations";
@Injectable()
export class OrganizationMembershipService extends BaseOrganizationMembershipService {
constructor(organizationsRepository: OrganizationsRepository) {
super({ organizationRepository: organizationsRepository });
}
}
@@ -7,6 +7,7 @@ import { DeploymentsModule } from "@/modules/deployments/deployments.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { RedisModule } from "@/modules/redis/redis.module";
import { RolesModule } from "@/modules/roles/roles.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@@ -21,6 +22,7 @@ import { PassportModule } from "@nestjs/passport";
MembershipsModule,
TokensModule,
DeploymentsModule,
RolesModule,
],
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
exports: [NextAuthGuard, ApiAuthGuard],
@@ -0,0 +1,5 @@
import { Reflector } from "@nestjs/core";
import type { PermissionString } from "@calcom/platform-libraries/pbac";
export const Pbac = Reflector.createDecorator<PermissionString[]>();
@@ -54,7 +54,7 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
}
}
const org = await this.organizationsRepository.findById(Number(organizationId));
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });
if (org?.isOrganization && !org?.isPlatform) {
const adminAPIAccessIsEnabledInOrg = await this.organizationsRepository.fetchOrgAdminApiStatus(
@@ -55,7 +55,7 @@ export class IsOrgGuard implements CanActivate {
}
}
const org = await this.organizationsRepository.findById(Number(organizationId));
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });
if (org?.isOrganization) {
canAccess = true;
@@ -43,7 +43,7 @@ export class IsWebhookInOrg implements CanActivate {
}
}
const org = await this.organizationsRepository.findById(Number(organizationId));
const org = await this.organizationsRepository.findById({ id: Number(organizationId) });
if (org?.isOrganization) {
const isWebhookInOrg = await this.organizationsWebhooksRepository.findWebhook(
@@ -0,0 +1,177 @@
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { RedisService } from "@/modules/redis/redis.service";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
UnauthorizedException,
BadRequestException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import type { PermissionString } from "@calcom/platform-libraries/pbac";
import { PermissionCheckService, FeaturesRepository } from "@calcom/platform-libraries/pbac";
export const REDIS_PBAC_CACHE_KEY = (teamId: number) => `apiv2:team:${teamId}:has:pbac:guard:pbac`;
export const REDIS_REQUIRED_PERMISSIONS_CACHE_KEY = (
userId: number,
teamId: number,
requiredPermissions: PermissionString[]
) =>
`apiv2:user:${userId}:team:${teamId}:requiredPermissions:${requiredPermissions
.sort()
.join(",")}:guard:pbac`;
@Injectable()
export class PbacGuard implements CanActivate {
constructor(
private reflector: Reflector,
private prismaReadService: PrismaReadService,
private readonly redisService: RedisService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { pbacAuthorizedRequest?: boolean }>();
const user = request.user as ApiAuthGuardUser;
const teamId = request.params.teamId;
const orgId = request.params.orgId;
const requiredPermissions = this.reflector.get(Pbac, context.getHandler());
const effectiveTeamId = teamId || orgId;
if (!user) {
throw new UnauthorizedException("PbacGuard - the request does not have an authorized user provided");
}
if (!effectiveTeamId) {
throw new BadRequestException(
"PbacGuard - can't check pbac because no teamId or orgId provided within the request url"
);
}
if (!requiredPermissions || requiredPermissions.length === 0) {
request.pbacAuthorizedRequest = false;
return true;
}
const hasPbacEnabled = await this.hasPbacEnabled(Number(effectiveTeamId));
if (!hasPbacEnabled) {
request.pbacAuthorizedRequest = false;
return true;
}
const hasRequiredPermissions = await this.checkUserHasRequiredPermissions(
user.id,
Number(effectiveTeamId),
requiredPermissions
);
if (!hasRequiredPermissions) {
request.pbacAuthorizedRequest = false;
return true;
}
request.pbacAuthorizedRequest = true;
return true;
}
async hasPbacEnabled(teamId: number) {
const cachedHasPbacEnabled = await this.getCachePbacEnabled(teamId);
if (cachedHasPbacEnabled) {
return cachedHasPbacEnabled;
}
const pbacFeatureFlag = "pbac";
const featuresRepository = new FeaturesRepository(this.prismaReadService.prisma);
const hasPbacEnabled = await featuresRepository.checkIfTeamHasFeature(teamId, pbacFeatureFlag);
if (hasPbacEnabled) {
await this.setCachePbacEnabled(teamId, hasPbacEnabled);
}
return hasPbacEnabled;
}
async checkUserHasRequiredPermissions(
userId: number,
teamId: number,
requiredPermissions: PermissionString[]
) {
const cachedAccess = await this.getCacheRequiredPermissions(userId, teamId, requiredPermissions);
if (cachedAccess) {
return cachedAccess;
}
const permissionCheckService = new PermissionCheckService();
const hasRequiredPermissions = await permissionCheckService.checkPermissions({
userId,
teamId,
permissions: requiredPermissions,
fallbackRoles: [],
});
if (hasRequiredPermissions) {
await this.setCacheRequiredPermissions(userId, teamId, requiredPermissions, hasRequiredPermissions);
}
return hasRequiredPermissions;
}
private async getCacheRequiredPermissions(
userId: number,
teamId: number,
requiredPermissions: PermissionString[]
): Promise<boolean | null> {
return this.redisService.get<boolean>(
REDIS_REQUIRED_PERMISSIONS_CACHE_KEY(userId, teamId, requiredPermissions)
);
}
private async setCacheRequiredPermissions(
userId: number,
teamId: number,
requiredPermissions: PermissionString[],
hasRequired: boolean
): Promise<void> {
await this.redisService.set<boolean>(
REDIS_REQUIRED_PERMISSIONS_CACHE_KEY(userId, teamId, requiredPermissions),
hasRequired,
{ ttl: 300_000 }
);
}
private async getCachePbacEnabled(teamId: number) {
const cachedResult = await this.redisService.get<boolean>(REDIS_PBAC_CACHE_KEY(teamId));
return cachedResult;
}
private async setCachePbacEnabled(teamId: number, pbacEnabled: boolean) {
await this.redisService.set<boolean>(REDIS_PBAC_CACHE_KEY(teamId), pbacEnabled, {
ttl: 300_000,
});
}
throwForbiddenError(
userId: number,
teamId: string,
orgId: string,
requiredPermissions: PermissionString[]
) {
let errorMessage = `PbacGuard - user with id=${userId} does not have the minimum required permissions=${requiredPermissions.join(
","
)} `;
if (teamId) {
errorMessage += `within team with id=${teamId}`;
}
if (orgId) {
errorMessage += `within organization with id=${orgId}`;
}
errorMessage += `.`;
throw new ForbiddenException(errorMessage);
}
}
@@ -7,8 +7,6 @@ import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger }
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import type { Team } from "@calcom/prisma/client";
@Injectable()
export class RolesGuard implements CanActivate {
private readonly logger = new Logger("RolesGuard Logger");
@@ -19,7 +17,13 @@ export class RolesGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const request = context.switchToHttp().getRequest<Request & { pbacAuthorizedRequest?: boolean }>();
if (request.pbacAuthorizedRequest === true) {
this.logger.debug("PBAC authorized request, skipping legacy role checking");
return true;
}
const teamId = request.params.teamId as string;
const orgId = request.params.orgId as string;
const user = request.user as ApiAuthGuardUser;
@@ -305,11 +305,24 @@ export class BillingService implements IBillingService, OnModuleDestroy {
new Date(invoice.period_end * 1000)
);
const existingSubscription = await this.stripeService
.getStripe()
.subscriptions.retrieve(subscriptionId);
const perActiveUserPrice = this.billingConfigService.get(PlatformPlan.PER_ACTIVE_USER)?.base;
const subscriptionItem = existingSubscription.items.data.find(
(item) => item.price?.id === perActiveUserPrice
);
if (!subscriptionItem) {
throw new NotFoundException(
"No subscription item found for PER_ACTIVE_USER plan with matching price ID"
);
}
await this.stripeService.getStripe().subscriptions.update(subscriptionId, {
items: [
{
quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1,
},
{ id: subscriptionItem.id, quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1 },
],
});
}
@@ -4,23 +4,17 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { StripeService } from "@/modules/stripe/stripe.service";
import { Injectable } from "@nestjs/common";
import { OrganizationRepository } from "@calcom/platform-libraries/organizations";
import { Prisma } from "@calcom/prisma/client";
@Injectable()
export class OrganizationsRepository {
export class OrganizationsRepository extends OrganizationRepository {
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private readonly stripeService: StripeService
) {}
async findById(organizationId: number) {
return this.dbRead.prisma.team.findUnique({
where: {
id: organizationId,
isOrganization: true,
},
});
) {
super({ prismaClient: dbWrite.prisma });
}
async findByIds(organizationIds: number[]) {
@@ -6,7 +6,7 @@ export class OrganizationsService {
constructor(private readonly organizationsRepository: OrganizationsRepository) {}
async isPlatform(organizationId: number) {
const organization = await this.organizationsRepository.findById(organizationId);
const organization = await this.organizationsRepository.findById({ id: organizationId });
return organization?.isPlatform;
}
}
@@ -13,6 +13,7 @@ import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.ser
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { EmailModule } from "@/modules/email/email.module";
import { EmailService } from "@/modules/email/email.service";
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
@@ -39,6 +40,7 @@ import { OrganizationsMembershipRepository } from "@/modules/organizations/membe
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
import { OrganizationsRolesModule } from "@/modules/organizations/roles/organizations-roles.module";
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module";
@@ -50,6 +52,7 @@ import { OrganizationsTeamsInviteController } from "@/modules/organizations/team
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { OrganizationsTeamsRolesModule } from "@/modules/organizations/teams/roles/organizations-teams-roles.module";
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/teams/schedules/organizations-teams-schedules.controller";
import { OrganizationTeamWorkflowsController } from "@/modules/organizations/teams/workflows/controllers/org-team-workflows.controller";
@@ -96,6 +99,8 @@ import { Module } from "@nestjs/common";
TeamsModule,
OrganizationsDelegationCredentialModule,
OrganizationsOrganizationsModule,
OrganizationsRolesModule,
OrganizationsTeamsRolesModule,
OrganizationsStripeModule,
OrganizationsTeamsRoutingFormsModule,
MembershipsModule,
@@ -104,6 +109,7 @@ import { Module } from "@nestjs/common";
],
providers: [
OrganizationsRepository,
OrganizationMembershipService,
OrganizationsTeamsRepository,
OrganizationsService,
OrganizationsTeamsService,
@@ -196,4 +202,4 @@ import { Module } from "@nestjs/common";
OrganizationsEventTypesPrivateLinksController,
],
})
export class OrganizationsModule {}
export class OrganizationsModule { }
@@ -103,12 +103,12 @@ export class ManagedOrganizationsService {
}
private async isManagerOrganizationPlatform(managerOrganizationId: number) {
const organization = await this.organizationsRepository.findById(managerOrganizationId);
const organization = await this.organizationsRepository.findById({ id: managerOrganizationId });
return !!organization?.isPlatform;
}
async getManagedOrganization(managedOrganizationId: number) {
const organization = await this.organizationsRepository.findById(managedOrganizationId);
const organization = await this.organizationsRepository.findById({ id: managedOrganizationId });
if (!organization) {
throw new NotFoundException(`Managed organization with id=${managedOrganizationId} does not exist.`);
}
@@ -0,0 +1,34 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsArray, IsOptional, IsString, Validate } from "class-validator";
import type { PermissionString } from "@calcom/platform-libraries/pbac";
import { getAllPermissionStringsForScope, Scope } from "@calcom/platform-libraries/pbac";
import { OrgPermissionStringValidator } from "../permissions/inputs/validators/org-permission-string.validator";
export const orgPermissionEnum = [...getAllPermissionStringsForScope(Scope.Organization)] as const;
export class BaseOrgRoleInput {
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
@IsString()
@IsOptional()
color?: string;
@ApiPropertyOptional({ description: "Description of the role" })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional({
description:
"Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.",
enum: orgPermissionEnum,
isArray: true,
example: ["eventType.read", "eventType.create", "booking.read"],
})
@IsArray()
@IsString({ each: true })
@Validate(OrgPermissionStringValidator, { each: true })
@IsOptional()
permissions?: PermissionString[];
}
@@ -0,0 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, MinLength } from "class-validator";
import { BaseOrgRoleInput } from "./base-org-role.input";
export class CreateOrgRoleInput extends BaseOrgRoleInput {
@ApiProperty({ description: "Name of the role", minLength: 1 })
@IsString()
@MinLength(1)
name!: string;
}
@@ -0,0 +1,12 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsString, IsOptional, MinLength } from "class-validator";
import { BaseOrgRoleInput } from "./base-org-role.input";
export class UpdateOrgRoleInput extends BaseOrgRoleInput {
@ApiPropertyOptional({ description: "Name of the role", minLength: 1 })
@IsString()
@MinLength(1)
@IsOptional()
name?: string;
}
@@ -0,0 +1,638 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { CreateOrgRoleInput } from "@/modules/organizations/roles/inputs/create-org-role.input";
import { UpdateOrgRoleInput } from "@/modules/organizations/roles/inputs/update-org-role.input";
import { CreateOrgRoleOutput } from "@/modules/organizations/roles/outputs/create-org-role.output";
import { DeleteOrgRoleOutput } from "@/modules/organizations/roles/outputs/delete-org-role.output";
import { GetAllOrgRolesOutput } from "@/modules/organizations/roles/outputs/get-all-org-roles.output";
import { GetOrgRoleOutput } from "@/modules/organizations/roles/outputs/get-org-role.output";
import { UpdateOrgRoleOutput } from "@/modules/organizations/roles/outputs/update-org-role.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { RoleService } from "@calcom/platform-libraries/pbac";
import type { User, Team } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
describe("Organizations Roles Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let featuresRepositoryFixture: FeaturesRepositoryFixture;
let roleService: RoleService;
// Test users
let legacyOrgAdminUser: User;
let legacyOrgMemberUser: User;
let pbacOrgUserWithRolePermission: User;
let pbacOrgUserWithoutRolePermission: User;
let nonOrgUser: User;
// API Keys
let legacyOrgAdminApiKey: string;
let legacyOrgMemberApiKey: string;
let pbacOrgUserWithRolePermissionApiKey: string;
let pbacOrgUserWithoutRolePermissionApiKey: string;
let nonOrgUserApiKey: string;
// Organization
let organization: Team;
let pbacEnabledOrganization: Team;
const legacyOrgAdminEmail = `legacy-org-admin-${randomString()}@api.com`;
const legacyOrgMemberEmail = `legacy-org-member-${randomString()}@api.com`;
const pbacOrgUserWithRolePermissionEmail = `pbac-org-user-with-role-permission-${randomString()}@api.com`;
const pbacOrgUserWithoutRolePermissionEmail = `pbac-org-user-without-role-permission-${randomString()}@api.com`;
const nonOrgUserEmail = `non-org-user-${randomString()}@api.com`;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
}).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef);
roleService = new RoleService();
// Create test users
legacyOrgAdminUser = await userRepositoryFixture.create({
email: legacyOrgAdminEmail,
username: legacyOrgAdminEmail,
});
legacyOrgMemberUser = await userRepositoryFixture.create({
email: legacyOrgMemberEmail,
username: legacyOrgMemberEmail,
});
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
email: pbacOrgUserWithRolePermissionEmail,
username: pbacOrgUserWithRolePermissionEmail,
});
pbacOrgUserWithoutRolePermission = await userRepositoryFixture.create({
email: pbacOrgUserWithoutRolePermissionEmail,
username: pbacOrgUserWithoutRolePermissionEmail,
});
nonOrgUser = await userRepositoryFixture.create({
email: nonOrgUserEmail,
username: nonOrgUserEmail,
});
// Create organizations
organization = await organizationsRepositoryFixture.create({
name: `org-roles-test-${randomString()}`,
isOrganization: true,
});
pbacEnabledOrganization = await organizationsRepositoryFixture.create({
name: `pbac-org-roles-test-${randomString()}`,
isOrganization: true,
});
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac");
// Create memberships
await membershipRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: legacyOrgAdminUser.id } },
team: { connect: { id: organization.id } },
});
await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: legacyOrgMemberUser.id } },
team: { connect: { id: organization.id } },
});
const pbacOrgUserWithRolePermissionMembership = await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: pbacOrgUserWithRolePermission.id } },
team: { connect: { id: pbacEnabledOrganization.id } },
});
const pbacOrgUserWithoutRolePermissionMembership = await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: pbacOrgUserWithoutRolePermission.id } },
team: { connect: { id: pbacEnabledOrganization.id } },
});
const roleWithPermission = await roleService.createRole({
name: "Role Manager",
teamId: pbacEnabledOrganization.id,
permissions: ["role.create", "role.read", "role.update", "role.delete"],
type: "CUSTOM",
});
const roleWithoutPermission = await roleService.createRole({
name: "Basic Role",
teamId: pbacEnabledOrganization.id,
permissions: ["booking.read"],
type: "CUSTOM",
});
await roleService.assignRoleToMember(roleWithPermission.id, pbacOrgUserWithRolePermissionMembership.id);
await roleService.assignRoleToMember(
roleWithoutPermission.id,
pbacOrgUserWithoutRolePermissionMembership.id
);
const { keyString: legacyOrgAdminKeyString } = await apiKeysRepositoryFixture.createApiKey(
legacyOrgAdminUser.id,
null
);
legacyOrgAdminApiKey = `cal_test_${legacyOrgAdminKeyString}`;
const { keyString: legacyOrgMemberKeyString } = await apiKeysRepositoryFixture.createApiKey(
legacyOrgMemberUser.id,
null
);
legacyOrgMemberApiKey = `cal_test_${legacyOrgMemberKeyString}`;
const { keyString: pbacOrgUserWithRolePermissionKeyString } = await apiKeysRepositoryFixture.createApiKey(
pbacOrgUserWithRolePermission.id,
null
);
pbacOrgUserWithRolePermissionApiKey = `cal_test_${pbacOrgUserWithRolePermissionKeyString}`;
const { keyString: pbacOrgUserWithoutRolePermissionKeyString } =
await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithoutRolePermission.id, null);
pbacOrgUserWithoutRolePermissionApiKey = `cal_test_${pbacOrgUserWithoutRolePermissionKeyString}`;
const { keyString: nonOrgUserKeyString } = await apiKeysRepositoryFixture.createApiKey(
nonOrgUser.id,
null
);
nonOrgUserApiKey = `cal_test_${nonOrgUserKeyString}`;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("Role Creation Authorization", () => {
beforeEach(() => {
jest.restoreAllMocks();
});
describe("Positive Tests", () => {
it("should allow role creation when organization has PBAC enabled and user has a create permission", async () => {
const createRoleInput: CreateOrgRoleInput = {
name: "Test Role PBAC",
permissions: ["booking.read", "eventType.create"],
};
const pbacSpyCanActivate = jest.spyOn(PbacGuard.prototype, "canActivate");
const pbacSpyHasPbacEnabled = jest.spyOn(
PbacGuard.prototype as unknown as PbacGuard,
"hasPbacEnabled"
);
const rolesSpyCanActivate = jest.spyOn(RolesGuard.prototype, "canActivate");
const rolesSpyCheckUserRoleAccess = jest.spyOn(
RolesGuard.prototype as unknown as RolesGuard,
"checkUserRoleAccess"
);
return request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(createRoleInput)
.expect(201)
.then(async (response) => {
const responseBody: CreateOrgRoleOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.name).toEqual(createRoleInput.name);
expect(responseBody.data.permissions).toEqual(createRoleInput.permissions);
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
expect(pbacSpyCanActivate).toHaveBeenCalled();
expect(pbacSpyHasPbacEnabled).toHaveBeenCalled();
expect(rolesSpyCanActivate).toHaveBeenCalled();
const pbacCanActivateResult = await pbacSpyCanActivate.mock.results[0].value;
const rolesCanActivateResult = await rolesSpyCanActivate.mock.results[0].value;
const hasPbac = await pbacSpyHasPbacEnabled.mock.results[0].value;
expect(pbacCanActivateResult).toBe(true);
expect(rolesCanActivateResult).toBe(true);
expect(hasPbac).toBe(true);
expect(rolesSpyCheckUserRoleAccess).not.toHaveBeenCalled();
});
});
it("should allow role creation when organization does not have PBAC enabled and user is org admin", async () => {
const createRoleInput: CreateOrgRoleInput = {
name: "Test Role Legacy Admin",
permissions: ["booking.read"],
};
const pbacSpyCanActivate = jest.spyOn(PbacGuard.prototype, "canActivate");
const pbacSpyHasPbacEnabled = jest.spyOn(
PbacGuard.prototype as unknown as PbacGuard,
"hasPbacEnabled"
);
const rolesSpyCanActivate = jest.spyOn(RolesGuard.prototype, "canActivate");
const rolesSpyCheckUserRoleAccess = jest.spyOn(
RolesGuard.prototype as unknown as RolesGuard,
"checkUserRoleAccess"
);
return request(app.getHttpServer())
.post(`/v2/organizations/${organization.id}/roles`)
.set("Authorization", `Bearer ${legacyOrgAdminApiKey}`)
.send(createRoleInput)
.expect(201)
.then(async (response) => {
const responseBody: CreateOrgRoleOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.name).toEqual(createRoleInput.name);
expect(responseBody.data.organizationId).toEqual(organization.id);
expect(pbacSpyCanActivate).toHaveBeenCalled();
expect(rolesSpyCanActivate).toHaveBeenCalled();
expect(pbacSpyHasPbacEnabled).toHaveBeenCalled();
const pbacCanActivateResult = await pbacSpyCanActivate.mock.results[0].value;
const rolesCanActivateResult = await rolesSpyCanActivate.mock.results[0].value;
const hasPbac = await pbacSpyHasPbacEnabled.mock.results[0].value;
expect(pbacCanActivateResult).toBe(true);
expect(rolesCanActivateResult).toBe(true);
expect(hasPbac).toBe(false);
expect(rolesSpyCheckUserRoleAccess).toHaveBeenCalled();
});
});
});
describe("Negative Tests", () => {
it("should not allow role creation when organization has PBAC enabled but user has no role assigned", async () => {
const createRoleInput: CreateOrgRoleInput = {
name: "Test Role No Role",
permissions: ["booking.read"],
};
const userWithNoRole = await userRepositoryFixture.create({
email: `no-role-user-${randomString()}@api.com`,
username: `no-role-user-${randomString()}@api.com`,
});
await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: userWithNoRole.id } },
team: { connect: { id: pbacEnabledOrganization.id } },
});
const { keyString: noRoleKeyString } = await apiKeysRepositoryFixture.createApiKey(
userWithNoRole.id,
null
);
const noRoleApiKey = `cal_test_${noRoleKeyString}`;
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${noRoleApiKey}`)
.send(createRoleInput);
expect(response.status).toBe(403);
expect(response.body.error.message).toBe(
`RolesGuard - user with id=${userWithNoRole.id} does not have the minimum required role=ORG_ADMIN within organization with id=${pbacEnabledOrganization.id}.`
);
});
it("should not allow role creation when organization has PBAC enabled but user role lacks required permission", async () => {
const createRoleInput: CreateOrgRoleInput = {
name: "Test Role No Permission",
permissions: ["booking.read"],
};
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithoutRolePermissionApiKey}`)
.send(createRoleInput);
expect(response.status).toBe(403);
expect(response.body.error.message).toBe(
`RolesGuard - user with id=${pbacOrgUserWithoutRolePermission.id} does not have the minimum required role=ORG_ADMIN within organization with id=${pbacEnabledOrganization.id}.`
);
});
it("should not allow role creation when organization does not have PBAC enabled and user has no membership", async () => {
const createRoleInput: CreateOrgRoleInput = {
name: "Test Role No Membership",
permissions: ["booking.read"],
};
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${organization.id}/roles`)
.set("Authorization", `Bearer ${nonOrgUserApiKey}`)
.send(createRoleInput);
expect(response.status).toBe(403);
expect(response.body.error.message).toBe(
`RolesGuard - User is not a member of the organization with id=${organization.id}.`
);
});
it("should not allow role creation when organization does not have PBAC enabled and user has member membership (not admin)", async () => {
const createRoleInput: CreateOrgRoleInput = {
name: "Test Role Member Only",
permissions: ["booking.read"],
};
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${organization.id}/roles`)
.set("Authorization", `Bearer ${legacyOrgMemberApiKey}`)
.send(createRoleInput);
expect(response.status).toBe(403);
expect(response.body.error.message).toBe(
`RolesGuard - user with id=${legacyOrgMemberUser.id} does not have the minimum required role=ORG_ADMIN within organization with id=${organization.id}.`
);
});
});
describe("CRUD Role Endpoints", () => {
let createdRoleId: string;
it("should create a role", async () => {
const createRoleInput: CreateOrgRoleInput = {
name: "CRUD Test Role",
permissions: ["booking.read", "eventType.create"],
};
return request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(createRoleInput)
.expect(201)
.then((response) => {
const responseBody: CreateOrgRoleOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.name).toEqual(createRoleInput.name);
expect(responseBody.data.permissions).toEqual(createRoleInput.permissions);
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
createdRoleId = responseBody.data.id;
});
});
it("should update role permissions and name", async () => {
const updateRoleInput: UpdateOrgRoleInput = {
name: "CRUD Test Role Updated",
permissions: ["booking.read", "eventType.read"],
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(updateRoleInput)
.expect(200)
.then((response) => {
const responseBody: UpdateOrgRoleOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(createdRoleId);
expect(responseBody.data.name).toEqual(updateRoleInput.name);
expect(responseBody.data.permissions).toEqual(updateRoleInput.permissions);
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
});
});
it("should fetch the role", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(200)
.then((response) => {
const responseBody: GetOrgRoleOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(createdRoleId);
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
});
});
it("should fetch all roles", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(200)
.then((response) => {
const responseBody: GetAllOrgRolesOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.find((r) => r.id === createdRoleId)).toBeDefined();
const created = responseBody.data.find((r) => r.id === createdRoleId);
expect(created?.organizationId).toEqual(pbacEnabledOrganization.id);
});
});
it("should delete the role", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${createdRoleId}`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(200)
.then((response) => {
const responseBody: DeleteOrgRoleOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(createdRoleId);
expect(responseBody.data.organizationId).toEqual(pbacEnabledOrganization.id);
});
});
describe("Negative error cases", () => {
it("should fail to create a role with a duplicate name (400)", async () => {
const name = `dup-role-${randomString()}`;
const firstCreate: CreateOrgRoleInput = { name, permissions: ["booking.read"] };
await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(firstCreate)
.expect(201);
const secondCreate: CreateOrgRoleInput = { name, permissions: ["booking.read"] };
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(secondCreate);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(`Role with name "${name}" already exists`);
});
it("should fail to create a role with invalid permissions (400)", async () => {
const createRoleInput = {
name: `invalid-perms-${randomString()}`,
permissions: ["invalid"],
};
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(createRoleInput);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
);
});
it("should return 404 when updating a role not belonging to the organization", async () => {
const defaultAdminRoleId = await roleService.getDefaultRoleId(MembershipRole.ADMIN);
const updateRoleInput: UpdateOrgRoleInput = {
name: `no-update-default-${randomString()}`,
permissions: ["booking.read"],
};
const response = await request(app.getHttpServer())
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultAdminRoleId}`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(updateRoleInput);
expect(response.status).toBe(404);
expect(response.body.error.message).toBe(
`Role with id ${defaultAdminRoleId} within team id ${pbacEnabledOrganization.id} not found`
);
});
it("should return 404 when updating a default (system) role not belonging to the organization", async () => {
const defaultAdminRoleId = await roleService.getDefaultRoleId(MembershipRole.ADMIN);
const updateRoleInput: UpdateOrgRoleInput = {
name: `no-update-default-${randomString()}`,
permissions: ["booking.read"],
};
const response = await request(app.getHttpServer())
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultAdminRoleId}`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(updateRoleInput);
expect(response.status).toBe(404);
expect(response.body.error.message).toBe(
`Role with id ${defaultAdminRoleId} within team id ${pbacEnabledOrganization.id} not found`
);
});
it("should fail to update with invalid permissions (400)", async () => {
// Create a fresh role to update
const { body: createRes } = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send({ name: `update-invalid-${randomString()}` as string, permissions: ["booking.read"] })
.expect(201);
const roleId: string = createRes.data.id;
const updateRoleInput = {
name: `update-invalid-${randomString()}`,
permissions: ["invalid"],
};
const response = await request(app.getHttpServer())
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(updateRoleInput);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
);
});
it("should return 404 when updating a role from a different organization", async () => {
// Create a role in a different org (legacy/non-PBAC org)
const foreignRole = await roleService.createRole({
name: `foreign-update-${randomString()}`,
teamId: organization.id,
permissions: ["booking.read"],
type: "CUSTOM",
});
const response = await request(app.getHttpServer())
.patch(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send({ permissions: ["eventType.read"] });
expect(response.status).toBe(404);
expect(response.body.error.message).toBe(
`Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found`
);
});
it("should return 404 when deleting a role not belonging to the organization", async () => {
const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER);
const response = await request(app.getHttpServer())
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
expect(response.status).toBe(404);
expect(response.body.error.message).toBe(
`Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found`
);
});
it("should return 404 when deleting a default (system) role not belonging to the organization", async () => {
const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER);
const response = await request(app.getHttpServer())
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
expect(response.status).toBe(404);
expect(response.body.error.message).toBe(
`Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found`
);
});
it("should return 404 when deleting a role from a different organization", async () => {
// Create a role in a different org
const foreignRole = await roleService.createRole({
name: `foreign-delete-${randomString()}`,
teamId: organization.id,
permissions: ["booking.read"],
type: "CUSTOM",
});
const response = await request(app.getHttpServer())
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
expect(response.status).toBe(404);
expect(response.body.error.message).toBe(
`Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found`
);
});
});
});
});
afterAll(async () => {
try {
await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledOrganization.id, "pbac");
await organizationsRepositoryFixture.delete(organization.id);
await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id);
await userRepositoryFixture.deleteByEmail(legacyOrgAdminUser.email);
await userRepositoryFixture.deleteByEmail(legacyOrgMemberUser.email);
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email);
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithoutRolePermission.email);
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
try {
await featuresRepositoryFixture.deleteBySlug("pbac");
} catch (error) {
console.error("Cleanup error:", error);
}
} catch (error) {
console.error("Cleanup error:", error);
} finally {
await app.close();
}
});
});
@@ -0,0 +1,145 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
OPTIONAL_API_KEY_HEADER,
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
} from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { CreateOrgRoleInput } from "@/modules/organizations/roles/inputs/create-org-role.input";
import { UpdateOrgRoleInput } from "@/modules/organizations/roles/inputs/update-org-role.input";
import { CreateOrgRoleOutput } from "@/modules/organizations/roles/outputs/create-org-role.output";
import { DeleteOrgRoleOutput } from "@/modules/organizations/roles/outputs/delete-org-role.output";
import { GetAllOrgRolesOutput } from "@/modules/organizations/roles/outputs/get-all-org-roles.output";
import { GetOrgRoleOutput } from "@/modules/organizations/roles/outputs/get-org-role.output";
import { UpdateOrgRoleOutput } from "@/modules/organizations/roles/outputs/update-org-role.output";
import { OrganizationsRolesOutputService } from "@/modules/organizations/roles/services/organizations-roles-output.service";
import { RolesService } from "@/modules/roles/services/roles.service";
import {
Controller,
UseGuards,
Get,
Param,
ParseIntPipe,
Query,
Delete,
Patch,
Post,
Body,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId/roles",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, PbacGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Roles")
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsRolesController {
constructor(
private readonly rolesService: RolesService,
private readonly rolesOutputService: OrganizationsRolesOutputService
) {}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.create"])
@Post("/")
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: "Create a new organization role" })
async createRole(
@Param("orgId", ParseIntPipe) orgId: number,
@Body() body: CreateOrgRoleInput
): Promise<CreateOrgRoleOutput> {
const role = await this.rolesService.createRole(orgId, body);
return {
status: SUCCESS_STATUS,
data: this.rolesOutputService.getOrganizationRoleOutput(role),
};
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.read"])
@Get("/:roleId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get a specific organization role" })
async getRole(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("roleId") roleId: string
): Promise<GetOrgRoleOutput> {
const role = await this.rolesService.getRole(orgId, roleId);
return {
status: SUCCESS_STATUS,
data: this.rolesOutputService.getOrganizationRoleOutput(role),
};
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.read"])
@Get("/")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get all organization roles" })
async getAllRoles(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetAllOrgRolesOutput> {
const { skip, take } = queryParams;
const roles = await this.rolesService.getTeamRoles(orgId, skip ?? 0, take ?? 250);
return {
status: SUCCESS_STATUS,
data: this.rolesOutputService.getOrganizationRolesOutput(roles),
};
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.update"])
@Patch("/:roleId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Update an organization role" })
async updateRole(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("roleId") roleId: string,
@Body() body: UpdateOrgRoleInput
): Promise<UpdateOrgRoleOutput> {
const role = await this.rolesService.updateRole(orgId, roleId, body);
return {
status: SUCCESS_STATUS,
data: this.rolesOutputService.getOrganizationRoleOutput(role),
};
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.delete"])
@Delete("/:roleId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Delete an organization role" })
async deleteRole(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("roleId") roleId: string
): Promise<DeleteOrgRoleOutput> {
const role = await this.rolesService.deleteRole(orgId, roleId);
return {
status: SUCCESS_STATUS,
data: this.rolesOutputService.getOrganizationRoleOutput(role),
};
}
}
@@ -0,0 +1,17 @@
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsRolesPermissionsController } from "@/modules/organizations/roles/permissions/organizations-roles-permissions.controller";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RolesModule } from "@/modules/roles/roles.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { Module } from "@nestjs/common";
import { OrganizationsRolesController } from "./organizations-roles.controller";
@Module({
imports: [StripeModule, PrismaModule, RedisModule, MembershipsModule, RolesModule],
providers: [OrganizationsRepository],
controllers: [OrganizationsRolesController, OrganizationsRolesPermissionsController],
})
export class OrganizationsRolesModule {}
@@ -0,0 +1,20 @@
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class CreateOrgRoleOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: OrgRoleOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => OrgRoleOutput)
data!: OrgRoleOutput;
}
@@ -0,0 +1,20 @@
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class DeleteOrgRoleOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: OrgRoleOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => OrgRoleOutput)
data!: OrgRoleOutput;
}
@@ -0,0 +1,20 @@
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsArray, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class GetAllOrgRolesOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: [OrgRoleOutput],
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrgRoleOutput)
data!: OrgRoleOutput[];
}
@@ -0,0 +1,20 @@
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class GetOrgRoleOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: OrgRoleOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => OrgRoleOutput)
data!: OrgRoleOutput;
}
@@ -0,0 +1,71 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsString, IsOptional, IsArray, IsEnum, IsDateString, IsNumber } from "class-validator";
import { orgPermissionEnum } from "../inputs/base-org-role.input";
enum RoleTypeEnum {
SYSTEM = "SYSTEM",
CUSTOM = "CUSTOM",
}
type RoleType = keyof typeof RoleTypeEnum;
export class OrgRoleOutput {
@ApiProperty({ description: "Unique identifier for the role" })
@IsString()
@Expose()
id!: string;
@ApiProperty({ description: "Name of the role" })
@IsString()
@Expose()
name!: string;
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
@IsString()
@IsOptional()
@Expose()
color?: string | null;
@ApiPropertyOptional({ description: "Description of the role" })
@IsString()
@IsOptional()
@Expose()
description?: string | null;
@ApiPropertyOptional({ description: "Organization ID this role belongs to" })
@IsNumber()
@IsOptional()
@Expose()
organizationId?: number | null;
@ApiProperty({
description: "Type of role",
enum: RoleTypeEnum,
})
@IsEnum(RoleTypeEnum)
@Expose()
type!: RoleType;
@ApiProperty({
description: "Permissions assigned to this role in 'resource.action' format.",
enum: orgPermissionEnum,
isArray: true,
example: ["booking.read", "eventType.create"],
})
@IsArray()
@IsString({ each: true })
@Expose()
permissions!: string[];
@ApiProperty({ description: "When the role was created" })
@IsDateString()
@Expose()
createdAt!: string;
@ApiProperty({ description: "When the role was last updated" })
@IsDateString()
@Expose()
updatedAt!: string;
}
@@ -0,0 +1,20 @@
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class UpdateOrgRoleOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: OrgRoleOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => OrgRoleOutput)
data!: OrgRoleOutput;
}
@@ -0,0 +1,20 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsArray, IsString, Validate } from "class-validator";
import type { PermissionString } from "@calcom/platform-libraries/pbac";
import { orgPermissionEnum } from "../../inputs/base-org-role.input";
import { OrgPermissionStringValidator } from "./validators/org-permission-string.validator";
export class CreateOrgRolePermissionsInput {
@ApiProperty({
description: "Permissions to add (format: resource.action)",
enum: orgPermissionEnum,
isArray: true,
example: ["eventType.read", "booking.read"],
})
@IsArray()
@IsString({ each: true })
@Validate(OrgPermissionStringValidator, { each: true })
permissions!: PermissionString[];
}
@@ -0,0 +1,33 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { ArrayNotEmpty, IsArray, IsOptional, IsString, Validate } from "class-validator";
import type { PermissionString } from "@calcom/platform-libraries/pbac";
import { orgPermissionEnum } from "../../inputs/base-org-role.input";
import { OrgPermissionStringValidator } from "./validators/org-permission-string.validator";
export class DeleteOrgRolePermissionsQuery {
@ApiPropertyOptional({
description:
"Permissions to remove (format: resource.action). Supports comma-separated values as well as repeated query params.",
example: "?permissions=eventType.read,booking.read",
enum: orgPermissionEnum,
isArray: true,
})
@IsOptional()
@Transform(({ value }) => {
if (typeof value === "string") {
return value
.split(",")
.map((p: string) => p.trim())
.filter((p: string) => p.length > 0);
}
return value;
})
@IsArray()
@ArrayNotEmpty({ message: "permissions cannot be empty." })
@IsString({ each: true })
@Validate(OrgPermissionStringValidator, { each: true })
permissions?: PermissionString[];
}
@@ -0,0 +1,71 @@
import { BadRequestException } from "@nestjs/common";
import { OrgPermissionStringValidator } from "./org-permission-string.validator";
describe("PermissionStringValidator", () => {
let validator: OrgPermissionStringValidator;
beforeEach(() => {
validator = new OrgPermissionStringValidator();
});
describe("validate", () => {
it("should return true for valid permission strings", () => {
const validPermissions = [
"eventType.read",
"eventType.create",
"eventType.update",
"eventType.delete",
"booking.read",
"booking.update",
"role.create",
"role.read",
"role.update",
"role.delete",
"team.read",
"team.invite",
"organization.read",
"organization.listMembers",
// no wildcards here
];
validPermissions.forEach((permission) => {
expect(validator.validate(permission)).toBe(true);
});
});
it("should throw for invalid permission strings", () => {
const invalidPermissions = [
"invalid", // no dot
"invalid.", // no action
".invalid", // no resource
"invalid.action", // invalid resource
"eventType.invalid", // invalid action
"event-type.read", // wrong format (should be eventType)
"", // empty string
"eventType..read", // double dot
"eventType.read.extra", // too many parts
];
invalidPermissions.forEach((permission) => {
expect(() => validator.validate(permission)).toThrow(BadRequestException);
});
});
it("should throw for wildcard permission strings", () => {
const wildcardPermissions = ["*", "*.read", "eventType.*"];
wildcardPermissions.forEach((permission) => {
expect(() => validator.validate(permission)).toThrow(BadRequestException);
});
});
});
describe("defaultMessage", () => {
it("should return appropriate error message", () => {
const message = validator.defaultMessage();
expect(message).toContain("Permission must be a valid permission string");
expect(message).toContain("resource.action");
});
});
});
@@ -0,0 +1,23 @@
import { BadRequestException } from "@nestjs/common";
import type { ValidatorConstraintInterface } from "class-validator";
import { ValidatorConstraint } from "class-validator";
import { isValidPermissionStringForScope } from "@calcom/platform-libraries/pbac";
import { Scope } from "@calcom/platform-libraries/pbac";
@ValidatorConstraint({ name: "orgPermissionStringValidator", async: false })
export class OrgPermissionStringValidator implements ValidatorConstraintInterface {
validate(permission: string) {
const isValid = isValidPermissionStringForScope(permission, Scope.Organization);
if (!isValid) {
throw new BadRequestException(
`Permission '${permission}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')`
);
}
return true;
}
defaultMessage() {
return "Permission must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')";
}
}
@@ -0,0 +1,395 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
import type { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { RoleService } from "@calcom/platform-libraries/pbac";
import type { PermissionString } from "@calcom/platform-libraries/pbac";
import type { Team, User } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
describe("Organizations Roles Permissions Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let featuresRepositoryFixture: FeaturesRepositoryFixture;
let roleService: RoleService;
let pbacOrgUserWithRolePermission: User;
let pbacOrgUserWithRolePermissionApiKey: string;
let pbacEnabledOrganization: Team;
const pbacUserEmail = `pbac-org-user-with-permissions-${randomString()}@api.com`;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
}).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef);
roleService = new RoleService();
// Create PBAC org
pbacEnabledOrganization = await organizationsRepositoryFixture.create({
name: `pbac-org-role-perm-test-${randomString()}`,
isOrganization: true,
});
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledOrganization.id, "pbac");
// Create user + membership in org
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
email: pbacUserEmail,
username: pbacUserEmail,
});
const membership = await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: pbacOrgUserWithRolePermission.id } },
team: { connect: { id: pbacEnabledOrganization.id } },
});
// Create a role that allows role.read and role.update, assign to user
const managerRole = await roleService.createRole({
name: `role-manager-${randomString()}`,
teamId: pbacEnabledOrganization.id,
permissions: ["role.create", "role.read", "role.update"],
type: "CUSTOM",
});
await roleService.assignRoleToMember(managerRole.id, membership.id);
// API key for user
const { keyString } = await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithRolePermission.id, null);
pbacOrgUserWithRolePermissionApiKey = `cal_test_${keyString}`;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("Negative Tests", () => {
it("rejects invalid permission on add (400)", async () => {
const baseRoleInput: CreateTeamRoleInput = {
name: `neg-add-invalid-${randomString()}`,
permissions: ["booking.read"],
};
const createRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(baseRoleInput)
.expect(201);
const roleId = createRes.body.data.id as string;
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send({ permissions: ["invalid"] });
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
);
});
it("rejects invalid permission on replace (400)", async () => {
const baseRoleInput: CreateTeamRoleInput = {
name: `neg-put-invalid-${randomString()}`,
permissions: ["booking.read"],
};
const createRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(baseRoleInput)
.expect(201);
const roleId = createRes.body.data.id as string;
const response = await request(app.getHttpServer())
.put(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send({ permissions: ["invalid"] });
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
);
});
it("rejects invalid permission on delete single (400)", async () => {
const baseRoleInput: CreateTeamRoleInput = {
name: `neg-delone-invalid-${randomString()}`,
permissions: ["booking.read"],
};
const createRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(baseRoleInput)
.expect(201);
const roleId = createRes.body.data.id as string;
const response = await request(app.getHttpServer())
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions/${"invalid"}`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
);
});
it("rejects invalid permission on bulk delete (400)", async () => {
const baseRoleInput: CreateTeamRoleInput = {
name: `neg-delmany-invalid-${randomString()}`,
permissions: ["booking.read", "eventType.create"],
};
const createRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(baseRoleInput)
.expect(201);
const roleId = createRes.body.data.id as string;
const invalid = "invalid";
const res = await request(app.getHttpServer())
.delete(
`/v2/organizations/${
pbacEnabledOrganization.id
}/roles/${roleId}/permissions?permissions=${encodeURIComponent(`booking.read,${invalid}`)}`
)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`);
expect(res.status).toBe(400);
expect(res.body.error.message).toBe(
"Permission 'invalid' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')"
);
});
it("returns 404 when modifying permissions for role not belonging to organization", async () => {
// Create foreign organization and role
const foreignOrg = await organizationsRepositoryFixture.create({
name: `foreign-org-${randomString()}`,
isOrganization: true,
});
const foreignRole = await roleService.createRole({
name: `foreign-role-${randomString()}`,
teamId: foreignOrg.id,
permissions: ["booking.read"],
type: "CUSTOM",
});
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${foreignRole.id}/permissions`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send({ permissions: ["eventType.read"] });
expect(response.status).toBe(404);
expect(response.body.error.message).toBe(
`Role with id ${foreignRole.id} within team id ${pbacEnabledOrganization.id} not found`
);
await organizationsRepositoryFixture.delete(foreignOrg.id);
});
it("returns 404 when modifying permissions of a default (system) role not belonging to organization", async () => {
const defaultMemberRoleId = await roleService.getDefaultRoleId(MembershipRole.MEMBER);
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${defaultMemberRoleId}/permissions`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send({ permissions: ["eventType.read"] });
expect(response.status).toBe(404);
expect(response.body.error.message).toBe(
`Role with id ${defaultMemberRoleId} within team id ${pbacEnabledOrganization.id} not found`
);
});
});
it("lists permissions for a role (GET /)", async () => {
const initialPermissions = ["booking.read"] as const;
const baseRoleInput: CreateTeamRoleInput = {
name: `perm-target-role-list-${randomString()}`,
permissions: [...initialPermissions],
};
const createRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(baseRoleInput)
.expect(201);
const responseBody: CreateTeamRoleOutput = createRes.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
const roleId = responseBody.data.id;
const listRes = await request(app.getHttpServer())
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(200);
expect(listRes.body.status).toEqual(SUCCESS_STATUS);
expect(listRes.body.data).toEqual(initialPermissions);
});
it("adds permissions (POST /)", async () => {
const initialPermissions = ["booking.read"] as const;
const toAdd = ["eventType.create", "eventType.read"] as const;
const expected = [...initialPermissions, ...toAdd] as string[];
const baseRoleInput: CreateTeamRoleInput = {
name: `perm-target-role-add-${randomString()}`,
permissions: [...initialPermissions],
};
const createRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(baseRoleInput)
.expect(201);
const responseBody: CreateTeamRoleOutput = createRes.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
const roleId = responseBody.data.id;
const addRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send({ permissions: toAdd })
.expect(200);
expect(addRes.body.status).toEqual(SUCCESS_STATUS);
expect(addRes.body.data).toEqual(expected);
});
it("bulk removes permissions via query (DELETE /)", async () => {
const initialPermissions: PermissionString[] = ["booking.read", "eventType.create", "eventType.read"];
const toRemove: PermissionString[] = ["eventType.create", "eventType.read"];
const expected: PermissionString[] = ["booking.read"];
const baseRoleInput: CreateTeamRoleInput = {
name: `perm-target-role-delmany-${randomString()}`,
permissions: initialPermissions,
};
const createRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(baseRoleInput)
.expect(201);
const responseBody: CreateTeamRoleOutput = createRes.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
const roleId = responseBody.data.id;
await request(app.getHttpServer())
.delete(
`/v2/organizations/${
pbacEnabledOrganization.id
}/roles/${roleId}/permissions?permissions=${toRemove.join(",")}`
)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(204);
const listRes = await request(app.getHttpServer())
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(200);
expect(listRes.body.data).toEqual(expected);
});
it("replaces all permissions (PUT /)", async () => {
const initialPermissions = ["booking.read"] as const;
const replacement = ["booking.read", "eventType.update"] as const;
const baseRoleInput: CreateTeamRoleInput = {
name: `perm-target-role-put-${randomString()}`,
permissions: [...initialPermissions],
};
const createRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(baseRoleInput)
.expect(201);
const responseBody: CreateTeamRoleOutput = createRes.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
const roleId = responseBody.data.id;
const putRes = await request(app.getHttpServer())
.put(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send({ permissions: replacement })
.expect(200);
expect(putRes.body.status).toEqual(SUCCESS_STATUS);
expect(putRes.body.data).toEqual(replacement);
});
it("removes a single permission (DELETE /:permission)", async () => {
const initialPermissions = ["booking.read", "eventType.update"] as const;
const toRemove = "eventType.update" as const;
const expected = ["booking.read"] as const;
const baseRoleInput: CreateTeamRoleInput = {
name: `perm-target-role-delone-${randomString()}`,
permissions: [...initialPermissions],
};
const createRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(baseRoleInput)
.expect(201);
const responseBody: CreateTeamRoleOutput = createRes.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
const roleId = responseBody.data.id;
await request(app.getHttpServer())
.delete(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions/${toRemove}`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(204);
const listRes = await request(app.getHttpServer())
.get(`/v2/organizations/${pbacEnabledOrganization.id}/roles/${roleId}/permissions`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(200);
expect(listRes.body.data).toEqual(expected);
});
afterAll(async () => {
try {
// Clean up feature flag from organization
await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledOrganization.id, "pbac");
// Clean up org
await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id);
// Clean up user
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email);
// Clean up feature definition
try {
await featuresRepositoryFixture.deleteBySlug("pbac");
} catch (err) {
console.log(err);
}
} catch (err) {
console.log(err);
} finally {
await app.close();
}
});
});
@@ -0,0 +1,126 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
OPTIONAL_API_KEY_HEADER,
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
} from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { CreateOrgRolePermissionsInput } from "@/modules/organizations/roles/permissions/inputs/create-org-role-permissions.input";
import { DeleteOrgRolePermissionsQuery } from "@/modules/organizations/roles/permissions/inputs/delete-org-role-permissions.query";
import { GetOrgRolePermissionsOutput } from "@/modules/organizations/roles/permissions/outputs/get-org-role-permissions.output";
import { RolesPermissionsService } from "@/modules/roles/permissions/services/roles-permissions.service";
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Put,
Query,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { PermissionString } from "@calcom/platform-libraries/pbac";
@Controller({
path: "/v2/organizations/:orgId/roles/:roleId/permissions",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, PbacGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Roles / Permissions")
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsRolesPermissionsController {
constructor(private readonly rolePermissionsService: RolesPermissionsService) {}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.update"])
@Post("/")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Add permissions to an organization role (single or batch)" })
async addPermissions(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("roleId") roleId: string,
@Body() body: CreateOrgRolePermissionsInput
): Promise<GetOrgRolePermissionsOutput> {
const permissions = await this.rolePermissionsService.addRolePermissions(orgId, roleId, body.permissions);
return { status: SUCCESS_STATUS, data: permissions };
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.read"])
@Get("/")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "List permissions for an organization role" })
async listPermissions(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("roleId") roleId: string
): Promise<GetOrgRolePermissionsOutput> {
const permissions = await this.rolePermissionsService.getRolePermissions(orgId, roleId);
return { status: SUCCESS_STATUS, data: permissions };
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.update"])
@Put("/")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Replace all permissions for an organization role" })
async setPermissions(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("roleId") roleId: string,
@Body() body: CreateOrgRolePermissionsInput
): Promise<GetOrgRolePermissionsOutput> {
const permissions = await this.rolePermissionsService.setRolePermissions(
orgId,
roleId,
body.permissions || []
);
return { status: SUCCESS_STATUS, data: permissions };
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.update"])
@Delete("/:permission")
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: "Remove a permission from an organization role" })
async removePermission(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("roleId") roleId: string,
@Param("permission") permission: PermissionString
): Promise<void> {
await this.rolePermissionsService.removeRolePermission(orgId, roleId, permission);
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.update"])
@Delete("/")
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: "Remove multiple permissions from an organization role" })
async removePermissions(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("roleId") roleId: string,
@Query() query: DeleteOrgRolePermissionsQuery
): Promise<void> {
await this.rolePermissionsService.removeRolePermissions(orgId, roleId, query.permissions || []);
}
}
@@ -0,0 +1,14 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsArray, IsString } from "class-validator";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
export class GetOrgRolePermissionsOutput {
@ApiProperty({ example: SUCCESS_STATUS })
status!: typeof SUCCESS_STATUS;
@ApiProperty({ type: [String] })
@IsArray()
@IsString({ each: true })
data!: string[];
}
@@ -0,0 +1,26 @@
import { OrgRoleOutput } from "@/modules/organizations/roles/outputs/org-role.output";
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
import { Injectable } from "@nestjs/common";
import type { Role } from "@calcom/platform-libraries/pbac";
@Injectable()
export class OrganizationsRolesOutputService {
getOrganizationRoleOutput(role: Role): OrgRoleOutput {
return {
id: role.id,
name: role.name,
color: role.color || null,
description: role.description || null,
organizationId: role.teamId || null,
type: role.type,
permissions: role.permissions.map((permission) => `${permission.resource}.${permission.action}`),
createdAt: role.createdAt.toISOString(),
updatedAt: role.updatedAt.toISOString(),
};
}
getOrganizationRolesOutput(roles: Role[]): TeamRoleOutput[] {
return roles.map((role) => this.getOrganizationRoleOutput(role));
}
}
@@ -418,6 +418,209 @@ describe("Organizations Teams Memberships Endpoints", () => {
.expect(404);
});
// Auto-accept tests
describe("auto-accept based on email domain", () => {
let orgWithAutoAccept: Team;
let subteamWithAutoAccept: Team;
let userWithMatchingEmail: User;
let userWithUppercaseEmail: User;
let userWithMatchingEmailForOverride: User;
let userWithNonMatchingEmail: User;
beforeAll(async () => {
// Create org with auto-accept settings
orgWithAutoAccept = await organizationsRepositoryFixture.create({
name: `auto-accept-org-${randomString()}`,
isOrganization: true,
});
// Update organizationSettings with orgAutoAcceptEmail
await organizationsRepositoryFixture.updateSettings(orgWithAutoAccept.id, {
orgAutoAcceptEmail: "acme.com",
isOrganizationVerified: true,
isOrganizationConfigured: true,
});
// Create subteam
subteamWithAutoAccept = await teamsRepositoryFixture.create({
name: `auto-accept-subteam-${randomString()}`,
isOrganization: false,
parent: { connect: { id: orgWithAutoAccept.id } },
});
// Create event type with assignAllTeamMembers
await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: { connect: { id: subteamWithAutoAccept.id } },
title: "Auto Accept Event Type",
slug: "auto-accept-event-type",
length: 30,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
// Create users
userWithMatchingEmail = await userRepositoryFixture.create({
email: `alice@acme.com`,
username: `alice-${randomString()}`,
});
userWithUppercaseEmail = await userRepositoryFixture.create({
email: `bob@ACME.COM`,
username: `bob-${randomString()}`,
});
userWithMatchingEmailForOverride = await userRepositoryFixture.create({
email: `david@acme.com`,
username: `david-${randomString()}`,
});
userWithNonMatchingEmail = await userRepositoryFixture.create({
email: `charlie@external.com`,
username: `charlie-${randomString()}`,
});
// Add users to org
await membershipsRepositoryFixture.create({
role: "MEMBER",
accepted: true,
user: { connect: { id: userWithMatchingEmail.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
accepted: true,
user: { connect: { id: userWithUppercaseEmail.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
accepted: true,
user: { connect: { id: userWithMatchingEmailForOverride.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
accepted: true,
user: { connect: { id: userWithNonMatchingEmail.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});
// Create profiles for users
await profileRepositoryFixture.create({
uid: `usr-${userWithMatchingEmail.id}`,
username: userWithMatchingEmail.username || `user-${userWithMatchingEmail.id}`,
organization: { connect: { id: orgWithAutoAccept.id } },
user: { connect: { id: userWithMatchingEmail.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${userWithUppercaseEmail.id}`,
username: userWithUppercaseEmail.username || `user-${userWithUppercaseEmail.id}`,
organization: { connect: { id: orgWithAutoAccept.id } },
user: { connect: { id: userWithUppercaseEmail.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${userWithMatchingEmailForOverride.id}`,
username: userWithMatchingEmailForOverride.username || `user-${userWithMatchingEmailForOverride.id}`,
organization: { connect: { id: orgWithAutoAccept.id } },
user: { connect: { id: userWithMatchingEmailForOverride.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${userWithNonMatchingEmail.id}`,
username: userWithNonMatchingEmail.username || `user-${userWithNonMatchingEmail.id}`,
organization: { connect: { id: orgWithAutoAccept.id } },
user: { connect: { id: userWithNonMatchingEmail.id } },
});
// Make user an admin of the org for API access
await membershipsRepositoryFixture.create({
role: "ADMIN",
accepted: true,
user: { connect: { id: user.id } },
team: { connect: { id: orgWithAutoAccept.id } },
});
});
it("should auto-accept when email matches orgAutoAcceptEmail", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
.send({
userId: userWithMatchingEmail.id,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(201);
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.data.accepted).toBe(true);
// Verify EventTypes assignment
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(
subteamWithAutoAccept.id
);
const eventTypeWithAssignAll = eventTypes.find((et) => et.assignAllTeamMembers);
expect(eventTypeWithAssignAll).toBeTruthy();
const userIsHost = eventTypeWithAssignAll?.hosts.some((h) => h.userId === userWithMatchingEmail.id);
expect(userIsHost).toBe(true);
});
it("should handle case-insensitive email domain matching", async () => {
// User with email="bob@ACME.COM" should match orgAutoAcceptEmail="acme.com"
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
.send({
userId: userWithUppercaseEmail.id,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(201);
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.data.accepted).toBe(true);
});
it("should ALWAYS auto-accept when email matches, even if accepted:false", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
.send({
userId: userWithMatchingEmailForOverride.id,
role: "MEMBER",
accepted: false,
} satisfies CreateOrgTeamMembershipDto)
.expect(201);
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
// Should override to true because email matches
expect(responseBody.data.accepted).toBe(true);
});
it("should NOT auto-accept when email does not match orgAutoAcceptEmail", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${orgWithAutoAccept.id}/teams/${subteamWithAutoAccept.id}/memberships`)
.send({
userId: userWithNonMatchingEmail.id,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(201);
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.data.accepted).toBe(false);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(userWithMatchingEmail.email);
await userRepositoryFixture.deleteByEmail(userWithUppercaseEmail.email);
await userRepositoryFixture.deleteByEmail(userWithMatchingEmailForOverride.email);
await userRepositoryFixture.deleteByEmail(userWithNonMatchingEmail.email);
await organizationsRepositoryFixture.delete(orgWithAutoAccept.id);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
@@ -12,6 +12,7 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { OrganizationMembershipService } from "@/lib/services/organization-membership.service";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
@@ -58,8 +59,9 @@ export class OrganizationsTeamsMembershipsController {
constructor(
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
private readonly organizationsRepository: OrganizationsRepository
) {}
private readonly organizationsRepository: OrganizationsRepository,
private readonly orgMembershipService: OrganizationMembershipService
) { }
@Get("/")
@ApiOperation({ summary: "Get all memberships" })
@@ -168,6 +170,9 @@ export class OrganizationsTeamsMembershipsController {
};
}
// TODO: Refactor to use inviteMembersWithNoInviterPermissionCheck when it is moved to a Service
// See: packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/")
@@ -184,7 +189,21 @@ export class OrganizationsTeamsMembershipsController {
throw new UnprocessableEntityException("User is not part of the Organization");
}
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data);
const shouldAutoAccept = await this.orgMembershipService.shouldAutoAccept({
organizationId: orgId,
userEmail: user.email,
});
// ALWAYS override when email matches - prevents pending memberships
// Remember organizations expect added team member to automatically start receiving bookings for the team event
const acceptedStatus = shouldAutoAccept ? true : (data.accepted ?? false);
const membershipData = { ...data, accepted: acceptedStatus };
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(
teamId,
membershipData
);
if (membership.accepted) {
try {
await updateNewTeamMemberEventTypes(user.id, teamId);
@@ -0,0 +1,34 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsArray, IsOptional, IsString, Validate } from "class-validator";
import type { PermissionString } from "@calcom/platform-libraries/pbac";
import { getAllPermissionStringsForScope, Scope } from "@calcom/platform-libraries/pbac";
import { TeamPermissionStringValidator } from "../permissions/inputs/validators/team-permission-string.validator";
export const teamPermissionEnum = [...getAllPermissionStringsForScope(Scope.Team)] as const;
export class BaseTeamRoleInput {
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
@IsString()
@IsOptional()
color?: string;
@ApiPropertyOptional({ description: "Description of the role" })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional({
description:
"Permissions for this role (format: resource.action). On update, this field replaces the entire permission set for the role (full replace). Use granular permission endpoints for one-by-one changes.",
enum: teamPermissionEnum,
isArray: true,
example: ["eventType.read", "eventType.create", "booking.read"],
})
@IsArray()
@IsString({ each: true })
@Validate(TeamPermissionStringValidator, { each: true })
@IsOptional()
permissions?: PermissionString[];
}
@@ -0,0 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, MinLength } from "class-validator";
import { BaseTeamRoleInput } from "./base-team-role.input";
export class CreateTeamRoleInput extends BaseTeamRoleInput {
@ApiProperty({ description: "Name of the role", minLength: 1 })
@IsString()
@MinLength(1)
name!: string;
}
@@ -0,0 +1,12 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsString, IsOptional, MinLength } from "class-validator";
import { BaseTeamRoleInput } from "./base-team-role.input";
export class UpdateTeamRoleInput extends BaseTeamRoleInput {
@ApiPropertyOptional({ description: "Name of the role", minLength: 1 })
@IsString()
@MinLength(1)
@IsOptional()
name?: string;
}
@@ -0,0 +1,469 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
import { UpdateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/update-team-role.input";
import { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output";
import { DeleteTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/delete-team-role.output";
import { GetAllTeamRolesOutput } from "@/modules/organizations/teams/roles/outputs/get-all-team-roles.output";
import { GetTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/get-team-role.output";
import { UpdateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/update-team-role.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { RoleService } from "@calcom/platform-libraries/pbac";
import type { User, Team } from "@calcom/prisma/client";
describe("Organizations Roles Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let featuresRepositoryFixture: FeaturesRepositoryFixture;
let roleService: RoleService;
// Test users
let legacyOrgAdminUser: User;
let legacyOrgMemberUser: User;
let pbacOrgUserWithRolePermission: User;
let pbacOrgUserWithoutRolePermission: User;
let nonOrgUser: User;
// API Keys
let legacyOrgAdminApiKey: string;
let legacyOrgMemberApiKey: string;
let pbacOrgUserWithRolePermissionApiKey: string;
let pbacOrgUserWithoutRolePermissionApiKey: string;
let nonOrgUserApiKey: string;
// Organization and team
let organization: Team;
let team: Team;
let pbacEnabledOrganization: Team;
let pbacEnabledTeam: Team;
const legacyOrgAdminEmail = `legacy-org-admin-${randomString()}@api.com`;
const legacyOrgMemberEmail = `legacy-org-member-${randomString()}@api.com`;
const pbacOrgUserWithRolePermissionEmail = `pbac-org-user-with-role-permission-${randomString()}@api.com`;
const pbacOrgUserWithoutRolePermissionEmail = `pbac-org-user-without-role-permission-${randomString()}@api.com`;
const nonOrgUserEmail = `non-org-user-${randomString()}@api.com`;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
}).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef);
roleService = new RoleService();
// Create test users
legacyOrgAdminUser = await userRepositoryFixture.create({
email: legacyOrgAdminEmail,
username: legacyOrgAdminEmail,
});
legacyOrgMemberUser = await userRepositoryFixture.create({
email: legacyOrgMemberEmail,
username: legacyOrgMemberEmail,
});
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
email: pbacOrgUserWithRolePermissionEmail,
username: pbacOrgUserWithRolePermissionEmail,
});
pbacOrgUserWithoutRolePermission = await userRepositoryFixture.create({
email: pbacOrgUserWithoutRolePermissionEmail,
username: pbacOrgUserWithoutRolePermissionEmail,
});
nonOrgUser = await userRepositoryFixture.create({
email: nonOrgUserEmail,
username: nonOrgUserEmail,
});
// Create organizations and teams
organization = await organizationsRepositoryFixture.create({
name: `org-roles-test-${randomString()}`,
isOrganization: true,
});
team = await teamRepositoryFixture.create({
name: `team-roles-test-${randomString()}`,
isOrganization: false,
parent: { connect: { id: organization.id } },
});
pbacEnabledOrganization = await organizationsRepositoryFixture.create({
name: `pbac-org-roles-test-${randomString()}`,
isOrganization: true,
});
pbacEnabledTeam = await teamRepositoryFixture.create({
name: `pbac-team-roles-test-${randomString()}`,
isOrganization: false,
parent: { connect: { id: pbacEnabledOrganization.id } },
});
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledTeam.id, "pbac");
// Create memberships
await membershipRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: legacyOrgAdminUser.id } },
team: { connect: { id: organization.id } },
});
await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: legacyOrgMemberUser.id } },
team: { connect: { id: organization.id } },
});
const pbacOrgUserWithRolePermissionMembership = await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: pbacOrgUserWithRolePermission.id } },
team: { connect: { id: pbacEnabledOrganization.id } },
});
const pbacOrgUserWithoutRolePermissionMembership = await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: pbacOrgUserWithoutRolePermission.id } },
team: { connect: { id: pbacEnabledOrganization.id } },
});
const roleWithPermission = await roleService.createRole({
name: "Role Manager",
teamId: pbacEnabledOrganization.id,
permissions: ["role.create", "role.read", "role.update", "role.delete"],
type: "CUSTOM",
});
const roleWithoutPermission = await roleService.createRole({
name: "Basic Role",
teamId: pbacEnabledOrganization.id,
permissions: ["booking.read"],
type: "CUSTOM",
});
await roleService.assignRoleToMember(roleWithPermission.id, pbacOrgUserWithRolePermissionMembership.id);
await roleService.assignRoleToMember(
roleWithoutPermission.id,
pbacOrgUserWithoutRolePermissionMembership.id
);
const { keyString: legacyOrgAdminKeyString } = await apiKeysRepositoryFixture.createApiKey(
legacyOrgAdminUser.id,
null
);
legacyOrgAdminApiKey = `cal_test_${legacyOrgAdminKeyString}`;
const { keyString: legacyOrgMemberKeyString } = await apiKeysRepositoryFixture.createApiKey(
legacyOrgMemberUser.id,
null
);
legacyOrgMemberApiKey = `cal_test_${legacyOrgMemberKeyString}`;
const { keyString: pbacOrgUserWithRolePermissionKeyString } = await apiKeysRepositoryFixture.createApiKey(
pbacOrgUserWithRolePermission.id,
null
);
pbacOrgUserWithRolePermissionApiKey = `cal_test_${pbacOrgUserWithRolePermissionKeyString}`;
const { keyString: pbacOrgUserWithoutRolePermissionKeyString } =
await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithoutRolePermission.id, null);
pbacOrgUserWithoutRolePermissionApiKey = `cal_test_${pbacOrgUserWithoutRolePermissionKeyString}`;
const { keyString: nonOrgUserKeyString } = await apiKeysRepositoryFixture.createApiKey(
nonOrgUser.id,
null
);
nonOrgUserApiKey = `cal_test_${nonOrgUserKeyString}`;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("Role Creation Authorization", () => {
describe("Positive Tests", () => {
it("should allow role creation when organization has PBAC enabled and user has a create permission", async () => {
const createRoleInput: CreateTeamRoleInput = {
name: "Test Role PBAC",
permissions: ["booking.read", "eventType.create"],
};
return request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(createRoleInput)
.expect(201)
.then((response) => {
const responseBody: CreateTeamRoleOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.name).toEqual(createRoleInput.name);
expect(responseBody.data.permissions).toEqual(createRoleInput.permissions);
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
});
});
it("should allow role creation when organization does not have PBAC enabled and user is org admin", async () => {
const createRoleInput: CreateTeamRoleInput = {
name: "Test Role Legacy Admin",
permissions: ["booking.read"],
};
return request(app.getHttpServer())
.post(`/v2/organizations/${organization.id}/teams/${team.id}/roles`)
.set("Authorization", `Bearer ${legacyOrgAdminApiKey}`)
.send(createRoleInput)
.expect(201)
.then((response) => {
const responseBody: CreateTeamRoleOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.name).toEqual(createRoleInput.name);
expect(responseBody.data.teamId).toEqual(team.id);
});
});
});
describe("Negative Tests", () => {
it("should not allow role creation when organization has PBAC enabled but user has no role assigned", async () => {
const createRoleInput: CreateTeamRoleInput = {
name: "Test Role No Role",
permissions: ["booking.read"],
};
const userWithNoRole = await userRepositoryFixture.create({
email: `no-role-user-${randomString()}@api.com`,
username: `no-role-user-${randomString()}@api.com`,
});
await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: userWithNoRole.id } },
team: { connect: { id: pbacEnabledOrganization.id } },
});
const { keyString: noRoleKeyString } = await apiKeysRepositoryFixture.createApiKey(
userWithNoRole.id,
null
);
const noRoleApiKey = `cal_test_${noRoleKeyString}`;
return request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
.set("Authorization", `Bearer ${noRoleApiKey}`)
.send(createRoleInput)
.expect(403);
});
it("should not allow role creation when organization has PBAC enabled but user role lacks required permission", async () => {
const createRoleInput: CreateTeamRoleInput = {
name: "Test Role No Permission",
permissions: ["booking.read"],
};
return request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithoutRolePermissionApiKey}`)
.send(createRoleInput)
.expect(403);
});
it("should not allow role creation when organization does not have PBAC enabled and user has no membership", async () => {
const createRoleInput: CreateTeamRoleInput = {
name: "Test Role No Membership",
permissions: ["booking.read"],
};
return request(app.getHttpServer())
.post(`/v2/organizations/${organization.id}/teams/${team.id}/roles`)
.set("Authorization", `Bearer ${nonOrgUserApiKey}`)
.send(createRoleInput)
.expect(403);
});
it("should not allow role creation when organization does not have PBAC enabled and user has member membership (not admin)", async () => {
const createRoleInput: CreateTeamRoleInput = {
name: "Test Role Member Only",
permissions: ["booking.read"],
};
return request(app.getHttpServer())
.post(`/v2/organizations/${organization.id}/teams/${team.id}/roles`)
.set("Authorization", `Bearer ${legacyOrgMemberApiKey}`)
.send(createRoleInput)
.expect(403);
});
});
});
describe("CRUD Role Endpoints", () => {
let createdRoleId: string;
it("should create a role", async () => {
const createRoleInput: CreateTeamRoleInput = {
name: "CRUD Test Role",
permissions: ["booking.read", "eventType.create"],
};
return request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(createRoleInput)
.expect(201)
.then((response) => {
const responseBody: CreateTeamRoleOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.name).toEqual(createRoleInput.name);
expect(responseBody.data.permissions).toEqual(createRoleInput.permissions);
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
createdRoleId = responseBody.data.id;
});
});
it("should update role permissions and name", async () => {
const updateRoleInput: UpdateTeamRoleInput = {
name: "CRUD Test Role Updated",
permissions: ["booking.read", "eventType.read"],
};
return request(app.getHttpServer())
.patch(
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}`
)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(updateRoleInput)
.expect(200)
.then((response) => {
const responseBody: UpdateTeamRoleOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(createdRoleId);
expect(responseBody.data.name).toEqual(updateRoleInput.name);
expect(responseBody.data.permissions).toEqual(updateRoleInput.permissions);
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
});
});
it("should update only name and keep permissions unchanged", async () => {
const updateNameOnly: UpdateTeamRoleInput = {
name: "CRUD Test Role Renamed Only",
};
const expectedPermissions = ["booking.read", "eventType.read"]; // from previous update
return request(app.getHttpServer())
.patch(
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}`
)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(updateNameOnly)
.expect(200)
.then((response) => {
const responseBody: UpdateTeamRoleOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(createdRoleId);
expect(responseBody.data.name).toEqual(updateNameOnly.name);
expect(responseBody.data.permissions).toEqual(expectedPermissions);
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
});
});
it("should fetch the role", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}`
)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(200)
.then((response) => {
const responseBody: GetTeamRoleOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(createdRoleId);
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
});
});
it("should fetch all roles", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(200)
.then((response) => {
const responseBody: GetAllTeamRolesOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.find((r) => r.id === createdRoleId)).toBeDefined();
const created = responseBody.data.find((r) => r.id === createdRoleId);
expect(created?.teamId).toEqual(pbacEnabledTeam.id);
});
});
it("should delete the role", async () => {
return request(app.getHttpServer())
.delete(
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${createdRoleId}`
)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(200)
.then((response) => {
const responseBody: DeleteTeamRoleOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(createdRoleId);
expect(responseBody.data.teamId).toEqual(pbacEnabledTeam.id);
});
});
});
afterAll(async () => {
try {
await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledTeam.id, "pbac");
await teamRepositoryFixture.delete(team.id);
await teamRepositoryFixture.delete(pbacEnabledTeam.id);
await organizationsRepositoryFixture.delete(organization.id);
await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id);
await userRepositoryFixture.deleteByEmail(legacyOrgAdminUser.email);
await userRepositoryFixture.deleteByEmail(legacyOrgMemberUser.email);
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email);
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithoutRolePermission.email);
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
try {
await featuresRepositoryFixture.deleteBySlug("pbac");
} catch (error) {
console.error("Cleanup error:", error);
}
} catch (error) {
console.error("Cleanup error:", error);
} finally {
await app.close();
}
});
});
@@ -0,0 +1,159 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
OPTIONAL_API_KEY_HEADER,
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
} from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
import { UpdateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/update-team-role.input";
import { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output";
import { DeleteTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/delete-team-role.output";
import { GetAllTeamRolesOutput } from "@/modules/organizations/teams/roles/outputs/get-all-team-roles.output";
import { GetTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/get-team-role.output";
import { UpdateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/update-team-role.output";
import { TeamRolesOutputService } from "@/modules/organizations/teams/roles/services/team-roles-output.service";
import { RolesService } from "@/modules/roles/services/roles.service";
import {
Controller,
UseGuards,
Get,
Param,
ParseIntPipe,
Query,
Delete,
Patch,
Post,
Body,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId/roles",
version: API_VERSIONS_VALUES,
})
@UseGuards(
ApiAuthGuard,
IsOrgGuard,
PbacGuard,
RolesGuard,
IsTeamInOrg,
PlatformPlanGuard,
IsAdminAPIEnabledGuard
)
@DocsTags("Orgs / Teams / Roles")
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsRolesController {
constructor(
private readonly rolesService: RolesService,
private readonly rolesOutputService: TeamRolesOutputService
) {}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.create"])
@Post("/")
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: "Create a new organization team role" })
async createRole(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() body: CreateTeamRoleInput
): Promise<CreateTeamRoleOutput> {
const role = await this.rolesService.createRole(teamId, body);
return {
status: SUCCESS_STATUS,
data: this.rolesOutputService.getTeamRoleOutput(role),
};
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.read"])
@Get("/:roleId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get a specific organization team role" })
async getRole(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("roleId") roleId: string
): Promise<GetTeamRoleOutput> {
const role = await this.rolesService.getRole(teamId, roleId);
return {
status: SUCCESS_STATUS,
data: this.rolesOutputService.getTeamRoleOutput(role),
};
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.read"])
@Get("/")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get all organization team roles" })
async getAllRoles(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetAllTeamRolesOutput> {
const { skip, take } = queryParams;
const roles = await this.rolesService.getTeamRoles(teamId, skip ?? 0, take ?? 250);
return {
status: SUCCESS_STATUS,
data: this.rolesOutputService.getTeamRolesOutput(roles),
};
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.update"])
@Patch("/:roleId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Update an organization team role" })
async updateRole(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("roleId") roleId: string,
@Body() body: UpdateTeamRoleInput
): Promise<UpdateTeamRoleOutput> {
const role = await this.rolesService.updateRole(teamId, roleId, body);
return {
status: SUCCESS_STATUS,
data: this.rolesOutputService.getTeamRoleOutput(role),
};
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.delete"])
@Delete("/:roleId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Delete an organization team role" })
async deleteRole(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("roleId") roleId: string
): Promise<DeleteTeamRoleOutput> {
const role = await this.rolesService.deleteRole(teamId, roleId);
return {
status: SUCCESS_STATUS,
data: this.rolesOutputService.getTeamRoleOutput(role),
};
}
}
@@ -0,0 +1,18 @@
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { OrganizationsTeamsRolesPermissionsController } from "@/modules/organizations/teams/roles/permissions/organizations-teams-roles-permissions.controller";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RolesModule } from "@/modules/roles/roles.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { Module } from "@nestjs/common";
import { OrganizationsTeamsRolesController } from "./organizations-teams-roles.controller";
@Module({
imports: [StripeModule, PrismaModule, RedisModule, MembershipsModule, RolesModule],
providers: [OrganizationsRepository, OrganizationsTeamsRepository],
controllers: [OrganizationsTeamsRolesController, OrganizationsTeamsRolesPermissionsController],
})
export class OrganizationsTeamsRolesModule {}
@@ -0,0 +1,20 @@
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class CreateTeamRoleOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamRoleOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamRoleOutput)
data!: TeamRoleOutput;
}
@@ -0,0 +1,20 @@
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class DeleteTeamRoleOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamRoleOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamRoleOutput)
data!: TeamRoleOutput;
}
@@ -0,0 +1,20 @@
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsArray, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class GetAllTeamRolesOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: [TeamRoleOutput],
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => TeamRoleOutput)
data!: TeamRoleOutput[];
}
@@ -0,0 +1,20 @@
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class GetTeamRoleOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamRoleOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamRoleOutput)
data!: TeamRoleOutput;
}
@@ -0,0 +1,71 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsString, IsOptional, IsArray, IsEnum, IsDateString, IsNumber } from "class-validator";
import { teamPermissionEnum } from "../inputs/base-team-role.input";
enum RoleTypeEnum {
SYSTEM = "SYSTEM",
CUSTOM = "CUSTOM",
}
type RoleType = keyof typeof RoleTypeEnum;
export class TeamRoleOutput {
@ApiProperty({ description: "Unique identifier for the role" })
@IsString()
@Expose()
id!: string;
@ApiProperty({ description: "Name of the role" })
@IsString()
@Expose()
name!: string;
@ApiPropertyOptional({ description: "Color for the role (hex code)" })
@IsString()
@IsOptional()
@Expose()
color?: string | null;
@ApiPropertyOptional({ description: "Description of the role" })
@IsString()
@IsOptional()
@Expose()
description?: string | null;
@ApiPropertyOptional({ description: "Team ID this role belongs to" })
@IsNumber()
@IsOptional()
@Expose()
teamId?: number | null;
@ApiProperty({
description: "Type of role",
enum: RoleTypeEnum,
})
@IsEnum(RoleTypeEnum)
@Expose()
type!: RoleType;
@ApiProperty({
description: "Permissions assigned to this role in 'resource.action' format.",
enum: teamPermissionEnum,
isArray: true,
example: ["booking.read", "eventType.create"],
})
@IsArray()
@IsString({ each: true })
@Expose()
permissions!: string[];
@ApiProperty({ description: "When the role was created" })
@IsDateString()
@Expose()
createdAt!: string;
@ApiProperty({ description: "When the role was last updated" })
@IsDateString()
@Expose()
updatedAt!: string;
}
@@ -0,0 +1,20 @@
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class UpdateTeamRoleOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamRoleOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamRoleOutput)
data!: TeamRoleOutput;
}
@@ -0,0 +1,20 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsArray, IsString, Validate } from "class-validator";
import type { PermissionString } from "@calcom/platform-libraries/pbac";
import { teamPermissionEnum } from "../../inputs/base-team-role.input";
import { TeamPermissionStringValidator } from "./validators/team-permission-string.validator";
export class CreateTeamRolePermissionsInput {
@ApiProperty({
description: "Permissions to add (format: resource.action)",
enum: teamPermissionEnum,
isArray: true,
example: ["eventType.read", "booking.read"],
})
@IsArray()
@IsString({ each: true })
@Validate(TeamPermissionStringValidator, { each: true })
permissions!: PermissionString[];
}
@@ -0,0 +1,33 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { ArrayNotEmpty, IsArray, IsOptional, IsString, Validate } from "class-validator";
import type { PermissionString } from "@calcom/platform-libraries/pbac";
import { teamPermissionEnum } from "../../inputs/base-team-role.input";
import { TeamPermissionStringValidator } from "./validators/team-permission-string.validator";
export class DeleteTeamRolePermissionsQuery {
@ApiPropertyOptional({
description:
"Permissions to remove (format: resource.action). Supports comma-separated values as well as repeated query params.",
example: "?permissions=eventType.read,booking.read",
enum: teamPermissionEnum,
isArray: true,
})
@IsOptional()
@Transform(({ value }) => {
if (typeof value === "string") {
return value
.split(",")
.map((p: string) => p.trim())
.filter((p: string) => p.length > 0);
}
return value;
})
@IsArray()
@ArrayNotEmpty({ message: "permissions cannot be empty." })
@IsString({ each: true })
@Validate(TeamPermissionStringValidator, { each: true })
permissions?: PermissionString[];
}
@@ -0,0 +1,88 @@
import { BadRequestException } from "@nestjs/common";
import { TeamPermissionStringValidator } from "./team-permission-string.validator";
describe("PermissionStringValidator", () => {
let validator: TeamPermissionStringValidator;
beforeEach(() => {
validator = new TeamPermissionStringValidator();
});
describe("validate", () => {
it("should return true for valid permission strings", () => {
const validPermissions = [
"eventType.read",
"eventType.create",
"eventType.update",
"eventType.delete",
"booking.read",
"booking.update",
"role.create",
"role.read",
"role.update",
"role.delete",
"team.read",
"team.invite",
];
validPermissions.forEach((permission) => {
expect(validator.validate(permission)).toBe(true);
});
});
it("should throw for wildcard permission strings", () => {
const wildcardPermissions = ["*", "*.read", "eventType.*"];
wildcardPermissions.forEach((permission) => {
expect(() => validator.validate(permission)).toThrow(BadRequestException);
});
});
it("should throw for invalid permission strings", () => {
const invalidPermissions = [
"invalid", // no dot
"invalid.", // no action
".invalid", // no resource
"invalid.action", // invalid resource
"eventType.invalid", // invalid action
"event-type.read", // wrong format (should be eventType)
"", // empty string
"eventType..read", // double dot
"eventType.read.extra", // too many parts
];
invalidPermissions.forEach((permission) => {
expect(() => validator.validate(permission)).toThrow(BadRequestException);
});
});
it("should throw for organization-scoped permission strings", () => {
// These are explicitly marked with scope: [Scope.Organization] in permission-registry
const orgScoped = [
"organization.create",
"organization.read",
"organization.listMembers",
"organization.listMembersPrivate",
"organization.invite",
"organization.remove",
"organization.manageBilling",
"organization.changeMemberRole",
"organization.impersonate",
"organization.update",
];
orgScoped.forEach((permission) => {
expect(() => validator.validate(permission)).toThrow(BadRequestException);
});
});
});
describe("defaultMessage", () => {
it("should return appropriate error message", () => {
const message = validator.defaultMessage();
expect(message).toContain("Permission must be a valid permission string");
expect(message).toContain("resource.action");
});
});
});
@@ -0,0 +1,23 @@
import { BadRequestException } from "@nestjs/common";
import type { ValidatorConstraintInterface } from "class-validator";
import { ValidatorConstraint } from "class-validator";
import { isValidPermissionStringForScope } from "@calcom/platform-libraries/pbac";
import { Scope } from "@calcom/platform-libraries/pbac";
@ValidatorConstraint({ name: "teamPermissionStringValidator", async: false })
export class TeamPermissionStringValidator implements ValidatorConstraintInterface {
validate(permission: string) {
const isValid = isValidPermissionStringForScope(permission, Scope.Team);
if (!isValid) {
throw new BadRequestException(
`Permission '${permission}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')`
);
}
return true;
}
defaultMessage() {
return "Permission must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')";
}
}
@@ -0,0 +1,287 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
import type { CreateTeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/create-team-role.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { FeaturesRepositoryFixture } from "test/fixtures/repository/features.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { RoleService } from "@calcom/platform-libraries/pbac";
import type { PermissionString } from "@calcom/platform-libraries/pbac";
import type { Team, User } from "@calcom/prisma/client";
describe("Organizations Teams Roles Permissions Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let featuresRepositoryFixture: FeaturesRepositoryFixture;
let roleService: RoleService;
let pbacOrgUserWithRolePermission: User;
let pbacOrgUserWithRolePermissionApiKey: string;
let pbacEnabledOrganization: Team;
let pbacEnabledTeam: Team;
const pbacUserEmail = `pbac-org-user-with-permissions-${randomString()}@api.com`;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
}).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
featuresRepositoryFixture = new FeaturesRepositoryFixture(moduleRef);
roleService = new RoleService();
// Create PBAC org/team
pbacEnabledOrganization = await organizationsRepositoryFixture.create({
name: `pbac-org-role-perm-test-${randomString()}`,
isOrganization: true,
});
pbacEnabledTeam = await teamRepositoryFixture.create({
name: `pbac-team-role-perm-test-${randomString()}`,
isOrganization: false,
parent: { connect: { id: pbacEnabledOrganization.id } },
});
await featuresRepositoryFixture.create({ slug: "pbac", enabled: true });
await featuresRepositoryFixture.enableFeatureForTeam(pbacEnabledTeam.id, "pbac");
// Create user + membership in org
pbacOrgUserWithRolePermission = await userRepositoryFixture.create({
email: pbacUserEmail,
username: pbacUserEmail,
});
const membership = await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: pbacOrgUserWithRolePermission.id } },
team: { connect: { id: pbacEnabledOrganization.id } },
});
// Create a role that allows role.read and role.update, assign to user
const managerRole = await roleService.createRole({
name: `role-manager-${randomString()}`,
teamId: pbacEnabledOrganization.id,
permissions: ["role.create", "role.read", "role.update"],
type: "CUSTOM",
});
await roleService.assignRoleToMember(managerRole.id, membership.id);
// API key for user
const { keyString } = await apiKeysRepositoryFixture.createApiKey(pbacOrgUserWithRolePermission.id, null);
pbacOrgUserWithRolePermissionApiKey = `cal_test_${keyString}`;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("lists permissions for a role (GET /)", async () => {
const initialPermissions = ["booking.read"] as const;
const baseRoleInput: CreateTeamRoleInput = {
name: `perm-target-role-list-${randomString()}`,
permissions: [...initialPermissions],
};
const createRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(baseRoleInput)
.expect(201);
const responseBody: CreateTeamRoleOutput = createRes.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
const roleId = responseBody.data.id;
const listRes = await request(app.getHttpServer())
.get(
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(200);
expect(listRes.body.status).toEqual(SUCCESS_STATUS);
expect(listRes.body.data).toEqual(initialPermissions);
});
it("adds permissions (POST /)", async () => {
const initialPermissions = ["booking.read"] as const;
const toAdd = ["eventType.create", "eventType.read"] as const;
const expected = [...initialPermissions, ...toAdd] as string[];
const baseRoleInput: CreateTeamRoleInput = {
name: `perm-target-role-add-${randomString()}`,
permissions: [...initialPermissions],
};
const createRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(baseRoleInput)
.expect(201);
const responseBody: CreateTeamRoleOutput = createRes.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
const roleId = responseBody.data.id;
const addRes = await request(app.getHttpServer())
.post(
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send({ permissions: toAdd })
.expect(200);
expect(addRes.body.status).toEqual(SUCCESS_STATUS);
expect(addRes.body.data).toEqual(expected);
});
it("bulk removes permissions via query (DELETE /)", async () => {
// Seed role
const initialPermissions: PermissionString[] = ["booking.read", "eventType.create", "eventType.read"];
const toRemove: PermissionString[] = ["eventType.create", "eventType.read"];
const expected: PermissionString[] = ["booking.read"];
const baseRoleInput: CreateTeamRoleInput = {
name: `perm-target-role-delmany-${randomString()}`,
permissions: initialPermissions,
};
const createRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(baseRoleInput)
.expect(201);
const responseBody: CreateTeamRoleOutput = createRes.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
const roleId = responseBody.data.id;
await request(app.getHttpServer())
.delete(
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${
pbacEnabledTeam.id
}/roles/${roleId}/permissions?permissions=${toRemove.join(",")}`
)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(204);
const listRes = await request(app.getHttpServer())
.get(
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(200);
expect(listRes.body.data).toEqual(expected);
});
it("replaces all permissions (PUT /)", async () => {
const initialPermissions = ["booking.read"] as const;
const replacement = ["booking.read", "eventType.update"] as const;
const baseRoleInput: CreateTeamRoleInput = {
name: `perm-target-role-put-${randomString()}`,
permissions: [...initialPermissions],
};
const createRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(baseRoleInput)
.expect(201);
const responseBody: CreateTeamRoleOutput = createRes.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
const roleId = responseBody.data.id;
const putRes = await request(app.getHttpServer())
.put(
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send({ permissions: replacement })
.expect(200);
expect(putRes.body.status).toEqual(SUCCESS_STATUS);
expect(putRes.body.data).toEqual(replacement);
});
it("removes a single permission (DELETE /:permission)", async () => {
const initialPermissions = ["booking.read", "eventType.update"] as const;
const toRemove = "eventType.update" as const;
const expected = ["booking.read"] as const;
const baseRoleInput: CreateTeamRoleInput = {
name: `perm-target-role-delone-${randomString()}`,
permissions: [...initialPermissions],
};
const createRes = await request(app.getHttpServer())
.post(`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles`)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.send(baseRoleInput)
.expect(201);
const responseBody: CreateTeamRoleOutput = createRes.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.permissions).toEqual(baseRoleInput.permissions);
const roleId = responseBody.data.id;
await request(app.getHttpServer())
.delete(
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions/${toRemove}`
)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(204);
const listRes = await request(app.getHttpServer())
.get(
`/v2/organizations/${pbacEnabledOrganization.id}/teams/${pbacEnabledTeam.id}/roles/${roleId}/permissions`
)
.set("Authorization", `Bearer ${pbacOrgUserWithRolePermissionApiKey}`)
.expect(200);
expect(listRes.body.data).toEqual(expected);
});
afterAll(async () => {
try {
// Clean up feature flag from team
await featuresRepositoryFixture.deleteTeamFeature(pbacEnabledTeam.id, "pbac");
// Clean up teams and orgs
await teamRepositoryFixture.delete(pbacEnabledTeam.id);
await organizationsRepositoryFixture.delete(pbacEnabledOrganization.id);
// Clean up user
await userRepositoryFixture.deleteByEmail(pbacOrgUserWithRolePermission.email);
// Clean up feature definition
try {
await featuresRepositoryFixture.deleteBySlug("pbac");
} catch (err) {
console.log(err);
}
} catch (err) {
console.log(err);
} finally {
await app.close();
}
});
});
@@ -0,0 +1,144 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
OPTIONAL_API_KEY_HEADER,
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
} from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Pbac } from "@/modules/auth/decorators/pbac/pbac.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { PbacGuard } from "@/modules/auth/guards/pbac/pbac.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { CreateTeamRolePermissionsInput } from "@/modules/organizations/teams/roles/permissions/inputs/create-team-role-permissions.input";
import { DeleteTeamRolePermissionsQuery } from "@/modules/organizations/teams/roles/permissions/inputs/delete-team-role-permissions.query";
import { GetTeamRolePermissionsOutput } from "@/modules/organizations/teams/roles/permissions/outputs/get-team-role-permissions.output";
import { RolesPermissionsService } from "@/modules/roles/permissions/services/roles-permissions.service";
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Put,
Query,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { PermissionString } from "@calcom/platform-libraries/pbac";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId/roles/:roleId/permissions",
version: API_VERSIONS_VALUES,
})
@UseGuards(
ApiAuthGuard,
IsOrgGuard,
PbacGuard,
RolesGuard,
IsTeamInOrg,
PlatformPlanGuard,
IsAdminAPIEnabledGuard
)
@DocsTags("Orgs / Teams / Roles / Permissions")
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsRolesPermissionsController {
constructor(private readonly rolePermissionsService: RolesPermissionsService) {}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.update"])
@Post("/")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Add permissions to an organization team role (single or batch)" })
async addPermissions(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("roleId") roleId: string,
@Body() body: CreateTeamRolePermissionsInput
): Promise<GetTeamRolePermissionsOutput> {
const permissions = await this.rolePermissionsService.addRolePermissions(
teamId,
roleId,
body.permissions
);
return { status: SUCCESS_STATUS, data: permissions };
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.read"])
@Get("/")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "List permissions for an organization team role" })
async listPermissions(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("roleId") roleId: string
): Promise<GetTeamRolePermissionsOutput> {
const permissions = await this.rolePermissionsService.getRolePermissions(teamId, roleId);
return { status: SUCCESS_STATUS, data: permissions };
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.update"])
@Put("/")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Replace all permissions for an organization team role" })
async setPermissions(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("roleId") roleId: string,
@Body() body: CreateTeamRolePermissionsInput
): Promise<GetTeamRolePermissionsOutput> {
const permissions = await this.rolePermissionsService.setRolePermissions(
teamId,
roleId,
body.permissions || []
);
return { status: SUCCESS_STATUS, data: permissions };
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.update"])
@Delete("/:permission")
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: "Remove a permission from an organization team role" })
async removePermission(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("roleId") roleId: string,
@Param("permission") permission: PermissionString
): Promise<void> {
await this.rolePermissionsService.removeRolePermission(teamId, roleId, permission);
}
@Roles("ORG_ADMIN")
@PlatformPlan("SCALE")
@Pbac(["role.update"])
@Delete("/")
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: "Remove multiple permissions from an organization team role" })
async removePermissions(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("roleId") roleId: string,
@Query() query: DeleteTeamRolePermissionsQuery
): Promise<void> {
await this.rolePermissionsService.removeRolePermissions(teamId, roleId, query.permissions || []);
}
}
@@ -0,0 +1,14 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsArray, IsString } from "class-validator";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
export class GetTeamRolePermissionsOutput {
@ApiProperty({ example: SUCCESS_STATUS })
status!: typeof SUCCESS_STATUS;
@ApiProperty({ type: [String] })
@IsArray()
@IsString({ each: true })
data!: string[];
}
@@ -0,0 +1,25 @@
import { TeamRoleOutput } from "@/modules/organizations/teams/roles/outputs/team-role.output";
import { Injectable } from "@nestjs/common";
import type { Role } from "@calcom/platform-libraries/pbac";
@Injectable()
export class TeamRolesOutputService {
getTeamRoleOutput(role: Role): TeamRoleOutput {
return {
id: role.id,
name: role.name,
color: role.color || null,
description: role.description || null,
teamId: role.teamId || null,
type: role.type,
permissions: role.permissions.map((permission) => `${permission.resource}.${permission.action}`),
createdAt: role.createdAt.toISOString(),
updatedAt: role.updatedAt.toISOString(),
};
}
getTeamRolesOutput(roles: Role[]): TeamRoleOutput[] {
return roles.map((role) => this.getTeamRoleOutput(role));
}
}
+37 -1
View File
@@ -61,7 +61,7 @@ export class RedisService implements OnModuleDestroy {
try {
return JSON.parse(data) as TData;
} catch (e) {
} catch {
return data as TData;
}
}
@@ -78,6 +78,30 @@ export class RedisService implements OnModuleDestroy {
}
}
async getKeys(pattern: string): Promise<string[]> {
if (!this.isReady) {
return [];
}
try {
return this.redis.keys(pattern);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis getKeys failed: ${err.message}`);
return [];
}
}
async delMany(keys: string[]): Promise<number> {
if (!this.isReady || keys.length === 0) {
return 0;
}
try {
return this.redis.del(...keys);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis delMany failed: ${err.message}`);
return 0;
}
}
async set<TData>(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> {
if (!this.isReady) {
return null;
@@ -137,4 +161,16 @@ export class RedisService implements OnModuleDestroy {
return 0;
}
}
async incr(key: string): Promise<number> {
if (!this.isReady) {
return 0;
}
try {
return this.redis.incr(key);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis incr failed: ${err.message}`);
return 0;
}
}
}
@@ -0,0 +1,28 @@
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, Logger } from "@nestjs/common";
export const REDIS_TEAM_PERMISSIONS_CACHE_PATTERN = (teamId: number) =>
`apiv2:user:*:team:${teamId}:requiredPermissions:*:guard:pbac`;
@Injectable()
export class RolesPermissionsCacheService {
private readonly logger = new Logger(RolesPermissionsCacheService.name);
constructor(private readonly redisService: RedisService) {}
async deleteTeamPermissionsCache(teamId: number): Promise<void> {
try {
const pattern = REDIS_TEAM_PERMISSIONS_CACHE_PATTERN(teamId);
const keys = await this.redisService.getKeys(pattern);
if (keys.length > 0) {
await this.redisService.delMany(keys);
this.logger.log(`Deleted ${keys.length} permission cache keys for team ${teamId}`);
} else {
this.logger.log(`No permission cache keys found for team ${teamId}`);
}
} catch (error) {
this.logger.error(`Failed to delete permission cache for team ${teamId}:`, error);
}
}
}
@@ -0,0 +1,12 @@
import { Injectable } from "@nestjs/common";
import type { PermissionString, Role } from "@calcom/platform-libraries/pbac";
@Injectable()
export class RolesPermissionsOutputService {
getPermissionsFromRole(role: Role): PermissionString[] {
return role.permissions.map(
(permission) => `${permission.resource}.${permission.action}` as PermissionString
);
}
}
@@ -0,0 +1,172 @@
import { RolesPermissionsCacheService } from "@/modules/roles/permissions/services/roles-permissions-cache.service";
import { RolesPermissionsOutputService } from "@/modules/roles/permissions/services/roles-permissions-output.service";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import type { PermissionString, UpdateRolePermissionsData } from "@calcom/platform-libraries/pbac";
import { RoleService, isValidPermissionString } from "@calcom/platform-libraries/pbac";
@Injectable()
export class RolesPermissionsService {
constructor(
private readonly roleService: RoleService,
private readonly rolesPermissionsOutputService: RolesPermissionsOutputService,
private readonly rolesPermissionsCacheService: RolesPermissionsCacheService
) {}
async getRolePermissions(teamId: number, roleId: string) {
const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId);
if (!belongsToTeam) {
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
}
const role = await this.roleService.getRole(roleId);
if (!role) {
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
}
return this.rolesPermissionsOutputService.getPermissionsFromRole(role);
}
async addRolePermissions(teamId: number, roleId: string, permissionsToAdd: PermissionString[]) {
const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId);
if (!belongsToTeam) {
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
}
const current = await this.getRolePermissions(teamId, roleId);
const desired = Array.from(new Set([...(current || []), ...(permissionsToAdd || [])]));
const updateData: UpdateRolePermissionsData = {
roleId,
permissions: desired,
updates: {},
};
try {
const updatedRole = await this.roleService.update(updateData);
await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId);
return this.rolesPermissionsOutputService.getPermissionsFromRole(updatedRole);
} catch (error) {
if (error instanceof Error && error.message.includes("Invalid permissions provided")) {
throw new BadRequestException(error.message);
}
throw error;
}
}
async removeRolePermission(teamId: number, roleId: string, permissionToRemove: PermissionString) {
if (!isValidPermissionString(permissionToRemove)) {
throw new BadRequestException(
`Permission '${permissionToRemove}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')`
);
}
const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId);
if (!belongsToTeam) {
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
}
const current = await this.getRolePermissions(teamId, roleId);
const desired = (current || []).filter((p) => p !== permissionToRemove);
const updateData: UpdateRolePermissionsData = {
roleId,
permissions: desired,
updates: {},
};
try {
const updatedRole = await this.roleService.update(updateData);
await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId);
return this.rolesPermissionsOutputService.getPermissionsFromRole(updatedRole);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Invalid permissions provided")) {
throw new BadRequestException(error.message);
}
if (error.message.includes("Cannot update default roles")) {
throw new BadRequestException(error.message);
}
}
throw error;
}
}
async removeRolePermissions(teamId: number, roleId: string, permissionsToRemove: PermissionString[]) {
if (permissionsToRemove && permissionsToRemove.length > 0) {
for (const permission of permissionsToRemove) {
if (!isValidPermissionString(permission)) {
throw new BadRequestException(
`Permission '${permission}' must be a valid permission string in format 'resource.action' (e.g., 'eventType.read', 'booking.create')`
);
}
}
}
const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId);
if (!belongsToTeam) {
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
}
const current = await this.getRolePermissions(teamId, roleId);
const toRemove = new Set(permissionsToRemove || []);
const desired = (current || []).filter((p) => !toRemove.has(p));
const updateData: UpdateRolePermissionsData = {
roleId,
permissions: desired,
updates: {},
};
try {
const updatedRole = await this.roleService.update(updateData);
await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId);
return this.rolesPermissionsOutputService.getPermissionsFromRole(updatedRole);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Invalid permissions provided")) {
throw new BadRequestException(error.message);
}
if (error.message.includes("Cannot update default roles")) {
throw new BadRequestException(error.message);
}
}
throw error;
}
}
async setRolePermissions(teamId: number, roleId: string, permissions: PermissionString[]) {
const belongsToTeam = await this.roleService.roleBelongsToTeam(roleId, teamId);
if (!belongsToTeam) {
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
}
const updateData: UpdateRolePermissionsData = {
roleId,
permissions,
updates: {},
};
try {
const updatedRole = await this.roleService.update(updateData);
await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId);
return this.rolesPermissionsOutputService.getPermissionsFromRole(updatedRole);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Invalid permissions provided")) {
throw new BadRequestException(error.message);
}
if (error.message.includes("Cannot update default roles")) {
throw new BadRequestException(error.message);
}
}
throw error;
}
}
}
@@ -0,0 +1,39 @@
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsRolesOutputService } from "@/modules/organizations/roles/services/organizations-roles-output.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { Module } from "@nestjs/common";
import { RoleService } from "@calcom/platform-libraries/pbac";
import { TeamRolesOutputService } from "../organizations/teams/roles/services/team-roles-output.service";
import { RolesPermissionsCacheService } from "./permissions/services/roles-permissions-cache.service";
import { RolesPermissionsOutputService } from "./permissions/services/roles-permissions-output.service";
import { RolesPermissionsService } from "./permissions/services/roles-permissions.service";
import { RolesService } from "./services/roles.service";
@Module({
imports: [StripeModule, PrismaModule, RedisModule, MembershipsModule],
providers: [
{
provide: RoleService,
useFactory: () => new RoleService(),
},
RolesService,
TeamRolesOutputService,
OrganizationsRolesOutputService,
RolesPermissionsService,
RolesPermissionsOutputService,
RolesPermissionsCacheService,
],
exports: [
RolesService,
TeamRolesOutputService,
OrganizationsRolesOutputService,
RolesPermissionsService,
RolesPermissionsOutputService,
RolesPermissionsCacheService,
],
})
export class RolesModule {}
@@ -0,0 +1,143 @@
import { CreateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/create-team-role.input";
import { UpdateTeamRoleInput } from "@/modules/organizations/teams/roles/inputs/update-team-role.input";
import { RolesPermissionsCacheService } from "@/modules/roles/permissions/services/roles-permissions-cache.service";
import { BadRequestException, Injectable, NotFoundException, Logger } from "@nestjs/common";
import { RoleService } from "@calcom/platform-libraries/pbac";
import type { CreateRoleData, UpdateRolePermissionsData } from "@calcom/platform-libraries/pbac";
@Injectable()
export class RolesService {
private readonly logger = new Logger(RolesService.name);
constructor(
private readonly rolesService: RoleService,
private readonly rolesPermissionsCacheService: RolesPermissionsCacheService
) {}
async createRole(teamId: number, data: CreateTeamRoleInput) {
const createRoleData: CreateRoleData = {
name: data.name,
color: data.color,
description: data.description,
permissions: data.permissions || [],
teamId: teamId,
type: "CUSTOM",
};
try {
const role = await this.rolesService.createRole(createRoleData);
await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId);
return role;
} catch (error) {
this.logger.error(
`RolesService - createRole failed (teamId=${teamId}, roleName=${data.name}): ${
error instanceof Error ? `${error.name}: ${error.message}` : String(error)
}`,
error instanceof Error ? error.stack : undefined
);
if (error instanceof Error) {
if (error.message.includes("already exists")) {
throw new BadRequestException(error.message);
}
// Map permission validation failures from PBAC service
if (error.message.toLowerCase().includes("permission")) {
throw new BadRequestException(error.message);
}
}
throw error;
}
}
async getRole(teamId: number, roleId: string) {
const role = await this.rolesService.getRole(roleId);
if (!role || role.teamId !== teamId) {
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
}
return role;
}
async getTeamRoles(teamId: number, skip = 0, take = 250) {
const allRoles = await this.rolesService.getTeamRoles(teamId);
const paginatedRoles = allRoles.slice(skip, skip + take);
return paginatedRoles;
}
async updateRole(teamId: number, roleId: string, data: UpdateTeamRoleInput) {
const belongsToTeam = await this.rolesService.roleBelongsToTeam(roleId, teamId);
if (!belongsToTeam) {
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
}
const updateData: UpdateRolePermissionsData = {
roleId,
permissions: data.permissions,
updates: {
name: data.name,
color: data.color,
},
};
try {
const role = await this.rolesService.update(updateData);
if (data.permissions) {
await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId);
}
return role;
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Role not found")) {
throw new NotFoundException(error.message);
}
if (error.message.includes("Cannot update default roles")) {
throw new BadRequestException(error.message);
}
if (
error.message.includes("Invalid permissions provided") ||
error.message.toLowerCase().includes("permission")
) {
throw new BadRequestException(error.message);
}
}
throw error;
}
}
async deleteRole(teamId: number, roleId: string) {
const belongsToTeam = await this.rolesService.roleBelongsToTeam(roleId, teamId);
if (!belongsToTeam) {
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
}
const role = await this.rolesService.getRole(roleId);
if (!role) {
throw new NotFoundException(`Role with id ${roleId} within team id ${teamId} not found`);
}
try {
await this.rolesService.deleteRole(roleId);
await this.rolesPermissionsCacheService.deleteTeamPermissionsCache(teamId);
return role;
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Cannot delete default roles")) {
throw new BadRequestException(error.message);
}
if (error.message.includes("Role not found")) {
throw new NotFoundException(error.message);
}
}
throw error;
}
}
}
@@ -0,0 +1,71 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import type { Prisma } from "@calcom/prisma/client";
export class FeaturesRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async create(data: Prisma.FeatureCreateInput) {
// note(Lauris): upserting because this create function is called in multiple tests in parallel and otherwise would lead to unique
// key constraint violation.
return await this.prismaWriteClient.feature.upsert({
where: { slug: data.slug },
create: data,
update: {},
});
}
async createTeamFeature(data: Prisma.TeamFeaturesCreateInput) {
return await this.prismaWriteClient.teamFeatures.create({
data,
});
}
async enableFeatureForTeam(teamId: number, featureId: string, assignedBy = "test") {
await this.prismaWriteClient.teamFeatures.upsert({
where: {
teamId_featureId: {
teamId,
featureId,
},
},
create: {
teamId,
featureId,
assignedBy,
},
update: {},
});
}
async disableFeatureForTeam(teamId: number, featureSlug: string) {
return await this.prismaWriteClient.teamFeatures.deleteMany({
where: {
teamId,
featureId: featureSlug,
},
});
}
async deleteBySlug(slug: string) {
return await this.prismaWriteClient.feature.delete({
where: { slug },
});
}
async deleteTeamFeature(teamId: number, featureSlug: string) {
return await this.prismaWriteClient.teamFeatures.deleteMany({
where: {
teamId,
featureId: featureSlug,
},
});
}
}
@@ -37,6 +37,20 @@ export class OrganizationRepositoryFixture {
});
}
async updateSettings(
teamId: Team["id"],
settings: {
orgAutoAcceptEmail?: string;
isOrganizationVerified?: boolean;
isOrganizationConfigured?: boolean;
}
) {
return this.prismaWriteClient.organizationSettings.update({
where: { organizationId: teamId },
data: settings,
});
}
async delete(teamId: Team["id"]) {
return await this.prismaWriteClient.$transaction(async (prisma) => {
await prisma.organizationSettings.delete({
+2 -1
View File
@@ -29,7 +29,8 @@
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"],
"@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"],
"@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"],
"@calcom/platform-libraries/private-links": ["../../../packages/platform/libraries/private-links.ts"]
"@calcom/platform-libraries/private-links": ["../../../packages/platform/libraries/private-links.ts"],
"@calcom/platform-libraries/organizations": ["../../../packages/platform/libraries/organizations.ts"]
},
"incremental": true,
"skipLibCheck": true,
@@ -22,6 +22,7 @@ type IconName =
| "binary"
| "blocks"
| "bold"
| "book"
| "book-open-check"
| "book-open"
| "book-user"
@@ -6,7 +6,7 @@ import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { getScheduleListItemData } from "@calcom/lib/schedules/transformers/getScheduleListItemData";
@@ -61,8 +61,9 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
};
const organizationId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
const organizationRepository = getOrganizationRepository();
const isOrgPrivate = organizationId
? await OrganizationRepository.checkIfPrivate({
? await organizationRepository.checkIfPrivate({
orgId: organizationId,
})
: false;
@@ -26,11 +26,9 @@ const ServerPage = async () => {
return redirect("/auth/login");
}
// Hello {username} || there. Not sure how to do this nicely with i18n just yet
const userName = session.user.name || "there";
const userEmail = session.user.email || "";
return <OnboardingView userName={userName} userEmail={userEmail} />;
return <OnboardingView userEmail={userEmail} />;
};
export default ServerPage;
@@ -7,7 +7,7 @@ import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { PersonalProfileView } from "~/onboarding/personal/profile/personal-profile-view";
import { PersonalSettingsView } from "~/onboarding/personal/settings/personal-settings-view";
export const generateMetadata = async () => {
return await _generateMetadata(
@@ -28,7 +28,7 @@ const ServerPage = async () => {
const userEmail = session.user.email || "";
return <PersonalProfileView userEmail={userEmail} />;
return <PersonalSettingsView userEmail={userEmail} />;
};
export default ServerPage;
@@ -1,34 +0,0 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { PersonalVideoView } from "~/onboarding/personal/video/personal-video-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("connect_video")}`,
() => "",
true,
undefined,
"/onboarding/personal/video"
);
};
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
return <PersonalVideoView userEmail={userEmail} />;
};
export default ServerPage;
@@ -1,34 +0,0 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TeamBrandView } from "~/onboarding/teams/brand/team-brand-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("team_brand")}`,
() => "",
true,
undefined,
"/onboarding/teams/brand"
);
};
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
return <TeamBrandView userEmail={userEmail} />;
};
export default ServerPage;
@@ -4,12 +4,13 @@ import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { OrgForm } from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgEditPage";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
const orgIdSchema = z.object({ id: z.coerce.number() });
export const generateMetadata = async ({ params }: { params: Params }) => {
const organizationRepository = getOrganizationRepository();
const input = orgIdSchema.safeParse(await params);
if (!input.success) {
return await _generateMetadata(
@@ -21,7 +22,7 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
);
}
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
const org = await organizationRepository.adminFindById({ id: input.data.id });
return await _generateMetadata(
(t) => `${t("editing_org")}: ${org.name}`,
@@ -33,11 +34,12 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
};
const Page = async ({ params }: { params: Params }) => {
const organizationRepository = getOrganizationRepository();
const input = orgIdSchema.safeParse(await params);
if (!input.success) throw new Error("Invalid access");
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
const org = await organizationRepository.adminFindById({ id: input.data.id });
const t = await getTranslate();
return (
<SettingsHeader title={`${t("editing_org")}: ${org.name}`} description={t("admin_orgs_edit_description")}>
@@ -9,6 +9,10 @@ const LINKS = [
title: "Bookings by Hour",
href: "/settings/admin/playground/bookings-by-hour",
},
{
title: "Weekly Calendar",
href: "/settings/admin/playground/weekly-calendar",
},
];
export default function Page() {
@@ -0,0 +1,521 @@
"use client";
import { useState } from "react";
import dayjs from "@calcom/dayjs";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import type { CalendarComponentProps } from "@calcom/features/calendars/weeklyview/types/state";
const makeDate = (dayOffset: number, hour: number, minute: number = 0) => {
return dayjs("2025-01-06").add(dayOffset, "day").hour(hour).minute(minute).second(0).toDate();
};
const getBaseProps = (events: CalendarEvent[]): CalendarComponentProps => ({
startDate: dayjs("2025-01-06").toDate(), // Monday
endDate: dayjs("2025-01-12").toDate(), // Sunday
events,
startHour: 6,
endHour: 18,
gridCellsPerHour: 4,
timezone: "UTC",
showBackgroundPattern: false,
showBorder: false,
hideHeader: true,
borderColor: "subtle",
});
type Scenario = {
id: string;
title: string;
description: string;
expected: string;
events: CalendarEvent[];
};
const scenarios: Scenario[] = [
{
id: "two-overlapping",
title: "Two Overlapping Events",
description: "Two events with overlapping time ranges on the same day",
expected:
"Second event should be offset 8% to the right, both 80% width. Hover should bring event to front.",
events: [
{
id: 1,
title: "Meeting A",
start: makeDate(0, 10, 0),
end: makeDate(0, 11, 0),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 2,
title: "Meeting B",
start: makeDate(0, 10, 30),
end: makeDate(0, 11, 30),
options: { status: "ACCEPTED", color: "#10b981" },
},
],
},
{
id: "three-cascading",
title: "Three Overlapping Events (Cascading)",
description: "Three events that overlap, creating a cascading effect",
expected:
"Events should cascade with offsets 0%, 8%, 16%. Z-index should increment. Hover brings any to top.",
events: [
{
id: 3,
title: "Long Meeting",
start: makeDate(1, 10, 0),
end: makeDate(1, 12, 0),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 4,
title: "Mid Meeting",
start: makeDate(1, 10, 30),
end: makeDate(1, 11, 30),
options: { status: "PENDING", color: "#f59e0b" },
},
{
id: 5,
title: "Late Meeting",
start: makeDate(1, 11, 0),
end: makeDate(1, 12, 30),
options: { status: "ACCEPTED", color: "#10b981" },
},
],
},
{
id: "non-overlapping",
title: "Non-Overlapping Events",
description: "Events that don't overlap should not cascade",
expected: "Both events at 0% offset (separate groups), no cascade. Both should be 80% width.",
events: [
{
id: 6,
title: "Morning Meeting",
start: makeDate(2, 10, 0),
end: makeDate(2, 11, 0),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 7,
title: "Afternoon Meeting",
start: makeDate(2, 12, 0),
end: makeDate(2, 13, 0),
options: { status: "ACCEPTED", color: "#10b981" },
},
],
},
{
id: "same-start-time",
title: "Same Start Time, Different Durations",
description: "Multiple events starting at the same time with varying lengths",
expected: "Longest event first (base of cascade), shorter ones offset 8%, 16%. All start at 10:00.",
events: [
{
id: 8,
title: "2-Hour Meeting",
start: makeDate(3, 10, 0),
end: makeDate(3, 12, 0),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 9,
title: "1.5-Hour Meeting",
start: makeDate(3, 10, 0),
end: makeDate(3, 11, 30),
options: { status: "ACCEPTED", color: "#f59e0b" },
},
{
id: 10,
title: "30-Min Meeting",
start: makeDate(3, 10, 0),
end: makeDate(3, 10, 30),
options: { status: "PENDING", color: "#10b981" },
},
],
},
{
id: "chain-overlaps",
title: "Chain Overlaps (A→B→C)",
description: "Events where A overlaps B, and B overlaps C",
expected: "Single overlap group with cascading offsets 0%, 8%, 16%.",
events: [
{
id: 11,
title: "Event A",
start: makeDate(4, 10, 0),
end: makeDate(4, 11, 0),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 12,
title: "Event B",
start: makeDate(4, 10, 30),
end: makeDate(4, 11, 30),
options: { status: "ACCEPTED", color: "#f59e0b" },
},
{
id: 13,
title: "Event C",
start: makeDate(4, 11, 0),
end: makeDate(4, 12, 0),
options: { status: "ACCEPTED", color: "#10b981" },
},
],
},
{
id: "dense-day",
title: "Dense Day (20+ Events)",
description: "A very busy day with many overlapping events",
expected:
"Visually tight stack with multiple cascading levels. Right edge should not overflow. Hover should still work.",
events: [
{
id: 14,
title: "Team Standup",
start: makeDate(5, 9, 0),
end: makeDate(5, 9, 30),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 15,
title: "Client Call",
start: makeDate(5, 9, 15),
end: makeDate(5, 10, 0),
options: { status: "ACCEPTED", color: "#10b981" },
},
{
id: 16,
title: "Design Review",
start: makeDate(5, 9, 45),
end: makeDate(5, 11, 0),
options: { status: "PENDING", color: "#f59e0b" },
},
{
id: 17,
title: "1-on-1",
start: makeDate(5, 10, 0),
end: makeDate(5, 10, 30),
options: { status: "ACCEPTED", color: "#8b5cf6" },
},
{
id: 18,
title: "Planning",
start: makeDate(5, 10, 15),
end: makeDate(5, 11, 30),
options: { status: "ACCEPTED", color: "#ec4899" },
},
{
id: 19,
title: "Code Review",
start: makeDate(5, 11, 0),
end: makeDate(5, 12, 0),
options: { status: "ACCEPTED", color: "#14b8a6" },
},
{
id: 20,
title: "Lunch Meeting",
start: makeDate(5, 11, 30),
end: makeDate(5, 12, 30),
options: { status: "ACCEPTED", color: "#f97316" },
},
{
id: 21,
title: "Workshop",
start: makeDate(5, 12, 0),
end: makeDate(5, 13, 30),
options: { status: "ACCEPTED", color: "#06b6d4" },
},
{
id: 22,
title: "Interview",
start: makeDate(5, 12, 30),
end: makeDate(5, 13, 0),
options: { status: "PENDING", color: "#84cc16" },
},
{
id: 23,
title: "Retrospective",
start: makeDate(5, 13, 0),
end: makeDate(5, 14, 0),
options: { status: "ACCEPTED", color: "#a855f7" },
},
{
id: 24,
title: "Demo",
start: makeDate(5, 13, 15),
end: makeDate(5, 14, 0),
options: { status: "CANCELLED", color: "#ef4444" },
},
{
id: 30,
title: "Quick Sync",
start: makeDate(5, 9, 10),
end: makeDate(5, 9, 40),
options: { status: "ACCEPTED", color: "#6366f1" },
},
{
id: 31,
title: "Architecture Discussion",
start: makeDate(5, 9, 30),
end: makeDate(5, 10, 30),
options: { status: "ACCEPTED", color: "#ec4899" },
},
{
id: 32,
title: "Sprint Planning",
start: makeDate(5, 10, 10),
end: makeDate(5, 11, 0),
options: { status: "ACCEPTED", color: "#f59e0b" },
},
{
id: 33,
title: "Product Demo",
start: makeDate(5, 10, 45),
end: makeDate(5, 11, 45),
options: { status: "PENDING", color: "#14b8a6" },
},
{
id: 34,
title: "Bug Triage",
start: makeDate(5, 11, 15),
end: makeDate(5, 12, 15),
options: { status: "ACCEPTED", color: "#8b5cf6" },
},
{
id: 35,
title: "Customer Feedback",
start: makeDate(5, 11, 45),
end: makeDate(5, 12, 45),
options: { status: "ACCEPTED", color: "#10b981" },
},
{
id: 36,
title: "Team Sync",
start: makeDate(5, 12, 15),
end: makeDate(5, 13, 15),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 37,
title: "Tech Talk",
start: makeDate(5, 12, 45),
end: makeDate(5, 13, 45),
options: { status: "ACCEPTED", color: "#f97316" },
},
{
id: 38,
title: "Standup Follow-up",
start: makeDate(5, 13, 30),
end: makeDate(5, 14, 0),
options: { status: "ACCEPTED", color: "#06b6d4" },
},
{
id: 39,
title: "Office Hours",
start: makeDate(5, 13, 45),
end: makeDate(5, 14, 30),
options: { status: "ACCEPTED", color: "#a855f7" },
},
],
},
{
id: "touching-events",
title: "Touching Events (Edge Case)",
description: "Events that touch exactly at boundaries",
expected: "Separate groups; no cascade; both at 0% offset. Events touching at 11:00 should not overlap.",
events: [
{
id: 25,
title: "Morning Block",
start: makeDate(6, 10, 0),
end: makeDate(6, 11, 0),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 26,
title: "Afternoon Block",
start: makeDate(6, 11, 0),
end: makeDate(6, 12, 0),
options: { status: "ACCEPTED", color: "#10b981" },
},
],
},
{
id: "mixed-statuses",
title: "Mixed Event Statuses",
description: "Events with different booking statuses",
expected:
"Visual styling should differ by status (ACCEPTED, PENDING, CANCELLED). Cascade should still work.",
events: [
{
id: 27,
title: "Confirmed Meeting",
start: makeDate(0, 14, 0),
end: makeDate(0, 15, 0),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 28,
title: "Pending Approval",
start: makeDate(0, 14, 30),
end: makeDate(0, 15, 30),
options: { status: "PENDING", color: "#f59e0b" },
},
{
id: 29,
title: "Cancelled Meeting",
start: makeDate(0, 15, 0),
end: makeDate(0, 16, 0),
options: { status: "CANCELLED", color: "#ef4444" },
},
],
},
{
id: "event-durations",
title: "Event Duration Layout Tests",
description: "Events with different durations to test layout logic (eventDuration > 30 changes flex-col)",
expected:
"Events ≤30min show horizontal layout (title and time inline). Events >30min show vertical layout (title and time stacked).",
events: [
{
id: 40,
title: "3min Quick Chat",
start: makeDate(0, 9, 0),
end: makeDate(0, 9, 3),
options: { status: "ACCEPTED", color: "#3b82f6" },
},
{
id: 41,
title: "7min Standup",
start: makeDate(0, 9, 15),
end: makeDate(0, 9, 22),
options: { status: "ACCEPTED", color: "#10b981" },
},
{
id: 42,
title: "15min Check-in",
start: makeDate(0, 9, 30),
end: makeDate(0, 9, 45),
options: { status: "PENDING", color: "#f59e0b" },
},
{
id: 43,
title: "20min Review",
start: makeDate(0, 10, 0),
end: makeDate(0, 10, 20),
options: { status: "ACCEPTED", color: "#8b5cf6" },
},
{
id: 44,
title: "30min Discussion",
start: makeDate(0, 10, 30),
end: makeDate(0, 11, 0),
options: { status: "ACCEPTED", color: "#ec4899" },
},
{
id: 45,
title: "32min Discussion",
start: makeDate(0, 11, 10),
end: makeDate(0, 11, 42),
options: { status: "ACCEPTED", color: "#ec4899" },
},
{
id: 46,
title: "40min Discussion",
start: makeDate(0, 12, 0),
end: makeDate(0, 12, 40),
options: { status: "ACCEPTED", color: "#ec4899" },
},
{
id: 47,
title: "53min Workshop",
description: "Learn about the latest trends in web development",
start: makeDate(0, 13, 0),
end: makeDate(0, 13, 53),
options: { status: "ACCEPTED", color: "#14b8a6" },
},
{
id: 48,
title: "90min Workshop",
description: "Learn about the latest trends in web development",
start: makeDate(0, 14, 0),
end: makeDate(0, 15, 30),
options: { status: "ACCEPTED", color: "#14b8a6" },
},
],
},
];
function ScenarioCard({ scenario }: { scenario: Scenario }) {
const [showData, setShowData] = useState(false);
return (
<div className="border-subtle rounded-lg border p-4">
<div className="mb-4">
<h3 className="text-lg font-semibold">{scenario.title}</h3>
<p className="text-subtle mt-1 text-sm">{scenario.description}</p>
<div className="bg-subtle mt-2 rounded p-2 text-xs">
<strong>Expected:</strong> {scenario.expected}
</div>
</div>
<div className="h-[600px] overflow-hidden rounded border">
<Calendar {...getBaseProps(scenario.events)} />
</div>
<button
onClick={() => setShowData(!showData)}
className="text-emphasis mt-2 text-sm underline hover:no-underline">
{showData ? "Hide" : "Show"} Event Data
</button>
{showData && (
<pre className="bg-subtle mt-2 overflow-auto rounded p-2 text-xs">
{JSON.stringify(scenario.events, null, 2)}
</pre>
)}
</div>
);
}
export default function WeeklyCalendarPlayground() {
return (
<div className="space-y-8 p-6">
<div>
<h1 className="text-3xl font-bold">Weekly Calendar Playground</h1>
<p className="text-subtle mt-2">
Test the overlapping events cascading layout with various scenarios. Events should cascade with 80%
width and 8% left offset per overlap level. Hovering an event should bring it to the front.
</p>
</div>
{/* Grid View of All Scenarios */}
<div className="space-y-8">
<h2 className="mb-4 text-2xl font-bold">All Scenarios (Side-by-Side)</h2>
{scenarios.map((scenario, index) => (
<ScenarioCard key={index} scenario={scenario} />
))}
</div>
{/* Testing Checklist */}
<div className="border-subtle rounded-lg border p-6">
<h2 className="mb-4 text-xl font-bold">Testing Checklist</h2>
<ul className="text-subtle space-y-2 text-sm">
<li>✓ Visual appearance matches expectations (80% width with 8% cascading offsets)</li>
<li>✓ Hover behavior works smoothly - hovered event appears topmost</li>
<li>✓ No visual glitches with 3+ overlapping events</li>
<li>✓ Performance is acceptable with dense event days (10+ events)</li>
<li>✓ Edge case: Events with identical start times render correctly</li>
<li>✓ Edge case: Events that touch exactly (end = next start) don&apos;t incorrectly overlap</li>
<li>✓ Different booking statuses (ACCEPTED, PENDING, CANCELLED) display correctly</li>
<li>✓ Color bars on the left side of events display correctly</li>
</ul>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import { _generateMetadata } from "app/_utils";
import OutOfOfficeEntriesList from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList";
import OutOfOfficeView from "~/settings/my-account/out-of-office-view";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -12,7 +12,7 @@ export const generateMetadata = async () =>
);
const Page = () => {
return <OutOfOfficeEntriesList />;
return <OutOfOfficeView />;
};
export default Page;
@@ -2,7 +2,7 @@ import { _generateMetadata, getTranslate } from "app/_utils";
import { redirect } from "next/navigation";
import { OtherTeamsListing } from "@calcom/features/ee/organizations/pages/components/OtherTeamsListing";
import { OrganizationRepository } from "@calcom/features/ee/organizations/repositories/OrganizationRepository";
import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { validateUserHasOrg } from "../../../actions/validateUserHasOrg";
@@ -24,11 +24,12 @@ const Page = async () => {
redirect("/auth/login");
}
const organizationId = session?.user?.org?.id;
const organizationRepository = getOrganizationRepository();
const otherTeams = organizationId
? await OrganizationRepository.findTeamsInOrgIamNotPartOf({
userId: session?.user.id,
parentId: organizationId,
})
? await organizationRepository.findTeamsInOrgIamNotPartOf({
userId: session?.user.id,
parentId: organizationId,
})
: [];
return (
@@ -130,7 +130,7 @@ const Page = async ({ searchParams }: PageProps) => {
throw error;
}
console.error("Failed to create Cal AI workflow:", error);
console.error("Failed to create Cal.ai workflow:", error);
redirect("/workflows?error=failed-to-create-workflow");
}
};
@@ -50,7 +50,7 @@ async function handler(req: NextRequest) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const payload: OAuthTokenPayload = {
const payloadAuthToken: OAuthTokenPayload = {
userId: decodedRefreshToken.userId,
teamId: decodedRefreshToken.teamId,
scope: decodedRefreshToken.scope,
@@ -58,11 +58,23 @@ async function handler(req: NextRequest) {
clientId: client_id,
};
const access_token = jwt.sign(payload, secretKey, {
const payloadRefreshToken: OAuthTokenPayload = {
userId: decodedRefreshToken.userId,
teamId: decodedRefreshToken.teamId,
scope: decodedRefreshToken.scope,
token_type: "Refresh Token",
clientId: client_id,
};
const access_token = jwt.sign(payloadAuthToken, secretKey, {
expiresIn: 1800, // 30 min
});
return NextResponse.json({ access_token }, { status: 200 });
const refresh_token = jwt.sign(payloadRefreshToken, secretKey, {
expiresIn: 30 * 24 * 60 * 60, // 30 days
});
return NextResponse.json({ access_token, refresh_token }, { status: 200 });
}
export const POST = defaultResponderForAppDir(handler);
+4 -5
View File
@@ -6,6 +6,7 @@ import type { NextRequest } from "next/server";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
import { bookingCancelWithCsrfSchema } from "@calcom/prisma/zod-utils";
import { validateCsrfToken } from "@calcom/web/lib/validateCsrfToken";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -17,13 +18,11 @@ async function handler(req: NextRequest) {
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 });
const csrfError = await validateCsrfToken(bookingData.csrfToken);
if (csrfError) {
return csrfError;
}
cookieStore.delete("calcom.csrf_token");
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+13
View File
@@ -8,6 +8,7 @@ import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -20,6 +21,7 @@ const checkoutSessionMetadataSchema = z.object({
teamName: z.string(),
teamSlug: z.string(),
userId: z.string().transform(Number),
isOnboarding: z.string().optional(),
});
const generateRandomString = () => {
@@ -66,6 +68,7 @@ async function getHandler(req: NextRequest) {
teamName: checkoutSession?.metadata?.teamName ?? generateRandomString(),
teamSlug: checkoutSession?.metadata?.teamSlug ?? generateRandomString(),
userId: checkoutSession.metadata.userId,
isOnboarding: checkoutSession.metadata.isOnboarding,
};
const team = await prisma.team.create({
@@ -102,6 +105,16 @@ async function getHandler(req: NextRequest) {
});
}
// Check if this is from onboarding flow and redirect accordingly
const isOnboarding = checkoutSessionMetadata.isOnboarding === "true";
if (isOnboarding) {
// Redirect to event-types for onboarding flow
return NextResponse.redirect(new URL("/onboarding/personal/settings", WEBAPP_URL), {
status: 302,
});
}
// redirect to team screen
return NextResponse.redirect(
new URL(`/settings/teams/${team.id}/onboard-members?event=team_created`, req.nextUrl.origin),
@@ -0,0 +1,94 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { z } from "zod";
import {
generateGuestMeetingTokenFromOwnerMeetingToken,
updateMeetingTokenIfExpired,
} from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { getHostsAndGuests } from "@calcom/features/bookings/lib/getHostsAndGuests";
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { VideoCallGuestRepository } from "@calcom/features/video-call-guest/repositories/VideoCallGuestRepository";
import prisma from "@calcom/prisma";
import { validateCsrfToken } from "@calcom/web/lib/validateCsrfToken";
const videoCallGuestWithCsrfSchema = z.object({
bookingUid: z.string(),
email: z.string().email(),
name: z.string().min(1),
csrfToken: z.string().length(64, "Invalid CSRF token"),
});
async function handler(req: NextRequest) {
let appDirRequestBody;
try {
appDirRequestBody = await req.json();
} catch {
return NextResponse.json({ success: false, message: "Invalid JSON" }, { status: 400 });
}
const guestData = videoCallGuestWithCsrfSchema.parse(appDirRequestBody);
const csrfError = await validateCsrfToken(guestData.csrfToken);
if (csrfError) {
return csrfError;
}
const bookingRepo = new BookingRepository(prisma);
const booking = await bookingRepo.findBookingIncludeCalVideoSettingsAndReferences({
bookingUid: guestData.bookingUid,
});
if (!booking) {
return NextResponse.json({ error: "Booking not found" }, { status: 404 });
}
const { hosts, guests } = getHostsAndGuests(booking);
const isHost = hosts.some((host) => host.email.toLowerCase() === guestData.email.trim().toLowerCase());
const isGuest = guests.some((guest) => guest.email.toLowerCase() === guestData.email.trim().toLowerCase());
if (isHost) {
return NextResponse.json({ error: "hosts_must_use_login" }, { status: 403 });
} else if (!isGuest) {
return NextResponse.json({ error: "invalid_guest_email" }, { status: 403 });
}
const videoCallGuestRepo = new VideoCallGuestRepository(prisma);
const guestSession = await videoCallGuestRepo.upsertVideoCallGuest({
bookingUid: guestData.bookingUid,
email: guestData.email,
name: guestData.name,
});
const videoReference = getCalVideoReference(booking.references);
if (!videoReference) {
return NextResponse.json({ error: "Video reference not found" }, { status: 404 });
}
const endTime = new Date(booking.endTime);
const fourteenDaysAfter = new Date(endTime.getTime() + 14 * 24 * 60 * 60 * 1000);
const epochTimeFourteenDaysAfter = Math.floor(fourteenDaysAfter.getTime() / 1000);
const videoReferencePassword = await updateMeetingTokenIfExpired({
bookingReferenceId: videoReference.id,
roomName: videoReference.uid,
meetingToken: videoReference.meetingPassword,
exp: epochTimeFourteenDaysAfter,
});
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken({
meetingToken: videoReferencePassword,
userId: guestSession.id,
});
return NextResponse.json({
guestSessionId: guestSession.id,
meetingPassword: guestMeetingPassword,
meetingUrl: videoReference.meetingUrl,
});
}
export const POST = defaultResponderForAppDir(handler);
@@ -3,6 +3,7 @@ import { Retell } from "retell-sdk";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { CalAiPhoneNumber, User, Team, Agent } from "@calcom/prisma/client";
import { CreditUsageType } from "@calcom/prisma/enums";
import { POST } from "../route";
@@ -71,6 +72,8 @@ vi.mock("retell-sdk", () => ({
const mockHasAvailableCredits = vi.fn();
const mockChargeCredits = vi.fn();
const mockSendCreditBalanceLimitReachedEmails = vi.fn();
const mockSendCreditBalanceLowWarningEmails = vi.fn();
vi.mock("@calcom/features/ee/billing/credit-service", () => ({
CreditService: vi.fn().mockImplementation(() => ({
@@ -79,6 +82,12 @@ vi.mock("@calcom/features/ee/billing/credit-service", () => ({
})),
}));
vi.mock("@calcom/emails/email-manager", () => ({
sendCreditBalanceLimitReachedEmails: (...args: unknown[]) =>
mockSendCreditBalanceLimitReachedEmails(...args),
sendCreditBalanceLowWarningEmails: (...args: unknown[]) => mockSendCreditBalanceLowWarningEmails(...args),
}));
const mockFindByPhoneNumber = vi.fn();
const mockFindByProviderAgentId = vi.fn();
@@ -231,6 +240,7 @@ describe("Retell AI Webhook Handler", () => {
credits: 58, // 120 seconds = 2 minutes * $0.29 = $0.58 = 58 credits
callDuration: 120,
externalRef: "retell:test-call-id",
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
})
);
});
@@ -288,6 +298,7 @@ describe("Retell AI Webhook Handler", () => {
credits: 87, // 180 seconds = 3 minutes * $0.29 = $0.87 = 87 credits
callDuration: 180,
externalRef: "retell:test-call-id",
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
})
);
});
@@ -471,6 +482,7 @@ describe("Retell AI Webhook Handler", () => {
credits: expectedCredits,
callDuration: durationSeconds,
externalRef: expect.stringMatching(/^retell:test-call-/),
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
})
);
}
@@ -517,7 +529,12 @@ describe("Retell AI Webhook Handler", () => {
const response = await callPOST(createMockRequest(body, "valid-signature"));
expect(response.status).toBe(200);
expect(mockChargeCredits).toHaveBeenCalledWith(
expect.objectContaining({ userId: 42, credits: 61, callDuration: 125 }) // 125s = 2.083 minutes * $0.29 = $0.604 = 61 credits (rounded up)
expect.objectContaining({
userId: 42,
credits: 61,
callDuration: 125,
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
}) // 125s = 2.083 minutes * $0.29 = $0.604 = 61 credits (rounded up)
);
});
@@ -574,6 +591,7 @@ describe("Retell AI Webhook Handler", () => {
credits: 29, // 60 seconds = 1 minute * $0.29 = $0.29 = 29 credits
callDuration: 60,
externalRef: "retell:test-idempotency-call",
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
})
);
});
@@ -760,6 +778,7 @@ describe("Retell AI Webhook Handler", () => {
credits: 4, // 7 seconds = 0.117 minutes * $0.29 = $0.034 = 4 credits (rounded up)
callDuration: 7,
externalRef: "retell:call_bcd94f5a50832873a5fd68cb1aa",
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
})
);
});
@@ -794,6 +813,7 @@ describe("Retell AI Webhook Handler", () => {
teamId: 10,
credits: 29, // 60 seconds = 1 minute * $0.29 = 29 credits
callDuration: 60,
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
})
);
});
+5 -3
View File
@@ -63,6 +63,8 @@ const RetellWebhookSchema = z.object({
.passthrough(),
});
type RetellCallData = z.infer<typeof RetellWebhookSchema>["call"];
async function chargeCreditsForCall({
userId,
teamId,
@@ -120,7 +122,7 @@ async function chargeCreditsForCall({
}
}
async function handleCallAnalyzed(callData: any) {
async function handleCallAnalyzed(callData: RetellCallData) {
const { from_number, call_id, call_cost, call_type, agent_id } = callData;
if (
@@ -165,7 +167,7 @@ async function handleCallAnalyzed(callData: any) {
}
userId = agent.userId ?? undefined;
teamId = agent.teamId ?? undefined;
teamId = agent.team?.parentId ?? agent.teamId ?? undefined;
log.info(`Processing web call ${call_id} for agent ${agent_id}, user ${userId}, team ${teamId}`);
} else {
@@ -181,7 +183,7 @@ async function handleCallAnalyzed(callData: any) {
}
userId = phoneNumber.userId ?? undefined;
teamId = phoneNumber.teamId ?? undefined;
teamId = phoneNumber.team?.parentId ?? phoneNumber.teamId ?? undefined;
log.info(`Processing phone call ${call_id} from ${from_number}, user ${userId}, team ${teamId}`);
}
+12 -9
View File
@@ -125,16 +125,19 @@ export default async function RootLayout({ children }: { children: React.ReactNo
style={
isEmbed
? {
background: "transparent",
// Keep the embed hidden till parent initializes and
// - gives it the appropriate styles if UI instruction is there.
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
visibility: "hidden",
}
background: "transparent",
// Keep the embed hidden till parent initializes and
// - gives it the appropriate styles if UI instruction is there.
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
visibility: "hidden",
// This in addition to visibility: hidden is to ensure that elements with specific opacity set are not visible
opacity: 0,
}
: {
visibility: "visible",
}
visibility: "visible",
opacity: 1,
}
}>
<IconSprites />
<SpeculationRules
+2 -2
View File
@@ -115,8 +115,8 @@ export const AppPage = ({
const handleAppInstall = () => {
if (isRedirectApp(slug)) {
setIsLoading(true);
mutation.mutate({ type, variant, slug });
// For redirect apps, open the external URL directly
if (website) window.open(website, "_blank", "noopener,noreferrer");
return;
}
setIsLoading(true);
@@ -10,7 +10,6 @@ export const AppSetupMap = {
"exchange2016-calendar": dynamic(() => import("@calcom/web/components/apps/exchange2016calendar/Setup")),
"caldav-calendar": dynamic(() => import("@calcom/web/components/apps/caldavcalendar/Setup")),
"ics-feed": dynamic(() => import("@calcom/web/components/apps/ics-feedcalendar/Setup")),
zapier: dynamic(() => import("@calcom/web/components/apps/zapier/Setup")),
make: dynamic(() => import("@calcom/web/components/apps/make/Setup")),
sendgrid: dynamic(() => import("@calcom/web/components/apps/sendgrid/Setup")),
stripe: dynamic(() => import("@calcom/web/components/apps/stripepayment/Setup")),
@@ -90,7 +90,7 @@ export const AlbyPaymentComponent = (props: IAlbyPaymentComponentProps) => {
<p className="text-sm">Click or scan the invoice below to pay</p>
<Link
href={`lightning:${paymentRequest}`}
className="inline-flex items-center justify-center rounded-2xl rounded-md border border-transparent bg-white p-2 font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
className="inline-flex items-center justify-center rounded-2xl border border-transparent bg-white p-2 font-medium text-black shadow-sm hover:brightness-95 focus:outline-none focus:ring-offset-2">
<QRCode size={192} value={paymentRequest} />
</Link>
-179
View File
@@ -1,179 +0,0 @@
import Link from "next/link";
import { useState } from "react";
import { Toaster } from "sonner";
import AppNotInstalledMessage from "@calcom/app-store/_components/AppNotInstalledMessage";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
export interface IZapierSetupProps {
inviteLink?: string;
}
const ZAPIER = "zapier";
export default function ZapierSetup(props: IZapierSetupProps) {
const [newApiKeys, setNewApiKeys] = useState<Record<string, string>>({});
const { t } = useLocale();
const utils = trpc.useUtils();
const integrations = trpc.viewer.apps.integrations.useQuery({ variant: "automation" });
const oldApiKey = trpc.viewer.apiKeys.findKeyOfType.useQuery({ appId: ZAPIER });
const teamsList = trpc.viewer.teams.listOwnedTeams.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const teams = teamsList.data?.map((team) => ({ id: team.id, name: team.name }));
const deleteApiKey = trpc.viewer.apiKeys.delete.useMutation({
onSuccess: () => {
utils.viewer.apiKeys.findKeyOfType.invalidate();
},
});
const zapierCredentials: { userCredentialIds: number[] } | undefined = integrations.data?.items.find(
(item: { type: string }) => item.type === "zapier_automation"
);
const [credentialId] = zapierCredentials?.userCredentialIds || [false];
const showContent = integrations.data && integrations.isSuccess && credentialId;
const isCalDev = process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.dev";
async function createApiKey(teamId?: number) {
const event = { note: "Zapier", expiresAt: null, appId: ZAPIER, teamId };
const apiKey = await utils.client.viewer.apiKeys.create.mutate(event);
if (oldApiKey.data) {
const oldKey = teamId
? oldApiKey.data.find((key) => key.teamId === teamId)
: oldApiKey.data.find((key) => !key.teamId);
if (oldKey) {
deleteApiKey.mutate({
id: oldKey.id,
});
}
}
return apiKey;
}
async function generateApiKey(teamId?: number) {
const apiKey = await createApiKey(teamId);
setNewApiKeys({ ...newApiKeys, [teamId || ""]: apiKey });
}
if (integrations.isPending) {
return <div className="bg-emphasis absolute z-50 flex h-screen w-full items-center" />;
}
return (
<div className="bg-emphasis flex h-screen">
{showContent ? (
<div className="bg-default m-auto max-w-[43em] overflow-auto rounded pb-10 md:p-10">
<div className="md:flex md:flex-row">
<div className="invisible md:visible">
<img className="h-11" src="/api/app-store/zapier/icon.svg" alt="Zapier Logo" />
</div>
<div className="ml-2 ltr:mr-2 rtl:ml-2 md:ml-5">
<div className="text-default">{t("setting_up_zapier")}</div>
<>
<div className="mt-1 text-xl">{t("generate_api_key")}:</div>
{!teams ? (
<Button color="secondary" onClick={() => createApiKey()} className="mb-4 mt-2">
{t("generate_api_key")}
</Button>
) : (
<>
<div className="mt-8 text-sm font-semibold">Your event types:</div>
{!newApiKeys[""] ? (
<Button color="secondary" onClick={() => generateApiKey()} className="mb-4 mt-2">
{t("generate_api_key")}
</Button>
) : (
<CopyApiKey apiKey={newApiKeys[""]} />
)}
{teams.map((team) => {
return (
<div key={team.name}>
<div className="mt-2 text-sm font-semibold">{team.name}:</div>
{!newApiKeys[team.id] ? (
<Button
color="secondary"
onClick={() => generateApiKey(team.id)}
className="mb-4 mt-2">
{t("generate_api_key")}
</Button>
) : (
<CopyApiKey apiKey={newApiKeys[team.id]} />
)}
</div>
);
})}
</>
)}
</>
<ol className="mb-5 ml-5 mt-5 list-decimal ltr:mr-5 rtl:ml-5">
{isCalDev && (
<li>
{t("go_to")}
<a href={props.inviteLink} className="text-orange-600 underline">
{t("zapier_invite_link")}
</a>
</li>
)}
<ServerTrans
t={t}
i18nKey="zapier_setup_instructions"
components={[
<li key="instruction_1">Log into your Zapier account and create a new Zap.</li>,
<li key="instruction_2">
Select Cal.com as your Trigger app. Also choose a Trigger event.
</li>,
<li key="instruction_3">Choose your account and then enter your Unique API Key.</li>,
<li key="instruction_4">Test your Trigger.</li>,
<li key="instruction_5">You&apos;re set!</li>,
]}
/>
</ol>
<Link href="/apps/installed/automation?hl=zapier" passHref={true} legacyBehavior>
<Button color="secondary">{t("done")}</Button>
</Link>
</div>
</div>
</div>
) : (
<AppNotInstalledMessage appName="zapier" />
)}
<Toaster position="bottom-right" />
</div>
);
}
const CopyApiKey = ({ apiKey }: { apiKey: string }) => {
const { t } = useLocale();
return (
<div>
<div className="my-2 mt-3 flex-wrap sm:flex sm:flex-nowrap">
<code className="bg-subtle h-full w-full whitespace-pre-wrap rounded-md py-[6px] pl-2 pr-2 sm:rounded-r-none sm:pr-5">
{apiKey}
</code>
<Tooltip side="top" content={t("copy_to_clipboard")}>
<Button
onClick={() => {
navigator.clipboard.writeText(apiKey);
showToast(t("api_key_copied"), "success");
}}
type="button"
className="mt-4 text-base sm:mt-0 sm:rounded-l-none">
<Icon name="clipboard" className="h-4 w-4 ltr:mr-2 rtl:ml-2" />
{t("copy")}
</Button>
</Tooltip>
</div>
<div className="text-subtle mb-5 mt-2 text-sm">{t("copy_somewhere_safe")}</div>
</div>
);
};
@@ -0,0 +1,593 @@
import { useState } from "react";
import type { z } from "zod";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuPortal,
} from "@calcom/ui/components/dropdown";
import { TextAreaField } from "@calcom/ui/components/form";
import type { ActionType } from "@calcom/ui/components/table";
import { showToast } from "@calcom/ui/components/toast";
import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import { ReassignDialog } from "@components/dialog/ReassignDialog";
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
import { RerouteDialog } from "@components/dialog/RerouteDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider";
import {
getCancelEventAction,
getEditEventActions,
getAfterEventActions,
getReportAction,
shouldShowEditActions,
type BookingActionContext,
} from "./bookingActions";
import type { BookingItemProps } from "./types";
interface BookingActionsDropdownProps {
booking: BookingItemProps;
}
export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
// Use store for all dialog states
const rejectionReason = useBookingActionsStoreContext((state) => state.rejectionReason);
const setRejectionReason = useBookingActionsStoreContext((state) => state.setRejectionReason);
const rejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.rejectionDialogIsOpen);
const setRejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.setRejectionDialogIsOpen);
const chargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.chargeCardDialogIsOpen);
const setChargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.setChargeCardDialogIsOpen);
const viewRecordingsDialogIsOpen = useBookingActionsStoreContext(
(state) => state.viewRecordingsDialogIsOpen
);
const setViewRecordingsDialogIsOpen = useBookingActionsStoreContext(
(state) => state.setViewRecordingsDialogIsOpen
);
const meetingSessionDetailsDialogIsOpen = useBookingActionsStoreContext(
(state) => state.meetingSessionDetailsDialogIsOpen
);
const setMeetingSessionDetailsDialogIsOpen = useBookingActionsStoreContext(
(state) => state.setMeetingSessionDetailsDialogIsOpen
);
const isNoShowDialogOpen = useBookingActionsStoreContext((state) => state.isNoShowDialogOpen);
const setIsNoShowDialogOpen = useBookingActionsStoreContext((state) => state.setIsNoShowDialogOpen);
const isOpenRescheduleDialog = useBookingActionsStoreContext((state) => state.isOpenRescheduleDialog);
const setIsOpenRescheduleDialog = useBookingActionsStoreContext((state) => state.setIsOpenRescheduleDialog);
const isOpenReassignDialog = useBookingActionsStoreContext((state) => state.isOpenReassignDialog);
const setIsOpenReassignDialog = useBookingActionsStoreContext((state) => state.setIsOpenReassignDialog);
const isOpenSetLocationDialog = useBookingActionsStoreContext((state) => state.isOpenSetLocationDialog);
const setIsOpenLocationDialog = useBookingActionsStoreContext((state) => state.setIsOpenLocationDialog);
const isOpenAddGuestsDialog = useBookingActionsStoreContext((state) => state.isOpenAddGuestsDialog);
const setIsOpenAddGuestsDialog = useBookingActionsStoreContext((state) => state.setIsOpenAddGuestsDialog);
const isOpenReportDialog = useBookingActionsStoreContext((state) => state.isOpenReportDialog);
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
const rerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.rerouteDialogIsOpen);
const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen);
const cardCharged = booking?.payment[0]?.success;
const attendeeList = booking.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
id: attendee.id,
noShow: attendee.noShow || false,
phoneNumber: attendee.phoneNumber,
};
});
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(data.message, "success");
await utils.viewer.bookings.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
if (data?.status === "REJECTED") {
setRejectionDialogIsOpen(false);
showToast(t("booking_rejection_success"), "success");
} else {
showToast(t("booking_confirmation_success"), "success");
}
utils.viewer.bookings.invalidate();
utils.viewer.me.bookingUnconfirmedCount.invalidate();
},
onError: () => {
showToast(t("booking_confirmation_failed"), "error");
utils.viewer.bookings.invalidate();
},
});
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
onSuccess: () => {
showToast(t("location_updated"), "success");
setIsOpenLocationDialog(false);
utils.viewer.bookings.invalidate();
},
onError: (e) => {
const errorMessages: Record<string, string> = {
UNAUTHORIZED: t("you_are_unauthorized_to_make_this_change_to_the_booking"),
BAD_REQUEST: e.message,
};
const message = errorMessages[e.data?.code as string] || t("location_update_failed");
showToast(message, "error");
},
});
const isUpcoming = new Date(booking.endTime) >= new Date();
const isOngoing = isUpcoming && new Date() >= new Date(booking.startTime);
const isBookingInPast = new Date(booking.endTime) < new Date();
const isCancelled = booking.status === "CANCELLED";
const isConfirmed = booking.status === "ACCEPTED";
const isRejected = booking.status === "REJECTED";
const isPending = booking.status === "PENDING";
const isRescheduled = booking.fromReschedule !== null;
const isRecurring = booking.recurringEventId !== null;
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
if (isCancelled) return "cancelled";
if (isRejected) return "rejected";
if (isBookingInPast) return "past";
return "upcoming";
};
const isTabRecurring = booking.listingStatus === "recurring";
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
const isBookingFromRoutingForm = !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
const userEmail = booking.loggedInUser.userEmail;
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
const isAttendee = !!userSeat;
const isCalVideoLocation =
!booking.location ||
booking.location === "integrations:daily" ||
(typeof booking.location === "string" && booking.location.trim() === "");
const isDisabledCancelling = booking.eventType.disableCancelling;
const isDisabledRescheduling = booking.eventType.disableRescheduling;
const getSeatReferenceUid = () => {
return userSeat?.referenceUid;
};
const bookingConfirm = async (confirm: boolean) => {
let body = {
bookingId: booking.id,
confirmed: confirm,
reason: rejectionReason,
};
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
}
mutation.mutate(body);
};
const saveLocation = async ({
newLocation,
credentialId,
}: {
newLocation: string;
credentialId: number | null;
}) => {
try {
await setLocationMutation.mutateAsync({
bookingId: booking.id,
newLocation,
credentialId,
});
} catch {
// Errors are shown through the mutation onError handler
}
};
const actionContext: BookingActionContext = {
booking,
isUpcoming,
isOngoing,
isBookingInPast,
isCancelled,
isConfirmed,
isRejected,
isPending,
isRescheduled,
isRecurring,
isTabRecurring,
isTabUnconfirmed,
isBookingFromRoutingForm,
isDisabledCancelling,
isDisabledRescheduling,
isCalVideoLocation,
showPendingPayment: false, // This will be calculated below
isAttendee,
cardCharged,
attendeeList,
getSeatReferenceUid,
t,
} as BookingActionContext;
const cancelEventAction = getCancelEventAction(actionContext);
const baseEditEventActions = getEditEventActions(actionContext);
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
...action,
onClick:
action.id === "reschedule_request"
? () => setIsOpenRescheduleDialog(true)
: action.id === "reroute"
? () => setRerouteDialogIsOpen(true)
: action.id === "change_location"
? () => setIsOpenLocationDialog(true)
: action.id === "add_members"
? () => setIsOpenAddGuestsDialog(true)
: action.id === "reassign"
? () => setIsOpenReassignDialog(true)
: undefined,
})) as ActionType[];
const baseAfterEventActions = getAfterEventActions(actionContext);
const afterEventActions: ActionType[] = baseAfterEventActions.map((action) => ({
...action,
onClick:
action.id === "view_recordings"
? () => setViewRecordingsDialogIsOpen(true)
: action.id === "meeting_session_details"
? () => setMeetingSessionDetailsDialogIsOpen(true)
: action.id === "charge_card"
? () => setChargeCardDialogIsOpen(true)
: action.id === "no_show"
? () => {
if (attendeeList.length === 1) {
const attendee = attendeeList[0];
noShowMutation.mutate({
bookingUid: booking.uid,
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
});
return;
}
setIsNoShowDialogOpen(true);
}
: undefined,
disabled:
action.disabled ||
(action.id === "no_show" && !(isBookingInPast || isOngoing)) ||
(action.id === "view_recordings" && !booking.isRecorded),
})) as ActionType[];
const reportAction = getReportAction(actionContext);
const reportActionWithHandler = {
...reportAction,
onClick: () => setIsOpenReportDialog(true),
};
const NoShowAttendeesDialog = ({
bookingUid,
attendees,
setIsOpen,
isOpen,
}: {
bookingUid: string;
attendees: Array<{
name: string;
email: string;
id: number;
noShow: boolean;
phoneNumber: string | null;
}>;
setIsOpen: (open: boolean) => void;
isOpen: boolean;
}) => {
const [noShowAttendees, setNoShowAttendees] = useState<
Array<{
email: string;
noShow: boolean;
}>
>(attendees.map((attendee) => ({ email: attendee.email, noShow: attendee.noShow })));
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(data.message, "success");
setIsOpen(false);
await utils.viewer.bookings.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent title={t("mark_as_no_show")} enableOverflow>
<div className="space-y-2">
{attendees.map((attendee, index) => (
<label key={attendee.email} className="flex items-center space-x-2">
<input
type="checkbox"
checked={noShowAttendees[index]?.noShow || false}
onChange={(e) => {
const newNoShowAttendees = [...noShowAttendees];
newNoShowAttendees[index] = {
email: attendee.email,
noShow: e.target.checked,
};
setNoShowAttendees(newNoShowAttendees);
}}
/>
<span>
{attendee.name} ({attendee.email})
</span>
</label>
))}
</div>
<DialogFooter>
<DialogClose />
<Button
onClick={() => {
noShowMutation.mutate({
bookingUid,
attendees: noShowAttendees,
});
}}>
{t("confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const parsedBooking = {
...booking,
eventType: booking.eventType.id ? booking.eventType : null,
};
// Render dialogs that might be triggered from BookingListItem even if dropdown is not shown
const dialogs = (
<>
<RescheduleDialog
isOpenDialog={isOpenRescheduleDialog}
setIsOpenDialog={setIsOpenRescheduleDialog}
bookingUId={booking.uid}
/>
{isOpenReassignDialog && (
<ReassignDialog
isOpenDialog={isOpenReassignDialog}
setIsOpenDialog={setIsOpenReassignDialog}
bookingId={booking.id}
teamId={booking.eventType?.team?.id || 0}
bookingFromRoutingForm={isBookingFromRoutingForm}
/>
)}
<EditLocationDialog
booking={booking}
saveLocation={saveLocation}
isOpenDialog={isOpenSetLocationDialog}
setShowLocationModal={setIsOpenLocationDialog}
teamId={booking.eventType?.team?.id}
/>
<AddGuestsDialog
isOpenDialog={isOpenAddGuestsDialog}
setIsOpenDialog={setIsOpenAddGuestsDialog}
bookingId={booking.id}
/>
<ReportBookingDialog
isOpenDialog={isOpenReportDialog}
setIsOpenDialog={setIsOpenReportDialog}
bookingUid={booking.uid}
isRecurring={isRecurring}
status={getBookingStatus()}
/>
{booking.paid && booking.payment[0] && (
<ChargeCardDialog
isOpenDialog={chargeCardDialogIsOpen}
setIsOpenDialog={setChargeCardDialogIsOpen}
bookingId={booking.id}
paymentAmount={booking.payment[0].amount}
paymentCurrency={booking.payment[0].currency}
/>
)}
{isCalVideoLocation && (
<ViewRecordingsDialog
booking={booking}
isOpenDialog={viewRecordingsDialogIsOpen}
setIsOpenDialog={setViewRecordingsDialogIsOpen}
timeFormat={booking.loggedInUser.userTimeFormat ?? null}
/>
)}
{isCalVideoLocation && meetingSessionDetailsDialogIsOpen && (
<MeetingSessionDetailsDialog
booking={booking}
isOpenDialog={meetingSessionDetailsDialogIsOpen}
setIsOpenDialog={setMeetingSessionDetailsDialogIsOpen}
timeFormat={booking.loggedInUser.userTimeFormat ?? null}
/>
)}
{isNoShowDialogOpen && (
<NoShowAttendeesDialog
bookingUid={booking.uid}
attendees={attendeeList}
setIsOpen={setIsNoShowDialogOpen}
isOpen={isNoShowDialogOpen}
/>
)}
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
<TextAreaField
name="rejectionReason"
label={
<>
{t("rejection_reason")}
<span className="text-subtle font-normal"> (Optional)</span>
</>
}
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
/>
</div>
<DialogFooter>
<DialogClose />
<Button
disabled={mutation.isPending}
data-testid="rejection-confirm"
onClick={() => {
bookingConfirm(false);
}}>
{t("rejection_confirmation")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{isBookingFromRoutingForm &&
parsedBooking.eventType &&
parsedBooking.eventType.id !== undefined &&
parsedBooking.eventType.slug !== undefined &&
parsedBooking.eventType.title !== undefined &&
parsedBooking.routedFromRoutingFormReponse && (
<RerouteDialog
isOpenDialog={rerouteDialogIsOpen}
setIsOpenDialog={setRerouteDialogIsOpen}
booking={{
...parsedBooking,
metadata: parsedBooking.metadata as z.infer<typeof bookingMetadataSchema>,
routedFromRoutingFormReponse: parsedBooking.routedFromRoutingFormReponse,
eventType: {
length: parsedBooking.eventType.length ?? 0,
schedulingType: parsedBooking.eventType.schedulingType ?? null,
title: parsedBooking.eventType.title,
id: parsedBooking.eventType.id,
slug: parsedBooking.eventType.slug,
team: parsedBooking.eventType.team ?? null,
},
}}
/>
)}
</>
);
// Don't render dropdown if edit actions shouldn't be shown
if (!shouldShowEditActions(actionContext)) {
return dialogs;
}
return (
<>
{dialogs}
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
type="button"
color="secondary"
variant="icon"
StartIcon="ellipsis"
data-testid="booking-actions-dropdown"
/>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
{editEventActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
<DropdownItem
type="button"
color={action.color}
StartIcon={action.icon}
href={action.href}
disabled={action.disabled}
onClick={action.onClick}
data-bookingid={action.bookingId}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
</DropdownItem>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
{afterEventActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
<DropdownItem
type="button"
color={action.color}
StartIcon={action.icon}
href={action.href}
onClick={action.onClick}
disabled={action.disabled}
data-bookingid={action.bookingId}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
</DropdownItem>
</DropdownMenuItem>
))}
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="rounded-lg"
key={reportActionWithHandler.id}
disabled={reportActionWithHandler.disabled}>
<DropdownItem
type="button"
color={reportActionWithHandler.color}
StartIcon={reportActionWithHandler.icon}
onClick={reportActionWithHandler.onClick}
disabled={reportActionWithHandler.disabled}
data-testid={reportActionWithHandler.id}
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
{reportActionWithHandler.label}
</DropdownItem>
</DropdownMenuItem>
</>
<DropdownMenuSeparator />
<DropdownMenuItem
className="rounded-lg"
key={cancelEventAction.id}
disabled={cancelEventAction.disabled}>
<DropdownItem
type="button"
color={cancelEventAction.color}
StartIcon={cancelEventAction.icon}
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
onClick={cancelEventAction.onClick}
disabled={cancelEventAction.disabled}
data-bookingid={cancelEventAction.bookingId}
data-testid={cancelEventAction.id}
className={cancelEventAction.disabled ? "text-muted" : undefined}>
{cancelEventAction.label}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
</>
);
}
@@ -0,0 +1,39 @@
"use client";
import { createContext, useContext, useRef, type ReactNode } from "react";
import { useStore } from "zustand";
import type { StoreApi } from "zustand";
import { createBookingActionsStore, type BookingActionsStore } from "./store";
export const BookingActionsStoreContext = createContext<StoreApi<BookingActionsStore> | null>(null);
export interface BookingActionsStoreProviderProps {
children: ReactNode;
}
export const BookingActionsStoreProvider = ({ children }: BookingActionsStoreProviderProps) => {
const storeRef = useRef<StoreApi<BookingActionsStore>>();
if (!storeRef.current) {
storeRef.current = createBookingActionsStore();
}
return (
<BookingActionsStoreContext.Provider value={storeRef.current}>
{children}
</BookingActionsStoreContext.Provider>
);
};
export const useBookingActionsStoreContext = <T,>(
selector: (store: BookingActionsStore) => T,
equalityFn?: (a: T, b: T) => boolean
): T => {
const bookingActionsStoreContext = useContext(BookingActionsStoreContext);
if (!bookingActionsStoreContext) {
throw new Error("useBookingActionsStoreContext must be used within BookingActionsStoreProvider");
}
return useStore(bookingActionsStoreContext, selector, equalityFn);
};
+23 -385
View File
@@ -1,3 +1,4 @@
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
@@ -9,8 +10,6 @@ import dayjs from "@calcom/dayjs";
// TODO: Use browser locale, implement Intl in Dayjs maybe?
import "@calcom/dayjs/locales";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
import { formatTime } from "@calcom/lib/dayjs";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -20,7 +19,6 @@ import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import type { AssignmentReason } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import classNames from "@calcom/ui/classNames";
@@ -48,42 +46,18 @@ import { Tooltip } from "@calcom/ui/components/tooltip";
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import { ReassignDialog } from "@components/dialog/ReassignDialog";
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
import { RerouteDialog } from "@components/dialog/RerouteDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
import { BookingActionsDropdown } from "./BookingActionsDropdown";
import { useBookingActionsStoreContext, BookingActionsStoreProvider } from "./BookingActionsStoreProvider";
import {
getPendingActions,
getCancelEventAction,
getEditEventActions,
getAfterEventActions,
getReportAction,
shouldShowPendingActions,
shouldShowEditActions,
shouldShowRecurringCancelAction,
shouldShowIndividualReportButton,
type BookingActionContext,
getReportAction,
} from "./bookingActions";
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
export type BookingItemProps = BookingItem & {
listingStatus: BookingListingStatus;
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined;
loggedInUser: {
userId: number | undefined;
userTimeZone: string | undefined;
userTimeFormat: number | null | undefined;
userEmail: string | undefined;
};
isToday: boolean;
};
import type { BookingItemProps } from "./types";
type ParsedBooking = ReturnType<typeof buildParsedBooking>;
type TeamEvent = Ensure<NonNullable<ParsedBooking["eventType"]>, "team">;
@@ -131,11 +105,6 @@ function BookingListItem(booking: BookingItemProps) {
const utils = trpc.useUtils();
const [rejectionReason, setRejectionReason] = useState<string>("");
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
const [meetingSessionDetailsDialogIsOpen, setMeetingSessionDetailsDialogIsOpen] = useState<boolean>(false);
const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState<boolean>(false);
const cardCharged = booking?.payment[0]?.success;
const attendeeList = booking.attendees.map((attendee) => {
return {
@@ -147,17 +116,6 @@ function BookingListItem(booking: BookingItemProps) {
};
});
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(data.message, "success");
// Invalidate and refetch the bookings query to update the UI
await utils.viewer.bookings.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
if (data?.status === BookingStatus.REJECTED) {
@@ -185,12 +143,6 @@ function BookingListItem(booking: BookingItemProps) {
const isRescheduled = booking.fromReschedule !== null;
const isRecurring = booking.recurringEventId !== null;
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
if (isCancelled) return "cancelled";
if (isRejected) return "rejected";
if (isBookingInPast) return "past";
return "upcoming";
};
const isTabRecurring = booking.listingStatus === "recurring";
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
@@ -219,6 +171,7 @@ function BookingListItem(booking: BookingItemProps) {
const isDisabledCancelling = booking.eventType.disableCancelling;
const isDisabledRescheduling = booking.eventType.disableRescheduling;
const cardCharged = booking?.payment[0]?.success;
const bookingConfirm = async (confirm: boolean) => {
let body = {
@@ -298,49 +251,6 @@ function BookingListItem(booking: BookingItemProps) {
.tz(userTimeZone)
.locale(language)
.format(isUpcoming ? (isDifferentYear ? "ddd, D MMM YYYY" : "ddd, D MMM") : "D MMMM YYYY");
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false);
const [isOpenReportDialog, setIsOpenReportDialog] = useState(false);
const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false);
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
onSuccess: () => {
showToast(t("location_updated"), "success");
setIsOpenLocationDialog(false);
utils.viewer.bookings.invalidate();
},
onError: (e) => {
const errorMessages: Record<string, string> = {
UNAUTHORIZED: t("you_are_unauthorized_to_make_this_change_to_the_booking"),
BAD_REQUEST: e.message,
};
const message = errorMessages[e.data?.code as string] || t("location_update_failed");
showToast(message, "error");
},
});
const saveLocation = async ({
newLocation,
credentialId,
}: {
newLocation: string;
/**
* It could be set for conferencing locations that support team level installations.
*/
credentialId: number | null;
}) => {
try {
await setLocationMutation.mutateAsync({
bookingId: booking.id,
newLocation,
credentialId,
});
} catch {
// Errors are shown through the mutation onError handler
}
};
// Getting accepted recurring dates to show
const recurringDates = booking.recurringInfo?.bookings[BookingStatus.ACCEPTED]
@@ -360,58 +270,9 @@ function BookingListItem(booking: BookingItemProps) {
const title = booking.title;
const isCalVideoLocation =
!booking.location ||
booking.location === "integrations:daily" ||
(typeof booking.location === "string" && booking.location.trim() === "");
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
const baseEditEventActions = getEditEventActions(actionContext);
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
...action,
onClick:
action.id === "reschedule_request"
? () => setIsOpenRescheduleDialog(true)
: action.id === "reroute"
? () => setRerouteDialogIsOpen(true)
: action.id === "change_location"
? () => setIsOpenLocationDialog(true)
: action.id === "add_members"
? () => setIsOpenAddGuestsDialog(true)
: action.id === "reassign"
? () => setIsOpenReassignDialog(true)
: undefined,
})) as ActionType[];
const baseAfterEventActions = getAfterEventActions(actionContext);
const afterEventActions: ActionType[] = baseAfterEventActions.map((action) => ({
...action,
onClick:
action.id === "view_recordings"
? () => setViewRecordingsDialogIsOpen(true)
: action.id === "meeting_session_details"
? () => setMeetingSessionDetailsDialogIsOpen(true)
: action.id === "charge_card"
? () => setChargeCardDialogIsOpen(true)
: action.id === "no_show"
? () => {
if (attendeeList.length === 1) {
const attendee = attendeeList[0];
noShowMutation.mutate({
bookingUid: booking.uid,
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
});
return;
}
setIsNoShowDialogOpen(true);
}
: undefined,
disabled:
action.disabled ||
(action.id === "no_show" && !(isBookingInPast || isOngoing)) ||
(action.id === "view_recordings" && !booking.isRecorded),
})) as ActionType[];
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
const reportAction = getReportAction(actionContext);
const reportActionWithHandler = {
@@ -421,72 +282,6 @@ function BookingListItem(booking: BookingItemProps) {
return (
<>
<RescheduleDialog
isOpenDialog={isOpenRescheduleDialog}
setIsOpenDialog={setIsOpenRescheduleDialog}
bookingUId={booking.uid}
/>
{isOpenReassignDialog && (
<ReassignDialog
isOpenDialog={isOpenReassignDialog}
setIsOpenDialog={setIsOpenReassignDialog}
bookingId={booking.id}
teamId={booking.eventType?.team?.id || 0}
bookingFromRoutingForm={isBookingFromRoutingForm}
/>
)}
<EditLocationDialog
booking={booking}
saveLocation={saveLocation}
isOpenDialog={isOpenSetLocationDialog}
setShowLocationModal={setIsOpenLocationDialog}
teamId={booking.eventType?.team?.id}
/>
<AddGuestsDialog
isOpenDialog={isOpenAddGuestsDialog}
setIsOpenDialog={setIsOpenAddGuestsDialog}
bookingId={booking.id}
/>
<ReportBookingDialog
isOpenDialog={isOpenReportDialog}
setIsOpenDialog={setIsOpenReportDialog}
bookingUid={booking.uid}
isRecurring={isRecurring}
status={getBookingStatus()}
/>
{booking.paid && booking.payment[0] && (
<ChargeCardDialog
isOpenDialog={chargeCardDialogIsOpen}
setIsOpenDialog={setChargeCardDialogIsOpen}
bookingId={booking.id}
paymentAmount={booking.payment[0].amount}
paymentCurrency={booking.payment[0].currency}
/>
)}
{isCalVideoLocation && (
<ViewRecordingsDialog
booking={booking}
isOpenDialog={viewRecordingsDialogIsOpen}
setIsOpenDialog={setViewRecordingsDialogIsOpen}
timeFormat={userTimeFormat ?? null}
/>
)}
{isCalVideoLocation && meetingSessionDetailsDialogIsOpen && (
<MeetingSessionDetailsDialog
booking={booking}
isOpenDialog={meetingSessionDetailsDialogIsOpen}
setIsOpenDialog={setMeetingSessionDetailsDialogIsOpen}
timeFormat={userTimeFormat ?? null}
/>
)}
{isNoShowDialogOpen && (
<NoShowAttendeesDialog
bookingUid={booking.uid}
attendees={attendeeList}
setIsOpen={setIsNoShowDialogOpen}
isOpen={isNoShowDialogOpen}
/>
)}
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
@@ -555,8 +350,10 @@ function BookingListItem(booking: BookingItemProps) {
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
<div className="flex items-center gap-2">
{provider?.iconUrl && (
<img
<Image
src={provider.iconUrl}
width={16}
height={16}
className="h-4 w-4 rounded-sm"
alt={`${provider?.label} logo`}
/>
@@ -661,94 +458,7 @@ function BookingListItem(booking: BookingItemProps) {
</div>
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
{shouldShowEditActions(actionContext) && (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
type="button"
color="secondary"
variant="icon"
StartIcon="ellipsis"
data-testid="booking-actions-dropdown"
/>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
{editEventActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
<DropdownItem
type="button"
color={action.color}
StartIcon={action.icon}
href={action.href}
disabled={action.disabled}
onClick={action.onClick}
data-bookingid={action.bookingId}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
</DropdownItem>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
{afterEventActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
<DropdownItem
type="button"
color={action.color}
StartIcon={action.icon}
href={action.href}
onClick={action.onClick}
disabled={action.disabled}
data-bookingid={action.bookingId}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
</DropdownItem>
</DropdownMenuItem>
))}
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="rounded-lg"
key={reportActionWithHandler.id}
disabled={reportActionWithHandler.disabled}>
<DropdownItem
type="button"
color={reportActionWithHandler.color}
StartIcon={reportActionWithHandler.icon}
onClick={reportActionWithHandler.onClick}
disabled={reportActionWithHandler.disabled}
data-testid={reportActionWithHandler.id}
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
{reportActionWithHandler.label}
</DropdownItem>
</DropdownMenuItem>
</>
<DropdownMenuSeparator />
<DropdownMenuItem
className="rounded-lg"
key={cancelEventAction.id}
disabled={cancelEventAction.disabled}>
<DropdownItem
type="button"
color={cancelEventAction.color}
StartIcon={cancelEventAction.icon}
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
onClick={cancelEventAction.onClick}
disabled={cancelEventAction.disabled}
data-bookingid={cancelEventAction.bookingId}
data-testid={cancelEventAction.id}
className={cancelEventAction.disabled ? "text-muted" : undefined}>
{cancelEventAction.label}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
)}
<BookingActionsDropdown booking={booking} />
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
{shouldShowIndividualReportButton(actionContext) && (
<div className="flex items-center space-x-2">
@@ -760,7 +470,7 @@ function BookingListItem(booking: BookingItemProps) {
onClick={reportActionWithHandler.onClick}
disabled={reportActionWithHandler.disabled}
data-testid={reportActionWithHandler.id}
className="h-8 w-8"
className="min-h-[34px] min-w-[34px]"
tooltip={reportActionWithHandler.label}
/>
</div>
@@ -782,14 +492,6 @@ function BookingListItem(booking: BookingItemProps) {
isRescheduled={isRescheduled}
/>
</div>
{isBookingFromRoutingForm && (
<RerouteDialog
isOpenDialog={rerouteDialogIsOpen}
setIsOpenDialog={setRerouteDialogIsOpen}
booking={{ ...parsedBooking, eventType: parsedBooking.eventType }}
/>
)}
</>
);
}
@@ -903,7 +605,7 @@ const RecurringBookingsTooltip = ({
return (
(booking.recurringInfo &&
booking.eventType?.recurringEvent?.freq &&
booking.eventType?.recurringEvent?.freq != null &&
(booking.listingStatus === "recurring" ||
booking.listingStatus === "unconfirmed" ||
booking.listingStatus === "cancelled") && (
@@ -1174,79 +876,6 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
);
};
const NoShowAttendeesDialog = ({
attendees,
isOpen,
setIsOpen,
bookingUid,
}: {
attendees: AttendeeProps[];
isOpen: boolean;
setIsOpen: (value: boolean) => void;
bookingUid: string;
}) => {
const { t } = useLocale();
const [noShowAttendees, setNoShowAttendees] = useState(
attendees.map((attendee) => ({
id: attendee.id,
email: attendee.email,
name: attendee.name,
noShow: attendee.noShow || false,
}))
);
const utils = trpc.useUtils();
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
const newValue = data.attendees[0];
setNoShowAttendees((old) =>
old.map((attendee) =>
attendee.email === newValue.email ? { ...attendee, noShow: newValue.noShow } : attendee
)
);
showToast(t(data.message), "success");
await utils.viewer.bookings.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
return (
<Dialog open={isOpen} onOpenChange={() => setIsOpen(false)}>
<DialogContent title={t("mark_as_no_show_title")} description={t("no_show_description")} enableOverflow>
{noShowAttendees.map((attendee, index) => (
<form
key={attendee.id}
onSubmit={(e) => {
e.preventDefault();
noShowMutation.mutate({
bookingUid,
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
});
}}>
<div
className={`bg-muted flex items-center justify-between px-4 py-2 ${
index === 0 ? "rounded-t-md" : ""
} ${index === noShowAttendees.length - 1 ? "rounded-b-md" : "border-subtle border-b"}`}>
<span className="text-emphasis flex flex-col text-sm">
{attendee.name}
{attendee.email && <span className="text-muted">({attendee.email})</span>}
</span>
<Button color="minimal" type="submit" StartIcon={attendee.noShow ? "eye-off" : "eye"}>
{attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
</Button>
</div>
</form>
))}
<DialogFooter noSticky>
<DialogClose>{t("done")}</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
const [openDropdown, setOpenDropdown] = useState(false);
const { t } = useLocale();
@@ -1369,4 +998,13 @@ const AssignmentReasonTooltip = ({ assignmentReason }: { assignmentReason: Assig
);
};
export default BookingListItem;
// Wrap BookingListItem with BookingActionsStoreProvider to provide isolated store for each booking
const BookingListItemWithProvider = (props: BookingItemProps) => {
return (
<BookingActionsStoreProvider>
<BookingListItem {...props} />
</BookingActionsStoreProvider>
);
};
export default BookingListItemWithProvider;
@@ -1,7 +1,7 @@
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import type { ActionType } from "@calcom/ui/components/table";
import type { BookingItemProps } from "./BookingListItem";
import type { BookingItemProps } from "./types";
export interface BookingActionContext {
booking: BookingItemProps;

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