Compare commits

...
Author SHA1 Message Date
github-actions[bot] b84405c5eb chore: version packages (#22418)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-11 18:53:35 +00:00
Morgan 2743b32536 fix: third part token auth strategy (#22374) 2025-07-11 17:27:51 +00:00
Omar López a251422f7a fix: locks eslint to v8 across monorepo (#22424) 2025-07-11 17:10:12 +00:00
Alex van Andel d7c9bd6ed8 perf: Tasker performance improvements (#22395)
* perf: Tasker performance improvements

* Remove fields from expected tasker create call, test fix

* Fix the tests, needs validation
2025-07-11 10:09:24 -07:00
8b3ff0b789 fix: ensure isBookingDryRun prop works for booking creation (#22182)
* fix: ensure isBookingDryRun prop works for booking creation

- Add isBookingDryRun prop to useHandleBookEvent hook
- Pass prop through BookerPlatformWrapper and useBookings
- Override _isDryRun flag when prop is provided
- Fixes issue where dry run banner showed but booking creation ignored prop

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

* fixup

* if dry run prop is pass we use that, otherwise we use params

* add changesets for PR

* replace minor with patch changeset

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: rajiv@cal.com <rajiv@cal.com>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-11 16:45:30 +00:00
Lauris Skraucis 94a537bfda feat: v2 bookings return rescheduledToUid (#22289) 2025-07-11 13:17:55 -03:00
Calcom BotandLingo.dev 64305ffee1 feat: update translations via @LingoDotDev (#22416)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-07-11 15:55:52 +00:00
45698db0b4 fix: validate Round Robin host availability in handleNewBooking (#22253)
* fix: validate Round Robin host availability in handleNewBooking

- Add validation to ensure Round Robin events have at least one available non-fixed host
- Throw NoAvailableUsersFound error when no Round Robin hosts are available
- Add unit test to verify the fix works correctly
- Fixes issue where Round Robin events with fixed hosts could be booked without Round Robin hosts

Co-Authored-By: carina@cal.com <c.wollendorfer@me.com>

* test: fix host configuration in Round Robin test

Co-Authored-By: carina@cal.com <c.wollendorfer@me.com>

* test: improve Round Robin test to only make RR host busy

Co-Authored-By: carina@cal.com <c.wollendorfer@me.com>

* fix: correct Round Robin validation to only check when RR hosts assigned

Co-Authored-By: carina@cal.com <c.wollendorfer@me.com>

* fix: refine Round Robin validation to only apply when both fixed and RR hosts present

- Add fixedUserPool.length > 0 condition to validation
- Ensures validation only triggers for specific bug scenario: events with fixed hosts + RR hosts but no available RR hosts
- Prevents blocking normal Round Robin events that only have RR hosts
- Updates test description to reflect more precise validation logic

Co-Authored-By: carina@cal.com <c.wollendorfer@me.com>

* fix: simplify Round Robin validation to resolve E2E test failures

Remove overly restrictive fixedUserPool.length > 0 condition that was
blocking valid Round Robin booking scenarios in E2E tests. The validation
now only checks if Round Robin hosts are assigned but none are available,
which is the intended behavior for the bug fix.

Co-Authored-By: carina@cal.com <c.wollendorfer@me.com>

* fix: restore fixedUserPool condition to Round Robin validation

- Add back fixedUserPool.length > 0 condition to make validation specific to bug scenario
- Only triggers when Round Robin events have both fixed hosts AND Round Robin hosts but no available Round Robin hosts
- Prevents blocking normal Round Robin events that only have Round Robin hosts (like in organization settings E2E test)
- Updates test description to reflect more precise validation logic

Co-Authored-By: carina@cal.com <c.wollendorfer@me.com>

Co-Authored-By: carina@cal.com <c.wollendorfer@me.com>

* throw error if no RR user is available

* add tests

* fix comments

* revert change

* remove comments

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-07-11 16:47:21 +01:00
Anik Dhabal Babuandcubic-dev-ai[bot] 14e14289f0 fix: e2e tests flakes (#22415)
* fix: e2e flakes

* Update apps/web/playwright/dynamic-booking-pages.e2e.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update delete-account.e2e.ts

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-11 16:45:06 +01:00
Omar López 18650b1215 fix: local eslint runs (#22402) 2025-07-11 16:44:35 +01:00
d2bbbf3f45 feat: expose AvailabilitySettingsProps type in @calcom/atoms (#22246)
* feat: expose AvailabilitySettingsProps type in @calcom/atoms

- Export AvailabilitySettingsPlatformWrapperProps as AvailabilitySettingsProps
- Add type export to main atoms package index for direct import
- Enables external consumers to import type directly from @calcom/atoms

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

* feat: also export AtomScheduleType for atomSchedule data

- Export FindDetailedScheduleByIdReturnType as AtomScheduleType
- Enables direct import of atomSchedule type from @calcom/atoms
- Covers both AvailabilitySettingsProps and AtomScheduleType exports

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

* fix: update AtomScheduleType to include null and undefined

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

* refactor: move AtomScheduleType to AvailabilitySettingsPlatformWrapper for cleaner organization

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

* feat: rename AtomScheduleType to AvailabilitySettingsAtomScheduleType for better clarity

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

* feat: rename to AvailabilitySettingsScheduleType for better naming consistency

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

* fix: use proper import from @calcom/platform-libraries/schedules instead of dynamic import

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

* add changelogs

* feat: extract AvailabilitySettingsSchedule type from AvailabilitySettingsProps

- Extract schedule property structure into separate AvailabilitySettingsSchedule type
- Update AvailabilitySettingsProps to reuse the extracted schedule type
- Export AvailabilitySettingsSchedule from @calcom/atoms package index
- Enables direct import of schedule type for external consumers

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

* Revert "feat: extract AvailabilitySettingsSchedule type from AvailabilitySettingsProps"

This reverts commit 79239e4b18925db3fcc1d8df7e9f37770ec4f116.

* fixup: extract schedule for AvailabilitySettingsProps

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: rajiv@cal.com <rajiv@cal.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
2025-07-11 16:53:40 +02:00
devin-ai-integration[bot]andmorgan@cal.com 13cb99b7fe feat: inject required repositories into AvailableSlotsService using dependency injection (#22413)
* feat: inject all repositories into AvailableSlotsService using dependency injection

- Create DI modules for SelectedSlotsRepository, TeamRepository, UserRepository, BookingRepository, EventTypeRepository, RoutingFormResponseRepository
- Update available-slots DI module to bind all 6 repositories
- Update DI container to load all new repository modules
- Replace all 10 direct repository instantiations with injected dependencies
- Add missing DI tokens for repository modules
- Expand IAvailableSlotsService interface to include all repository dependencies

This follows the same dependency injection pattern established in previous repository refactoring PRs and enables better testability and modularity by making all dependencies explicit and configurable.

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

* chore: DI available slots repo on api v2

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2025-07-11 17:37:24 +03:00
a1571d01bd docs: Update Organization Readme (#22060)
* Update org readme

* Update packages/features/ee/organizations/README.md

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2025-07-11 18:30:28 +05:30
Kartik Saini bbd8af074e chore: update i18n workflow to use Node.js 20 (#22403) 2025-07-11 12:17:12 +00:00
c9a47dd0ce refactor: convert RoutingFormResponseRepository to use dependency injection pattern (#22408)
* refactor: convert RoutingFormResponseRepository to use dependency injection pattern

- Add constructor with PrismaClient dependency injection
- Convert all 5 static methods to instance methods using this.prismaClient
- Convert private static helper method generateCreateFormResponseData to instance method
- Update all usage sites to use two-step instantiation pattern:
  const formResponseRepo = new RoutingFormResponseRepository(prisma); formResponseRepo.method(...)
- Update test files to use proper mock implementation with vi.mocked pattern
- Reuse repository instances within same function scope where multiple calls are made
- Follow same dependency injection pattern as UserRepository, TeamRepository, SelectedSlotsRepository, BookingRepository, and EventTypeRepository

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

* fix: resolve inconsistent dependency injection in AvailableSlotsService

- Use two-step instantiation pattern for UserRepository calls
- Continue fixing remaining repository instantiation inconsistencies

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-11 10:03:25 +00:00
sean-brydon 1c9778e667 fix members page crash with pbac feature flag (#22409) 2025-07-11 09:59:56 +01:00
devin-ai-integration[bot]andmorgan@cal.com 758143ef70 refactor: convert EventTypeRepository to use dependency injection pattern (#22397)
- Add constructor with PrismaClient dependency injection
- Convert all 14 static methods to instance methods using this.prismaClient
- Update all usage sites to use two-step instantiation pattern:
  const eventTypeRepo = new EventTypeRepository(prisma); eventTypeRepo.method(...)
- Keep getSelectedCalendarsFromUser as static utility method
- Follow same pattern as UserRepository, TeamRepository, SelectedSlotsRepository, and BookingRepository
- Maintain all existing method signatures and functionality

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2025-07-11 10:39:22 +03:00
sean-brydon b5911a9a2d feat: add method for getting teamId permissions PBAC (#22375)
* feat:add method for getting teamId permissions

* add logic and test for zero permsiion strings

* fix test by updating mock
2025-07-11 08:34:33 +01:00
keithwillcode 260f4e1b51 chore: release v5.4.19
Post release / release (push) Canceled after 0s
2025-07-11 01:25:57 +00:00
Keith Williams e6c36af3b4 revert: "fix: Add new route to create team routing-form response (#22347)" (#22399)
This reverts commit 89e7fca183.
2025-07-11 01:02:11 +00:00
Omar López 2fb2458eee Revert "feat: add race condition test for calendar-cache-serve booking flow (…" (#22400) 2025-07-10 17:43:41 -07:00
keithwillcode b340b0edbe chore: release v5.4.18 2025-07-10 22:59:01 +00:00
f7fd1c4402 feat: remove unused x-cal-timezone header and middleware matcher (#22398)
* feat: remove unused x-cal-timezone header and middleware matcher

- Remove x-cal-timezone header setting from middleware.ts
- Remove x-cal-timezone header setting from createNextApiHandler.ts
- Remove /api/trpc/:path* from middleware matcher
- Eliminates ~50M edge requests per month for unused functionality
- All timezone handling uses browser detection and localStorage instead

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* update POST_METHODS_ALLOWED_API_ROUTES

* test: remove api/trpc POST validation test

- Remove test for /api/trpc/book/event POST requests since /api/trpc/:path* was removed from middleware matcher
- Keep test for /api/auth/signup which is still processed by middleware

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-07-10 22:16:25 +00:00
devin-ai-integration[bot]andmorgan@cal.com c316721051 refactor: convert BookingRepository to use dependency injection pattern (#22389)
* refactor: convert BookingRepository to use dependency injection pattern

- Add constructor injection for PrismaClient to BookingRepository
- Convert all static methods to instance methods using this.prismaClient
- Update all usage sites to use two-step instantiation pattern:
  const bookingRepo = new BookingRepository(prisma); bookingRepo.method(...)
- Apply same dependency injection pattern as UserRepository, TeamRepository, and SelectedSlotsRepository
- Update test files to use correct prismaMock import paths
- Maintain all existing functionality and type safety

Files updated:
- BookingRepository class structure and all 15 static methods
- Video meeting pages and booking views
- Booking utilities and services (handleNewBooking, originalRescheduledBookingUtils)
- Round robin handlers and reassignment logic
- Interval limits and booking limits checking
- Test files with proper mocking setup

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

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2025-07-10 18:06:57 +00:00
80ac118242 refactor: convert SelectedSlotsRepository to use dependency injection pattern (#22387)
* refactor: convert TeamRepository to use dependency injection pattern

- Add constructor injection for PrismaClient in TeamRepository
- Convert all static methods to instance methods using this.prismaClient
- Update all usage sites to use two-step pattern: const teamRepo = new TeamRepository(prisma); teamRepo.method(...)
- Optimize instance reuse within same function scopes where possible
- Update test files to work with new instance pattern
- Preserve all existing functionality and method signatures

Follows the same dependency injection pattern as UserRepository refactoring

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

* refactor: convert SelectedSlotsRepository to use dependency injection pattern

- Add constructor injection for PrismaClient/PrismaTransaction
- Convert all static methods to instance methods using this.prismaClient
- Update usage sites to use two-step instantiation pattern:
  const slotsRepo = new SelectedSlotsRepository(prisma); slotsRepo.method(...)
- Optimize instance reuse in util.ts for multiple method calls
- Update test mocks to handle new constructor-based pattern
- Add explicit type annotations to resolve TypeScript inference issues

Follows same dependency injection pattern as UserRepository and TeamRepository refactorings.

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

* chore: bump platform libs

* fix: devin mistake prisma client

* fix: devin typing mistakes

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-10 15:14:26 +00:00
efd15c5a3e refactor: convert TeamRepository to use dependency injection pattern (#22383)
* refactor: convert TeamRepository to use dependency injection pattern

- Add constructor injection for PrismaClient in TeamRepository
- Convert all static methods to instance methods using this.prismaClient
- Update all usage sites to use two-step pattern: const teamRepo = new TeamRepository(prisma); teamRepo.method(...)
- Optimize instance reuse within same function scopes where possible
- Update test files to work with new instance pattern
- Preserve all existing functionality and method signatures

Follows the same dependency injection pattern as UserRepository refactoring

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

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-10 14:03:50 +00:00
devin-ai-integration[bot]andBailey 7d02d1bb23 fix: improve layout in copy times popup (#22368)
- Remove justify-between from checkbox labels to fix uneven padding
- Use CheckboxField with descriptionAsLabel for consistent spacing
- Add flex justify-end to button container for right alignment
- Maintain existing keyboard navigation functionality

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Bailey <bailey@pumfleet.co.uk>
2025-07-10 13:21:38 +00:00
Dhairyashil ShindeandAnik Dhabal Babu 0d877e7be3 fix: Always return cancellationReason and cancelledByEmail for cancelled bookings (#22082)
* fix: always return cancellationReason and cancelledByEmail for cancelled bookings

* Implemented reviewed changes

* Updated ternary conditional logic for cancelled booking

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-07-10 18:45:46 +05:30
e4c47640fc refactor: convert UserRepository to use dependency injection pattern (#22360)
* refactor: convert UserRepository to use dependency injection pattern

- Convert all static methods to public instance methods
- Add constructor that takes PrismaClient parameter
- Update all usage sites to use new instantiation pattern: new UserRepository(prisma).method()
- Follow same pattern as PrismaOOORepository for consistency
- Maintain all existing method logic and signatures unchanged
- Update 125+ files across the codebase to adapt to new pattern

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

* optimize: reuse UserRepository instances within same function scope

- Create single UserRepository instance per function scope
- Reuse instance for multiple method calls within same function
- Reduces object instantiation overhead and improves performance
- Apply optimization pattern consistently across codebase

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

* fix: repository

* fixup! fix: repository

* fixup! fixup! fix: repository

* fixup! fixup! fixup! fix: repository

* fix: update test mocking strategies for UserRepository dependency injection

- Convert static method mocks to instance method mocks in userCreationService.test.ts
- Update vi.spyOn calls to work with constructor injection pattern in getAllCredentials.test.ts
- Fix UserRepository mocking in getRoutedUrl.test.ts to use constructor injection
- Ensure consistent mocking approach across all test files
- Fix 'UserRepository is not a constructor' errors in tests

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

* feat: optimize UserRepository instance reuse and add SessionUser type

- Reuse UserRepository instance in OrganizationRepository.createWithNonExistentOwner
- Add comprehensive SessionUser type definition for type safety
- Improve type constraints in enrichUserWithTheProfile and enrichUserWithItsProfile
- Ensure proper return types with profile information

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

* fix: make UserRepository mocking strategy more robust for CI environments

- Add defensive checks for vi.mocked() to handle CI environment differences
- Ensure mockImplementation is available before calling it
- Maintain consistent mocking pattern across all test files
- Fix 'Cannot read properties of undefined' error in CI

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

* fixup! fix: make UserRepository mocking strategy more robust for CI environments

* refactor: convert direct UserRepository instantiations to two-step pattern

- Change await new UserRepository(prisma).method(...) to const userRepo = new UserRepository(prisma); await userRepo.method(...)
- Optimize instance reuse within same function scopes
- Apply pattern consistently across all modified files in PR
- Fix type errors in organization.ts and sessionMiddleware.ts

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

* refactor: complete two-step UserRepository pattern for remaining files

- Apply two-step instantiation pattern to all remaining modified files in PR
- Ensure consistent UserRepository usage across entire codebase
- Maintain instance reuse optimization within function scopes

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

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-10 12:11:14 +00:00
Hariom Balhara 89e7fca183 fix: Add new route to create team routing-form response (#22347)
* fix: Add formId ownership with org guard

* Don't allow user routing form endpoint to create response for team
routing form

* rename to or guard
2025-07-10 11:56:45 +00:00
sean-brydon 33b5bf1db8 fix: Move pbac to prisma from kysley (#22318)
* fixes

* fix prisma mock to include transaction callbacj

* remove redudant transactions

* remove hard requirement on transactions in services

* remove mocktrx

* fix cubic feedback

* correct permission updating logic of roles. Implement diff service + domain types

* remove role mapper
2025-07-10 10:24:50 +00:00
Devanshu Sharma f69c4a900e refactor: enhance UI spacing and text styling in Locations component and Switch component (#22328) 2025-07-10 08:34:32 +00:00
Sarthak Kapila 1da25cc85f docs: booking attendee absent (#22094)
* refactor: booking attendee absent

* docs: swagger generate
2025-07-10 07:52:10 +00:00
Akash Kinkar Pandey 3a65b372c5 perf:Remove Redundant Keys in common.json for english (#22362) 2025-07-10 13:19:38 +05:30
sean-brydon 9892d521fb fix: use domain errors instead of trpc (#22341) 2025-07-10 08:23:50 +01:00
Alex van Andel 3285b252c2 chore: Add High-Load warning for user availability checks with many users (#22369) 2025-07-10 01:09:21 +00:00
emrysal 97af2fc0f9 chore: release v5.4.17
Post release / release (push) Canceled after 0s
2025-07-09 21:19:55 +00:00
Morgan 5c508bce6d chore: DI repositories in AvailableSlotsService class (#22356)
* chore: DI available slots service repositories

* remove configService from worker module

* register scheduleRepo in availableSlotsModule

* chore: bump platform libs

* remove useless comment from prisma module

* fix: availableSlotsModule to class deps

* fix: repositoriesModule deps

* chore: container pattern

* refactor: move modules in DI folder

* fixup! refactor: move modules in DI folder
2025-07-09 19:55:55 +00:00
Morgan db84343581 chore: new auth strategy for third party access tokens (#21867)
* chore: new auth strategy for third party access tokens

* chore: throw error earlier if Bearer token is invalid

* fix: check apikey and access token before third party token
2025-07-09 22:47:07 +03:00
Shaunak DasandUdit Takkar 2b44d09191 fix: 🐛 guests no show insights bug (#22045)
* fix: guests no show insights bug

* feat: fix feature failure introduced by conflict resolve

* feat: address cubic comments

* fix: add no show guests count

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-07-09 19:17:19 +00:00
Anik Dhabal Babu 10d67aee86 Revert "fix: issue #9523 limited scroll range on mobile device (#21935)" (#22363)
This reverts commit f611c3d767.
2025-07-09 19:09:46 +00:00
Anik Dhabal Babuandcubic-dev-ai[bot] b33bbb8af0 fix: manage Seo index for embed page (#22348)
* fix: manage seo index

* Update apps/web/app/(booking-page-wrapper)/[user]/[type]/embed/page.tsx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* update

* tweak

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-09 16:03:46 +00:00
Joe Au-Yeung 3fe9327724 fix: Add unique constraint to team and slug for attributes (#22357)
* Add unique constraint to team and slug

* Add migration file
2025-07-09 15:52:48 +00:00
Omar LópezandDevin AI 179a547bd0 feat: add race condition test for calendar-cache-serve booking flow (#22170)
* feat: add race condition test for calendar-cache-serve booking flow

- Reproduces double-booking when two guests book same timeslot 1-2s apart
- Enables both calendar-cache and calendar-cache-serve feature flags
- Uses parallel browser contexts to simulate concurrent booking attempts
- Verifies both bookings succeed and create double-booking scenario

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: correct page reference in race condition test timeout

- Change page.waitForTimeout(1500) to use correct page context
- Fix timeslot selection to ensure both contexts book same slot
- Remove incorrect host assertion and add logging to demonstrate race condition
- Test now successfully reproduces double-booking bug locally
- Fixes 'end of central directory record signature not found' error

Co-Authored-By: zomars@cal.com <zomars@me.com>

* Update booking-race-condition.e2e.ts

* Update booking-race-condition.e2e.ts

* Update booking-race-condition.e2e.ts

* Update booking-race-condition.e2e.ts

* Create scratchpad.md

* Update booking-race-condition.e2e.ts

* Delete scratchpad.md

* Update booking-race-condition.e2e.ts

* Update booking-race-condition.e2e.ts

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-09 10:33:13 -04:00
Morganandcubic-dev-ai[bot] 7ada02177d refactor: get available slots service (#22340)
* refactor: GetAvailableSlotsService

* chore: add todo comments where DI is needed

* chore: bump platform libs

* chore: fix unit tests

* fix: only instantiate AvailableSlotsService once per service

* Update packages/trpc/server/routers/viewer/slots/util.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix: bind this on all functions with reporting

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-09 12:15:09 +01:00
59b55e40b5 fix: allow enter key to create new list items in editor (#22001)
* fix: allow enter key to create new list items in editor

* remove: unnecessary priority set to low

* remove: unnecessary priority set to low

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-07-09 09:39:08 +00:00
19563aa697 feat: Self hosted onboarding (#22102)
* intro work

* update wixard form to have content callback to remove preset navigation

* more fixes to deployment

* fix calling static service

* fix save license key text

* ensure default steps work as expected

* fix conditional for rendering step

* skip step

* add on next step for free license

* refactor wizard form to use nuqs

* fix styles

* merge base param with step config

* fix next stepo text

* use deployment Signature token

* decrypt signature token

* fix: resolve type errors and test failures from wizard form refactor

- Fix signatureToken field name to signatureTokenEncrypted in deployment repository
- Add missing getSignatureToken method to verifyApiKey test mock
- Fix WizardForm import from default to named export in test file
- Add missing nextStep prop to Steps component in WizardForm

Resolves TypeScript type check errors and unit test failures without changing functionality.

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* fix: add missing getDeploymentSignatureToken mock in LicenseKeyService test

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* fix: add nuqs library mock for WizardForm test

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* fix: add missing nav prop to AdminAppsList component with eslint disable

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* Apply suggestions from code review

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update apps/web/modules/auth/setup-view.tsx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix license schema changes

* revret schema generation

* fix eslint errors

* remove required nav type + add use client

* fix types

* Update packages/ui/components/form/wizard/useWizardState.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix controller issue

* add checks for deployment key being null - add more tests

* fix tests

* add deployment key tests

* fix: resolve crypto mock to handle empty encryption keys gracefully

- Updated symmetricDecrypt mock to return null instead of throwing 'Invalid key' error when encryption key is empty
- All getDeploymentKey tests now pass including the previously failing 'should return null when decryption fails due to missing encryption key' test
- Fixes mocking issues in PR 22102 self-hosted onboarding wizard form refactor

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* fix label

* add i18n to error

* use enum for steps

* add as const

* fix test env issues

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-09 09:26:01 +01:00
emrysal 6410687fb6 chore: release v5.4.16
Post release / release (push) Canceled after 0s
2025-07-09 00:00:28 +00:00
Bandhan Majumder 4e4c8f5f49 fix: fixes type issue in theme and password property of newUser seed data (#22232)
Signed-off-by: bandhan-majumder <bandhanmajumder16@gmail.com>
2025-07-08 23:53:48 +00:00
abhix4 416b17cf69 fix: center align dropdown indicator when menu is open (#22262)
* fix: vertical misalignment of dropdown indicator icon in select

* fix: vertical misalignment of dropdown indicator icon in select
2025-07-08 23:37:24 +00:00
Anwar SadathandAnik Dhabal Babu 27cd7c8496 fix: Hosts are able to cancel an event without providing any reason but with whitepsace characters (#22282)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-07-09 00:17:13 +01:00
Benny Joo 02321c33b2 Revert "refactor: GetAvailableSlotsService (#22321)" (#22334) 2025-07-08 14:28:59 -07:00
Morgan c3dab3e4f8 refactor: GetAvailableSlotsService (#22321)
* refactor: GetAvailableSlotsService

* chore: add todo comments where DI is needed

* chore: bump platform libs
2025-07-08 16:54:45 -04:00
Joe Au-Yeung 0c182e2813 Allow admins to update users (#22332) 2025-07-08 20:14:44 +00:00
Hariom BalharaandUdit Takkar 51a076212b fix: Do not wait for slots request before removing the (#22323)
default(non-skeleton) loader

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-07-08 16:52:37 -03:00
Akash Kinkar Pandey 27dce7374b feat: Add Bengali Language Support. This fixes #17717 (#20567) 2025-07-08 13:15:11 +00:00
Lauris Skraucis 52f2c18800 refactor: attendee scheduled email timezone (#22301) 2025-07-08 10:16:22 +00:00
Eunjae LeeandDevin AI 35a8918ea9 feat: add expandable submenu for sidebar (#22268)
* feat: add dropdown submenu for Insights navigation

- Add dropdown functionality for desktop Insights menu to show submenu instead of immediate navigation
- Add expandable submenu for mobile Insights in More navigation
- Users can now select specific insights destination (Bookings, Routing, Router Position)
- Maintains existing navigation patterns and accessibility

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

* fix: use expansion state instead of dropdown for insights navigation

- Remove dropdown components and use existing child rendering pattern
- Add state management for expansion tracking
- Change items with children to render as buttons instead of links
- Preserve existing behavior when currently on page

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

* feat: keep parent expanded and hide chevron when sub-item is active

- Add hasActiveChild logic to detect when any child item is currently active
- Keep parent menu expanded when any sub-item is selected
- Hide chevron icon when any sub-item is active
- Maintain existing expansion behavior when no sub-item is active

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

* feat: add localStorage persistence for navigation expansion state

- Replace useState with usePersistedExpansionState hook in both NavigationItem and MobileNavigationMoreItem
- Expansion state now persists across page navigations using localStorage
- Uses nav-expansion-{itemName} keys following existing localStorage patterns

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

* chore: update yarn.lock from dev server startup

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

* feat: change navigation expansion persistence from localStorage to sessionStorage

- Expansion state now persists within browser session but resets when tab closes
- Maintains same error handling and key naming patterns

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

* Revert "chore: update yarn.lock from dev server startup"

This reverts commit 832f417da96f79979942c99400d2a7b26f889616.

* clean up

* add aria attributes

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-08 09:39:38 +00:00
b39eab72e7 feat: added option to lock maxactivebookingsperbooker for managed events (#21892)
* added option to disable maxactivebookingsperbooker for managed events

* chore

* update

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-07-08 08:25:37 +00:00
Peer Richelsenandcubic-dev-ai[bot] 928b2477be chore: added platform link to api key settings (#21807)
* added platform link to api key settings

* Update packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx

* Update packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx

* nit

* Update packages/features/ee/api-keys/components/ApiKeyDialogForm.tsx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-08 08:05:29 +00:00
Lauris Skraucis dc13251bcc fix: flakey v2 assign-all-team-members test (#22300) 2025-07-08 08:03:10 +00:00
Hariom Balhara 4843658dfb Allow scroll when user selects a date (#22313) 2025-07-08 07:00:30 +00:00
Anik Dhabal Babuandcubic-dev-ai[bot] 076afdc00e fix: Unable to cancel a team event if any event host is an attendee (#22233)
* fix

* Update handleCancelBooking.ts

* Update handleCancelBooking.ts

* update comment

* fix type error

* added test

* Update packages/features/bookings/lib/handleCancelBooking.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update handleCancelBooking.test.ts

* refactor

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-07 19:33:33 +00:00
Deepak guptaandKartik Saini 4404699fa6 fix: improve redirect parameter handling for success page (#20764)
* improve redirect parameter

* test

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-07-08 00:23:38 +05:30
Morgan 52940ce254 refactor: getAvailableSlots use repositories (#22290) 2025-07-07 10:57:01 -07:00
Balaji KrishnamurthyandKartik Saini cd6293d220 fix: sort the availabilities based on start time for each day (#21656)
* sort the availabilities based on start time for each day

* move the sorting outside the reduce

* add guard variable for safe access

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-07-07 23:11:47 +05:30
Romit 108f95723b fix: Changes to Managed Event Types Reset Assignee Availability (#21980)
* fixed

* chore
2025-07-07 17:03:50 +00:00
Fábio Galiano f611c3d767 fix: issue #9523 limited scroll range on mobile device (#21935) 2025-07-07 21:45:26 +05:30
Lauris Skraucis 6091208f8a refactor: standardize v2 docs (#22299) 2025-07-07 14:47:31 +00:00
Morgan 585a12fa48 fix: unit tests failing on destination calendar missing timestamps (#22292) 2025-07-07 11:40:13 +00:00
emrysal bfc4f7b66c chore: release v5.4.15
Post release / release (push) Canceled after 0s
2025-07-07 10:30:57 +00:00
9dca13305f feat: pbac core ui (#21471)
* migration plus feature flag

* show navigation route + inital roles migration

* add a check permission use case to take in the feature flags for a team

* bulk update script

* inital frontend work for displaying roles

* move to a more "anemic domain models" approach

* update test to match new DDD strutcture

* fix tests

* update transaction call back types to include trx

* align fe types after transaction to DDD

* move away from usecases to a more domain tailored approach

* get permissions per resource and map them to domain permission string

* update permision logic

* correctly get the logic for *.* permissions on owner

* wip sheet logic for ssr

* role list

* use nuqs for sheet parsing on handle change

* fox

* improve hook usage

* enable PBAC router

* delete modal etc

* i18n and inital rough layout of roles + permisions creating

* add color to migrations

* add colors and new method to tests

* move hooks out of infra into client with provider

* move hooks out of infra into client with provider

* memo features and ensure render once

* remove comment

* seed color

* use role colours

* match i18n

* add custom color picker to edit/create form

* fix advanced mode toggle

* more work on adv permission group

* update migrations

* abstract lots of core form logic to a custom hook

* improve UX for selecting all and toggling all

* improve code quality and use domain mappers in role repositoryu

* call server action to revalidate cache

* call invalidate cache on delete

* fix re-render + improves update logic wip

* fix txn for assinging role to member

* wip on assigning users custom roles

* fix repo

* update logic for checking if users can update roles

* remove member from permission check

* check users permission and assign roles

* move to factory approach

* move default rolesIDs to constant

* add facuted values to table

* display custom role in table

* fix type error

* fix role filter

* check pbac feature flag to see what column to filter on

* push repo mocks and other mocks to fix unit tests

* fix and add test for empty permissions when creating a role

* pass updates to repo so we actually update roles

* fix types

* fix types

* restore lock changes

* fix role service test for new updates section

* fix updated at types

* update mocks to use feature repository mock

* remove roletype from db in model

* prevent multiple queries

* fix typeof in role model

* fix and migrate i18n to one registery

* fix and update i18n to be in registery

* fix type error + fall back in service instead of repo for BL

* more type errors

* update members faceted values to bennys refactor

* fix types

* remove the _resource from type conditionally

* fix managment factory types to expose PBAC enbaled obol

* narrow down types

* wip fix for types

* more fix types

* cast role

* fix tests

* attempt of fixing _resoucre key access type

* attempt of fixing _resoucre key access type

* seperate migraations to batches

* add invalidate time to team features

* restore router to main

* push main lock

* Update packages/features/pbac/domain/types/permission-registry.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update packages/features/pbac/domain/mappers/PermissionMapper.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update packages/prisma/migrations/20250527091330_add_color_to_pbac_role/migration.sql

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update packages/prisma/migrations/20250617070118_update_memberships_one_time/migration.sql

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/AdvancedPermissionGroup.tsx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix mapped type

* restore migration

* skip _resource

* use partical

* fix type errors in tests and hooks

* Simplified the role field in the editSchema to use z.nativeEnum(MembershipRole)

* fix type errors for editsheet

* fix type errors for editsheet

* Apply suggestion from comment 2151515295

* remove footer since we dont have docs yet

* add i18n

* lock all toggle chevron

* use prisma

* tidy up old manage permission

* fix i18n

* remove can manage from role permission check

* auto select read

* address benny feedback

* fix type

* fix type

* update function name due to merge

* fix types

* update tests to match new membership method from merge

* address cubic feedback

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-07-07 10:20:24 +01:00
Joe Au-Yeung 0ab9823c9a chore: add createdAt field to SelectedCalendar and DestinationCalendar (#22071)
* Add createdAt

* Address feedback

* Make date fields nullable

* Type fixes

* Type fix

* Fix tests
2025-07-05 17:04:38 +01:00
403 changed files with 20711 additions and 8024 deletions
+4
View File
@@ -17,6 +17,10 @@ jobs:
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- uses: lingodotdev/lingo.dev@main
env:
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+1 -6
View File
@@ -488,13 +488,8 @@ Don't code but still want to contribute? Join our [Discussions](https://github.c
- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.
## Single Org Mode
If you want to have booker.yourcompany.com to be the domain used for both dashboard(e.g. https://booker.yourcompany.com/event-types) and booking pages(e.g. https://booker.yourcompany.com/john.joe/15min).
- Set the `NEXT_PUBLIC_SINGLE_ORG_SLUG` environment variable to the slug of the organization you want to use. `NEXT_PUBLIC_SINGLE_ORG_SLUG=booker`
- Set the `NEXT_PUBLIC_WEBAPP_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBAPP_URL=https://booker.yourcompany.com`.
- Set the `NEXT_PUBLIC_WEBSITE_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBSITE_URL=https://booker.yourcompany.com`.
- Set the `NEXTAUTH_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXTAUTH_URL=https://booker.yourcompany.com`.
Refer to docs [here](./docs/self-hosting/guides/organization/single-organization-setup) for a detailed documentation with screenshots.
Note: It causes root to serve the dashboard and not the organization profile page which shows all bookable users in the organization.
## Integrations
@@ -23,6 +23,7 @@ afterEach(() => {
const mockDeploymentRepository: IDeploymentRepository = {
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value
getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"),
};
// TODO: Fix the skip condition for this test suite
+1
View File
@@ -43,6 +43,7 @@ enum locales {
SR = "sr",
SV = "sv",
VI = "vi",
BN = "bn",
}
enum theme {
DARK = "dark",
@@ -105,7 +105,9 @@ async function getHandler(req: NextApiRequest) {
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
const usersWithCalendars = await UserRepository.findManyByIdsIncludeDestinationAndSelectedCalendars({
const usersWithCalendars = await new UserRepository(
prisma
).findManyByIdsIncludeDestinationAndSelectedCalendars({
ids: userIds,
});
+8 -2
View File
@@ -4,11 +4,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { createContext } from "@calcom/trpc/server/createContext";
import { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
import { TRPCError } from "@trpc/server";
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
@@ -27,7 +27,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}
const input = getScheduleSchema.parse({ usernameList: slugs, isTeamEvent: parsedIsTeamEvent, ...rest });
const timeZoneSupported = input.timeZone ? isSupportedTimeZone(input.timeZone) : false;
const availableSlots = await getAvailableSlots({ ctx: await createContext({ req, res }), input });
const availableSlotsService = getAvailableSlotsService();
const availableSlots = await availableSlotsService.getAvailableSlots({
ctx: await createContext({ req, res }),
input,
});
const slotsInProvidedTimeZone = timeZoneSupported
? Object.keys(availableSlots.slots).reduce(
(acc: Record<string, { time: string; attendees?: number; bookingUid?: string }[]>, date) => {
@@ -93,6 +93,8 @@ describe("POST /api/selected-calendars", () => {
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
});
await handler(req, res);
@@ -136,6 +138,8 @@ describe("POST /api/selected-calendars", () => {
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
});
await handler(req, res);
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.239",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.257",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -443,7 +443,7 @@ export class BookingsController_2024_08_13 {
@Permissions([BOOKING_READ])
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get 'Booking References' for a booking",
summary: "Get booking references",
})
@HttpCode(HttpStatus.OK)
async getBookingReferences(
@@ -1425,8 +1425,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
// @ts-ignore
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.status).toEqual("cancelled");
createdBooking = data;
expect(data.rescheduledToUid).toEqual(rescheduledBooking.uid);
});
});
@@ -596,9 +596,9 @@ export class BookingsService_2024_08_13 {
} else if (isRecurring && isSeated) {
formattedBookings.push(this.outputService.getOutputRecurringSeatedBooking(formatted));
} else if (isSeated) {
formattedBookings.push(this.outputService.getOutputSeatedBooking(formatted));
formattedBookings.push(await this.outputService.getOutputSeatedBooking(formatted));
} else {
formattedBookings.push(this.outputService.getOutputBooking(formatted));
formattedBookings.push(await this.outputService.getOutputBooking(formatted));
}
}
@@ -190,9 +190,20 @@ export class InputBookingsService_2024_08_13 {
};
}
private getRoutingFormData(routing: { teamMemberIds?: number[]; responseId?: number; teamMemberEmail?: string; skipContactOwner?: boolean; crmAppSlug?: string; crmOwnerRecordType?: string } | undefined) {
private getRoutingFormData(
routing:
| {
teamMemberIds?: number[];
responseId?: number;
teamMemberEmail?: string;
skipContactOwner?: boolean;
crmAppSlug?: string;
crmOwnerRecordType?: string;
}
| undefined
) {
if (!routing) return null;
return {
routedTeamMemberIds: routing.teamMemberIds,
routingFormResponseId: routing.responseId,
@@ -90,7 +90,7 @@ type DatabaseMetadata = z.infer<typeof bookingMetadataSchema>;
export class OutputBookingsService_2024_08_13 {
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
getOutputBooking(databaseBooking: DatabaseBooking) {
async getOutputBooking(databaseBooking: DatabaseBooking) {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
@@ -101,6 +101,9 @@ export class OutputBookingsService_2024_08_13 {
);
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const rescheduledToUid = databaseBooking.rescheduled
? await this.getRescheduledToUid(databaseBooking.uid)
: undefined;
const booking = {
id: databaseBooking.id,
@@ -109,8 +112,9 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
cancelledByEmail: databaseBooking.cancelledBy || undefined,
cancellationReason:
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
@@ -137,6 +141,7 @@ export class OutputBookingsService_2024_08_13 {
updatedAt: databaseBooking.updatedAt,
rating: databaseBooking.rating,
icsUid: databaseBooking.iCalUID,
rescheduledToUid,
};
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
@@ -146,6 +151,11 @@ export class OutputBookingsService_2024_08_13 {
return bookingTransformed;
}
async getRescheduledToUid(bookingUid: string) {
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
return rescheduledTo?.uid;
}
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
if (databaseMetadata === null) return {};
@@ -204,8 +214,9 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
cancelledByEmail: databaseBooking.cancelledBy || undefined,
cancellationReason:
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
@@ -244,20 +255,23 @@ export class OutputBookingsService_2024_08_13 {
return bookingTransformed;
}
getOutputCreateSeatedBooking(
async getOutputCreateSeatedBooking(
databaseBooking: DatabaseBooking,
seatUid: string
): CreateSeatedBookingOutput_2024_08_13 {
const getSeatedBookingOutput = this.getOutputSeatedBooking(databaseBooking);
): Promise<CreateSeatedBookingOutput_2024_08_13> {
const getSeatedBookingOutput = await this.getOutputSeatedBooking(databaseBooking);
return { ...getSeatedBookingOutput, seatUid };
}
getOutputSeatedBooking(databaseBooking: DatabaseBooking) {
async getOutputSeatedBooking(databaseBooking: DatabaseBooking) {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const rescheduledToUid = databaseBooking.rescheduled
? await this.getRescheduledToUid(databaseBooking.uid)
: undefined;
const booking = {
id: databaseBooking.id,
@@ -266,7 +280,8 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
cancellationReason:
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -283,6 +298,7 @@ export class OutputBookingsService_2024_08_13 {
updatedAt: databaseBooking.updatedAt,
rating: databaseBooking.rating,
icsUid: databaseBooking.iCalUID,
rescheduledToUid,
};
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
@@ -375,7 +391,8 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
cancellationReason:
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -124,7 +124,9 @@ export class EventTypesController_2024_04_15 {
let orgSlug = queryParams.org;
if (clientId && !orgSlug && username.includes(`-${clientId}`)) {
const org = await this.organizationsRepository.findTeamIdAndSlugFromClientId(clientId).catch(() => null);
const org = await this.organizationsRepository
.findTeamIdAndSlugFromClientId(clientId)
.catch(() => null);
if (org) {
orgSlug = org.slug;
}
@@ -3,4 +3,5 @@ export enum AuthMethods {
"ACCESS_TOKEN" = "access-token",
"OAUTH_CLIENT" = "oauth-client",
"NEXT_AUTH" = "next-auth",
"THIRD_PARTY_ACCESS_TOKEN" = "third_party_access_token",
}
+1
View File
@@ -41,4 +41,5 @@ export enum Locales {
TA = "ta",
UK = "uk",
ZH_TW = "zh-TW",
BN = "bn",
}
@@ -0,0 +1,28 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
import { PrismaSelectedSlotsRepository } from "@/lib/repositories/prisma-selected-slots.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [
PrismaOOORepository,
PrismaScheduleRepository,
PrismaBookingRepository,
PrismaSelectedSlotsRepository,
PrismaUserRepository,
PrismaEventTypeRepository,
PrismaRoutingFormResponseRepository,
PrismaTeamRepository,
AvailableSlotsService,
],
exports: [AvailableSlotsService],
})
export class AvailableSlotsModule {}
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaBookingRepository as PrismaBookingRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaBookingRepository extends PrismaBookingRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaEventTypeRepository as PrismaEventTypeRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaEventTypeRepository extends PrismaEventTypeRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaOOORepository as PrismaOOORepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaOOORepository extends PrismaOOORepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaRoutingFormResponseRepository as PrismaRoutingFormResponseRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaRoutingFormResponseRepository extends PrismaRoutingFormResponseRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaScheduleRepository as PrismaScheduleRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaScheduleRepository extends PrismaScheduleRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaSelectedSlotsRepository as PrismaSelectedSlotsRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaSelectedSlotsRepository extends PrismaSelectedSlotsRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaTeamRepository as PrismaTeamRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaTeamRepository extends PrismaTeamRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaUserRepository as PrismaUserRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaUserRepository extends PrismaUserRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -0,0 +1,36 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
import { PrismaSelectedSlotsRepository } from "@/lib/repositories/prisma-selected-slots.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { Injectable } from "@nestjs/common";
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
@Injectable()
export class AvailableSlotsService extends BaseAvailableSlotsService {
constructor(
private readonly oooRepoDependency: PrismaOOORepository,
private readonly scheduleRepoDependency: PrismaScheduleRepository,
private readonly teamRepository: PrismaTeamRepository,
private readonly routingFormResponseRepository: PrismaRoutingFormResponseRepository,
private readonly bookingRepository: PrismaBookingRepository,
private readonly selectedSlotsRepository: PrismaSelectedSlotsRepository,
private readonly eventTypeRepository: PrismaEventTypeRepository,
private readonly userRepository: PrismaUserRepository
) {
super({
oooRepo: oooRepoDependency,
scheduleRepo: scheduleRepoDependency,
teamRepo: teamRepository,
routingFormResponseRepo: routingFormResponseRepository,
bookingRepo: bookingRepository,
selectedSlotsRepo: selectedSlotsRepository,
eventTypeRepo: eventTypeRepository,
userRepo: userRepository,
});
}
}
@@ -1,5 +1,10 @@
import { Reflector } from "@nestjs/core";
export type AllowedAuthMethod = "OAUTH_CLIENT_CREDENTIALS" | "API_KEY" | "ACCESS_TOKEN" | "NEXT_AUTH";
export type AllowedAuthMethod =
| "OAUTH_CLIENT_CREDENTIALS"
| "API_KEY"
| "ACCESS_TOKEN"
| "NEXT_AUTH"
| "THIRD_PARTY_ACCESS_TOKEN";
export const ApiAuthGuardOnlyAllow = Reflector.createDecorator<AllowedAuthMethod[]>();
@@ -10,16 +10,30 @@ import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
import {
HttpException,
Injectable,
InternalServerErrorException,
UnauthorizedException,
} from "@nestjs/common";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import type { Request } from "express";
import * as jwt from "jsonwebtoken";
import { getToken } from "next-auth/jwt";
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import type { AllowedAuthMethod } from "../../decorators/api-auth-guard-only-allow.decorator";
interface OAuthTokenPayload {
userId?: number;
teamId?: number;
scope: string[];
token_type: string;
}
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
export type ApiAuthGuardRequest = Request & {
authMethod: AuthMethods;
@@ -29,8 +43,11 @@ export type ApiAuthGuardRequest = Request & {
};
export const NO_AUTH_PROVIDED_MESSAGE =
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
@Injectable()
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
private readonly logger = new Logger("ApiAuthStrategy");
constructor(
private readonly deploymentsService: DeploymentsService,
private readonly config: ConfigService,
@@ -54,22 +71,53 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
const allowedMethods = request.allowedAuthMethods;
const noSpecificAuthExpected = !allowedMethods || !allowedMethods.length;
const oAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("OAUTH_CLIENT_CREDENTIALS");
const apiKeyAllowed = noSpecificAuthExpected || allowedMethods.includes("API_KEY");
const accessTokenAllowed = noSpecificAuthExpected || allowedMethods.includes("ACCESS_TOKEN");
const nextAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("NEXT_AUTH");
const thirdPartyAccessTokenAllowed =
noSpecificAuthExpected || allowedMethods.includes("THIRD_PARTY_ACCESS_TOKEN");
if (oAuthClientId && oAuthClientSecret && oAuthAllowed) {
request.authMethod = AuthMethods["OAUTH_CLIENT"];
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret, request);
}
if (bearerToken && (apiKeyAllowed || accessTokenAllowed)) {
const requestOrigin = request.get("Origin");
request.authMethod = isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
? AuthMethods["API_KEY"]
: AuthMethods["ACCESS_TOKEN"];
return await this.authenticateBearerToken(bearerToken, request, requestOrigin);
if (bearerToken) {
if (!apiKeyAllowed && !accessTokenAllowed && thirdPartyAccessTokenAllowed) {
request.authMethod = AuthMethods["THIRD_PARTY_ACCESS_TOKEN"];
const result = await this.validateThirdPartyAccessToken(bearerToken, request);
if (result.success) {
return this.success(this.getSuccessUser(result.data));
}
}
if (apiKeyAllowed || accessTokenAllowed) {
try {
const requestOrigin = request.get("Origin");
request.authMethod = isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
? AuthMethods["API_KEY"]
: AuthMethods["ACCESS_TOKEN"];
return await this.authenticateBearerToken(bearerToken, request, requestOrigin);
} catch (err) {
// failed to validate access token, try to validate third party token
if (thirdPartyAccessTokenAllowed && request.authMethod === AuthMethods["ACCESS_TOKEN"]) {
request.authMethod = AuthMethods["THIRD_PARTY_ACCESS_TOKEN"];
const result = await this.validateThirdPartyAccessToken(bearerToken, request);
if (result.success) {
return this.success(this.getSuccessUser(result.data));
}
}
// token was not third party token, rethrow error from authenticateBearerToken
if (err instanceof Error) {
return this.error(err);
}
}
}
throw new UnauthorizedException(`ApiAuthStrategy - Invalid Bearer token`);
}
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
@@ -83,8 +131,11 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
if (noAuthProvided) {
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
}
throw new UnauthorizedException(
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${allowedMethods}`
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${
allowedMethods && allowedMethods.length > 0 ? allowedMethods.join(", ") : "Any supported method"
}`
);
} catch (err) {
if (err instanceof Error) {
@@ -164,21 +215,17 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
: await this.accessTokenStrategy(authString, request, requestOrigin);
if (!user) {
return this.error(
new UnauthorizedException(
"ApiAuthStrategy - bearer token - No user associated with the provided token"
)
throw new UnauthorizedException(
"ApiAuthStrategy - bearer token - No user associated with the provided token"
);
}
return this.success(this.getSuccessUser(user));
} catch (err) {
if (err instanceof Error) {
return this.error(err);
throw err;
}
return this.error(
new InternalServerErrorException("An error occurred while authenticating the request")
);
throw new InternalServerErrorException("An error occurred while authenticating the request");
}
}
@@ -271,4 +318,55 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
return user;
}
async validateThirdPartyAccessToken(
token: string,
request: ApiAuthGuardRequest
): Promise<{ success: true; data: UserWithProfile } | { success: false }> {
// Removed requiredScopes parameter
const encryptionKey = this.config.get<string>("CALENDSO_ENCRYPTION_KEY");
if (!encryptionKey) {
throw new InternalServerErrorException("CALENDSO_ENCRYPTION_KEY environment variable is not set.");
}
let decodedToken: OAuthTokenPayload;
try {
decodedToken = jwt.verify(token, encryptionKey) as OAuthTokenPayload;
} catch (e) {
return { success: false };
}
if (!decodedToken || decodedToken.token_type !== "Access Token") {
return { success: false };
}
let user: UserWithProfile | null = null;
let organizationId: number | null = null;
if (decodedToken.userId) {
user = await this.userRepository.findByIdWithProfile(decodedToken.userId);
if (user) {
organizationId = this.usersService.getUserMainOrgId(user) as number;
}
} else if (decodedToken.teamId) {
const teamOwner = await this.userRepository.findOwnerByTeamIdWithProfile(decodedToken.teamId);
if (!teamOwner) {
throw new UnauthorizedException(
"ApiAuthStrategy - third-party token - No owner found for the associated team."
);
}
user = teamOwner;
organizationId =
teamOwner.profiles?.find((p) => p.organizationId === decodedToken.teamId)?.organizationId ?? null;
}
if (!user) {
throw new UnauthorizedException(
"ApiAuthStrategy - third-party token - No user or team owner associated with the token."
);
}
request.organizationId = organizationId;
return { success: true, data: user };
}
}
@@ -34,7 +34,7 @@ export class CalUnifiedCalendarsController {
@UseGuards(ApiAuthGuard, PermissionsGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get meeting Details from calendar",
summary: "Get meeting details from calendar",
description: "Returns detailed information about a meeting including attendance metrics",
})
async getCalendarEventDetails(
@@ -92,6 +92,8 @@ describe("Platform Destination Calendar Endpoints", () => {
publisher: "",
url: "",
email: "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
// calendars: {
// externalId:
@@ -118,6 +120,8 @@ describe("Platform Destination Calendar Endpoints", () => {
id: 0,
delegationCredentialId: null,
domainWideDelegationCredentialId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
})
);
@@ -304,8 +304,10 @@ describe("Assign all team members", () => {
expect(data.title).toEqual(body.title);
expect(data.hosts.length).toEqual(2);
expect(data.schedulingType).toEqual("collective");
evaluateHost({ userId: firstManagedUser.user.id }, data.hosts[0]);
evaluateHost({ userId: secondManagedUser.user.id }, data.hosts[1]);
const dataFirstHost = data.hosts.find((host) => host.userId === firstManagedUser.user.id);
const dataSecondHost = data.hosts.find((host) => host.userId === secondManagedUser.user.id);
evaluateHost({ userId: firstManagedUser.user.id }, dataFirstHost);
evaluateHost({ userId: secondManagedUser.user.id }, dataSecondHost);
const eventTypeHosts = await hostsRepositoryFixture.getEventTypeHosts(data.id);
expect(eventTypeHosts.length).toEqual(2);
@@ -341,13 +343,15 @@ describe("Assign all team members", () => {
expect(data.title).toEqual(body.title);
expect(data.hosts.length).toEqual(2);
expect(data.schedulingType).toEqual("roundRobin");
const dataFirstHost = data.hosts.find((host) => host.userId === firstManagedUser.user.id);
const dataSecondHost = data.hosts.find((host) => host.userId === secondManagedUser.user.id);
evaluateHost(
{ userId: firstManagedUser.user.id, mandatory: false, priority: "medium" },
data.hosts[0]
dataFirstHost
);
evaluateHost(
{ userId: secondManagedUser.user.id, mandatory: false, priority: "medium" },
data.hosts[1]
dataSecondHost
);
const eventTypeHosts = await hostsRepositoryFixture.getEventTypeHosts(data.id);
@@ -381,13 +385,15 @@ describe("Assign all team members", () => {
expect(data.title).toEqual(body.title);
expect(data.hosts.length).toEqual(2);
expect(data.schedulingType).toEqual("roundRobin");
const dataFirstHost = data.hosts.find((host) => host.userId === firstManagedUser.user.id);
const dataSecondHost = data.hosts.find((host) => host.userId === secondManagedUser.user.id);
evaluateHost(
{ userId: firstManagedUser.user.id, mandatory: false, priority: "medium" },
data.hosts[0]
dataFirstHost
);
evaluateHost(
{ userId: secondManagedUser.user.id, mandatory: false, priority: "medium" },
data.hosts[1]
dataSecondHost
);
const eventTypeHosts = await hostsRepositoryFixture.getEventTypeHosts(data.id);
@@ -43,7 +43,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
teamId: number | null;
userId: number | null;
title: string;
}
};
let membershipsRepositoryFixture: MembershipRepositoryFixture;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
@@ -130,24 +130,24 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"]
}
}
}
valueType: ["text"],
},
},
},
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: routingEventType.id,
value: `team/${team.slug}/${routingEventType.slug}`
value: `team/${team.slug}/${routingEventType.slug}`,
},
isFallback: false
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" }
}
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
@@ -155,18 +155,18 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
type: "text",
label: "Question 1",
required: true,
identifier: "question1"
identifier: "question1",
},
{
id: "question2",
id: "question2",
type: "text",
label: "Question 2",
required: false,
identifier: "question2"
}
identifier: "question2",
},
],
settings: {
emailOwnerOnSubmission: false
emailOwnerOnSubmission: false,
},
teamId: team.id,
userId: user.id,
@@ -245,7 +245,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
describe(`POST /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
it("should return 403 when organization does not exist", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.post(
`/v2/organizations/99999/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
@@ -255,7 +257,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 404 when routing form does not exist", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses?start=2050-09-05&end=2050-09-06`)
.post(
`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
@@ -265,7 +269,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 401 when authentication token is missing", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.send({
question1: "answer1",
})
@@ -274,7 +280,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should create response and return available slots when routing to event type", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1", // This matches the route condition
@@ -296,7 +304,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 400 when required form fields are missing", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question2: "answer2", // Missing required question1
@@ -306,7 +316,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should create response and return custom message if the routing is to custom page", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "different-answer", // This won't match any route
@@ -334,7 +346,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
})
.expect(400);
// Missing end parameter
// Missing end parameter
await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
@@ -347,7 +359,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 400 when date parameters have invalid format", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=invalid-date&end=2050-09-06`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=invalid-date&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
@@ -357,7 +371,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should return 400 when end date is before start date", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-10&end=2050-09-05`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-10&end=2050-09-05`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
@@ -371,10 +387,15 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
email: `unauthorized-user-${randomString()}@api.com`,
});
const { keyString: unauthorizedApiKey } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
const { keyString: unauthorizedApiKey } = await apiKeysRepositoryFixture.createApiKey(
unauthorizedUser.id,
null
);
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${unauthorizedApiKey}` })
.send({
question1: "answer1",
@@ -390,7 +411,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
it("should handle queued response creation", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
@@ -426,24 +449,24 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
operator: "equal",
value: ["answer1"],
valueSrc: ["value"],
valueType: ["text"]
}
}
}
valueType: ["text"],
},
},
},
},
action: {
type: "eventTypeRedirectUrl",
eventTypeId: 99999, // Invalid event type ID
value: `team/${team.slug}/non-existent-event-type`
value: `team/${team.slug}/non-existent-event-type`,
},
isFallback: false
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" }
}
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
@@ -451,11 +474,11 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
type: "text",
label: "Question 1",
required: true,
identifier: "question1"
}
identifier: "question1",
},
],
settings: {
emailOwnerOnSubmission: false
emailOwnerOnSubmission: false,
},
teamId: team.id,
userId: user.id,
@@ -464,7 +487,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
// Try to create a response for the form with invalid event type
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1",
@@ -474,7 +499,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
// Clean up the form
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: routingFormWithInvalidEventType.id }
where: { id: routingFormWithInvalidEventType.id },
});
});
@@ -482,7 +507,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
// This test verifies that routing forms can handle team member assignments
// and that the routing returns the correct team member information
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.post(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "answer1", // This matches the route condition
@@ -524,23 +551,23 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
operator: "equal",
value: ["external"],
valueSrc: ["value"],
valueType: ["text"]
}
}
}
valueType: ["text"],
},
},
},
},
action: {
type: "externalRedirectUrl",
value: "https://example.com/external-booking"
value: "https://example.com/external-booking",
},
isFallback: false
isFallback: false,
},
{
id: "fallback-route",
action: { type: "customPageMessage", value: "Fallback Message" },
isFallback: true,
queryValue: { id: "fallback-route", type: "group" }
}
queryValue: { id: "fallback-route", type: "group" },
},
],
fields: [
{
@@ -548,11 +575,11 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
type: "text",
label: "Question 1",
required: true,
identifier: "question1"
}
identifier: "question1",
},
],
settings: {
emailOwnerOnSubmission: false
emailOwnerOnSubmission: false,
},
teamId: team.id,
userId: user.id,
@@ -560,7 +587,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
});
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/routing-forms/${externalRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`)
.post(
`/v2/organizations/${org.id}/routing-forms/${externalRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({
question1: "external", // This matches the route condition for external redirect
@@ -574,7 +603,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
expect(data.routingExternalRedirectUrl).toBeDefined();
expect(data.routingExternalRedirectUrl).toContain("https://example.com/external-booking");
expect(data.routingExternalRedirectUrl).toContain("cal.action=externalRedirectUrl");
// Verify that it doesn't contain event type routing data
expect(data.eventTypeId).toBeUndefined();
expect(data.slots).toBeUndefined();
@@ -583,10 +612,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
// Clean up the external routing form
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
where: { id: externalRoutingForm.id }
where: { id: externalRoutingForm.id },
});
});
});
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
@@ -6,9 +6,9 @@ import { GetAvailableSlotsInput_2024_09_04 } from "@calcom/platform-types";
export class CreateRoutingFormResponseInput extends GetAvailableSlotsInput_2024_09_04 {
@Transform(({ value }: { value: string | boolean }) => {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
return value.toLowerCase() === 'true';
if (typeof value === "boolean") return value;
if (typeof value === "string") {
return value.toLowerCase() === "true";
}
return undefined;
})
@@ -20,4 +20,4 @@ export class CreateRoutingFormResponseInput extends GetAvailableSlotsInput_2024_
example: true,
})
queueResponse?: boolean;
}
}
@@ -2,7 +2,12 @@ import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger
import { Type } from "class-transformer";
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
import { ApiResponseWithoutData, SlotsOutput_2024_09_04, RangeSlotsOutput_2024_09_04 } from "@calcom/platform-types";
import {
ApiResponseWithoutData,
SlotsOutput_2024_09_04,
RangeSlotsOutput_2024_09_04,
} from "@calcom/platform-types";
class Routing {
@ApiProperty({
type: String,
@@ -133,4 +138,4 @@ export class CreateRoutingFormResponseOutput extends ApiResponseWithoutData {
@ApiProperty({ type: CreateRoutingFormResponseOutputData })
@Type(() => CreateRoutingFormResponseOutputData)
data!: CreateRoutingFormResponseOutputData;
}
}
@@ -2,7 +2,12 @@ import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/rou
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { BadRequestException, Injectable, NotFoundException, InternalServerErrorException } from "@nestjs/common";
import {
BadRequestException,
Injectable,
NotFoundException,
InternalServerErrorException,
} from "@nestjs/common";
import { Request } from "express";
import { getRoutedUrl } from "@calcom/platform-libraries";
@@ -71,10 +76,14 @@ export class OrganizationsRoutingFormsResponsesService {
request: Request
): Promise<CreateRoutingFormResponseOutputData> {
const { queueResponse, ...slotsQuery } = query;
this.validateDateRange(slotsQuery.start, slotsQuery.end);
const { redirectUrl, customMessage } = await this.getRoutingUrl(request, routingFormId, queueResponse ?? false);
const { redirectUrl, customMessage } = await this.getRoutingUrl(
request,
routingFormId,
queueResponse ?? false
);
// If there is no redirect URL, then we have to show the message as that would be custom page message to be shown as per the route chosen
if (!redirectUrl) {
@@ -86,7 +95,7 @@ export class OrganizationsRoutingFormsResponsesService {
if (!this.isEventTypeRedirectUrl(redirectUrl)) {
return {
routingExternalRedirectUrl: redirectUrl.toString(),
}
};
}
// Extract event type information from the routed URL
@@ -123,9 +132,11 @@ export class OrganizationsRoutingFormsResponsesService {
slots,
};
}
if (!queuedResponseId) {
throw new InternalServerErrorException("No routing form response ID or queued form response ID could be found.");
throw new InternalServerErrorException(
"No routing form response ID or queued form response ID could be found."
);
}
return {
@@ -145,7 +156,7 @@ export class OrganizationsRoutingFormsResponsesService {
private validateDateRange(start: string, end: string) {
const startDate = new Date(start);
const endDate = new Date(end);
if (endDate < startDate) {
throw new BadRequestException("End date cannot be before start date.");
}
@@ -153,10 +164,13 @@ export class OrganizationsRoutingFormsResponsesService {
private async getRoutingUrl(request: Request, formId: string, queueResponse: boolean) {
const params = Object.fromEntries(new URLSearchParams(request.body));
const routedUrlData = await getRoutedUrl({
req: request,
query: { ...params, form: formId, ...(queueResponse && { "cal.queueFormResponse": "true" }) },
}, true);
const routedUrlData = await getRoutedUrl(
{
req: request,
query: { ...params, form: formId, ...(queueResponse && { "cal.queueFormResponse": "true" }) },
},
true
);
if (routedUrlData.notFound) {
throw new NotFoundException("Routing form not found.");
@@ -186,8 +200,6 @@ export class OrganizationsRoutingFormsResponsesService {
}
private async extractEventTypeAndCrmParams(routingUrl: URL) {
// Extract team and event type information
const { teamId, eventTypeSlug } = this.extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl);
const eventType = await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlug(teamId, eventTypeSlug);
@@ -53,7 +53,7 @@ export type OAuthCallbackState = {
path: "/v2/organizations/:orgId/teams/:teamId/stripe",
version: API_VERSIONS_VALUES,
})
@DocsTags("Organizations/Teams Stripe")
@DocsTags("Orgs / Teams / Stripe")
export class OrganizationsStripeController {
constructor(
private readonly organizationsStripeService: OrganizationsStripeService,
@@ -80,7 +80,7 @@ export class OrganizationsTeamsBookingsController {
IsAdminAPIEnabledGuard
)
@ApiOperation({
summary: "Get 'Booking References' for a booking",
summary: "Get booking references",
})
@HttpCode(HttpStatus.OK)
async getBookingReferences(
@@ -20,7 +20,14 @@ import { OrganizationsTeamsRoutingFormsResponsesService } from "./services/organ
import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-teams-routing-forms.service";
@Module({
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04, TeamsEventTypesModule],
imports: [
PrismaModule,
StripeModule,
RedisModule,
RoutingFormsModule,
SlotsModule_2024_09_04,
TeamsEventTypesModule,
],
providers: [
OrganizationsTeamsRoutingFormsService,
OrganizationsTeamsRoutingFormsResponsesService,
@@ -52,8 +52,8 @@ import { SkipTakePagination } from "@calcom/platform-types";
export class OrgTeamsVerifiedResourcesController {
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
@ApiOperation({
summary: "Request Email Verification Code",
description: `Sends a verification code to the email.`,
summary: "Request email verification code",
description: `Sends a verification code to the email`,
})
@Roles("TEAM_ADMIN")
@Throttle({
@@ -82,8 +82,8 @@ export class OrgTeamsVerifiedResourcesController {
}
@ApiOperation({
summary: "Request Phone Number Verification Code",
description: `Sends a verification code to the phone number.`,
summary: "Request phone number verification code",
description: `Sends a verification code to the phone number`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@@ -108,7 +108,7 @@ export class OrgTeamsVerifiedResourcesController {
}
@ApiOperation({
summary: "Verify an email for an org team.",
summary: "Verify an email for an org team",
description: `Use code to verify an email`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@@ -134,7 +134,7 @@ export class OrgTeamsVerifiedResourcesController {
}
@ApiOperation({
summary: "Verify a phone number for an org team.",
summary: "Verify a phone number for an org team",
description: `Use code to verify a phone number`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@@ -166,7 +166,7 @@ export class OrgTeamsVerifiedResourcesController {
}
@ApiOperation({
summary: "Get list of verified emails of an org team.",
summary: "Get list of verified emails of an org team",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@@ -189,7 +189,7 @@ export class OrgTeamsVerifiedResourcesController {
}
@ApiOperation({
summary: "Get list of verified phone numbers of an org team.",
summary: "Get list of verified phone numbers of an org team",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@PlatformPlan("ESSENTIALS")
@@ -214,7 +214,7 @@ export class OrgTeamsVerifiedResourcesController {
}
@ApiOperation({
summary: "Get verified email of an org team by id.",
summary: "Get verified email of an org team by id",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@@ -233,7 +233,7 @@ export class OrgTeamsVerifiedResourcesController {
}
@ApiOperation({
summary: "Get verified phone number of an org team by id.",
summary: "Get verified phone number of an org team by id",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@@ -25,7 +25,6 @@ export class RouterController {
@Param("formId") formId: string,
@Body() body?: Record<string, string>
): Promise<void | (ApiResponse<unknown> & { redirect: boolean })> {
const params = Object.fromEntries(new URLSearchParams(body ?? {}));
const routedUrlData = await getRoutedUrl({ req: request, query: { ...params, form: formId } });
if (routedUrlData?.notFound) {
@@ -1,4 +1,5 @@
import { TRPC_ERROR_CODE, TRPC_ERROR_MAP, TRPCErrorCode } from "@/filters/trpc-exception.filter";
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import { SlotsWorkerService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-worker.service";
@@ -17,7 +18,6 @@ import {
VERSION_2024_08_13,
} from "@calcom/platform-constants";
import { TRPCError } from "@calcom/platform-libraries";
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
import { RemoveSelectedSlotInput_2024_04_15, ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
import { ApiResponse, GetAvailableSlotsInput_2024_04_15 } from "@calcom/platform-types";
@@ -31,7 +31,8 @@ export class SlotsController_2024_04_15 {
private readonly slotsService: SlotsService_2024_04_15,
private readonly config: ConfigService,
private readonly slotsOutputService: SlotsOutputService_2024_04_15,
private readonly slotsWorkerService: SlotsWorkerService_2024_04_15
private readonly slotsWorkerService: SlotsWorkerService_2024_04_15,
private readonly availableSlotsService: AvailableSlotsService
) {}
@Post("/reserve")
@@ -170,8 +171,10 @@ export class SlotsController_2024_04_15 {
: query.isTeamEvent;
// Do not use workers in E2E, not supported by TS-JEST
const availableSlots = this.config.get<boolean>("e2e")
? await getAvailableSlots({
const availableSlotsService = this.config.get<boolean>("e2e") ? this.availableSlotsService : null;
const availableSlots = availableSlotsService
? await availableSlotsService.getAvailableSlots({
input: {
...query,
isTeamEvent,
@@ -1,4 +1,5 @@
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { AvailableSlotsModule } from "@/lib/modules/available-slots.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SlotsController_2024_04_15 } from "@/modules/slots/slots-2024-04-15/controllers/slots.controller";
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
@@ -8,7 +9,7 @@ import { SlotsRepository_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slo
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, EventTypesModule_2024_04_15],
imports: [PrismaModule, EventTypesModule_2024_04_15, AvailableSlotsModule],
providers: [
SlotsRepository_2024_04_15,
SlotsService_2024_04_15,
@@ -0,0 +1,23 @@
// apps/api/v2/src/modules/slots/slots-2024-04-15/workers/slots-worker.module.ts
// Needed if ConfigService is used anywhere
import appConfig from "@/config/app";
import { AvailableSlotsModule } from "@/lib/modules/available-slots.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module, Logger } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { SentryModule } from "@sentry/nestjs/setup";
@Module({
imports: [
SentryModule.forRoot(),
ConfigModule.forRoot({
ignoreEnvFile: true,
isGlobal: true,
load: [appConfig],
}),
PrismaModule,
AvailableSlotsModule,
],
providers: [Logger],
})
export class SlotsWorkerModule {}
@@ -1,16 +1,65 @@
import { parentPort } from "worker_threads";
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { Logger } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { IncomingMessage } from "http";
import { parentPort, isMainThread } from "worker_threads";
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
import { SlotsWorkerModule } from "./slots.worker.module";
parentPort?.on("message", async (data) => {
try {
const { input, ctx } = data;
const result = await getAvailableSlots({ input, ctx });
parentPort?.postMessage({ success: true, data: result });
} catch (error) {
parentPort?.postMessage({
success: false,
error: error instanceof Error ? error : new Error("Unknown error"),
});
interface ContextForGetSchedule extends Record<string, unknown> {
req?: (IncomingMessage & { cookies: Partial<{ [key: string]: string }> }) | undefined;
}
interface WorkerMessage {
input: any; // Use a more specific type if available, e.g., TGetScheduleInputSchema
ctx?: ContextForGetSchedule;
}
interface WorkerResult {
success: boolean;
data?: any; // Use TimeSlots
error?: {
message: string;
code?: string;
stack?: string;
};
}
async function bootstrapWorkerApp() {
if (isMainThread) {
console.error("slots-worker-main.ts should only be run in a worker thread.");
process.exit(1);
}
});
const app = await NestFactory.createApplicationContext(SlotsWorkerModule);
const logger = app.get(Logger); // Get the NestJS Logger instance
logger.log("NestJS worker application bootstrapped successfully.");
const availableSlotsService = app.get(AvailableSlotsService);
parentPort?.on("message", async (data: WorkerMessage) => {
try {
const { input, ctx } = data;
const result = await availableSlotsService.getAvailableSlots({ input, ctx });
parentPort?.postMessage({ success: true, data: result } as WorkerResult);
} catch (error: any) {
parentPort?.postMessage({
success: false,
error: error instanceof Error ? error : new Error("Unknown error"),
});
}
});
parentPort?.on("close", async () => {
logger.log("Worker port closed. Shutting down NestJS worker application.");
await app.close();
});
parentPort?.on("disconnect", async () => {
logger.log("Worker port disconnected. Shutting down NestJS worker application.");
await app.close();
});
}
bootstrapWorkerApp();
@@ -1,4 +1,5 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
import { TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
@@ -17,7 +18,6 @@ import {
import { DateTime } from "luxon";
import { z } from "zod";
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
import { GetSlotsInput_2024_09_04, ReserveSlotInput_2024_09_04 } from "@calcom/platform-types";
import { Booking, EventType } from "@calcom/prisma/client";
@@ -38,13 +38,14 @@ export class SlotsService_2024_09_04 {
private readonly slotsInputService: SlotsInputService_2024_09_04,
private readonly membershipsService: MembershipsService,
private readonly membershipsRepository: MembershipsRepository,
private readonly teamsRepository: TeamsRepository
private readonly teamsRepository: TeamsRepository,
private readonly availableSlotsService: AvailableSlotsService
) {}
async getAvailableSlots(query: GetSlotsInput_2024_09_04) {
try {
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
const availableSlots: TimeSlots = await getAvailableSlots({
const availableSlots: TimeSlots = await this.availableSlotsService.getAvailableSlots({
input: {
...queryTransformed,
routingFormResponseId: queryTransformed.routingFormResponseId ?? undefined,
@@ -1,4 +1,5 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { AvailableSlotsModule } from "@/lib/modules/available-slots.module";
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";
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
TeamsModule,
MembershipsModule,
TeamsEventTypesModule,
AvailableSlotsModule,
],
providers: [
SlotsRepository_2024_09_04,
@@ -47,8 +47,8 @@ import { SkipTakePagination } from "@calcom/platform-types";
export class TeamsVerifiedResourcesController {
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
@ApiOperation({
summary: "Request Email Verification Code",
description: `Sends a verification code to the Email.`,
summary: "Request email verification code",
description: `Sends a verification code to the Email`,
})
@Roles("TEAM_ADMIN")
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@@ -71,8 +71,8 @@ export class TeamsVerifiedResourcesController {
}
@ApiOperation({
summary: "Request Phone Number Verification Code",
description: `Sends a verification code to the phone number.`,
summary: "Request phone number verification code",
description: `Sends a verification code to the phone number`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@@ -92,7 +92,7 @@ export class TeamsVerifiedResourcesController {
}
@ApiOperation({
summary: "Verify an email for a team.",
summary: "Verify an email for a team",
description: `Use code to verify an email`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@@ -117,7 +117,7 @@ export class TeamsVerifiedResourcesController {
}
@ApiOperation({
summary: "Verify a phone number for an org team.",
summary: "Verify a phone number for an org team",
description: `Use code to verify a phone number`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@@ -143,7 +143,7 @@ export class TeamsVerifiedResourcesController {
}
@ApiOperation({
summary: "Get list of verified emails of a team.",
summary: "Get list of verified emails of a team",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@@ -165,7 +165,7 @@ export class TeamsVerifiedResourcesController {
}
@ApiOperation({
summary: "Get list of verified phone numbers of a team.",
summary: "Get list of verified phone numbers of a team",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/phones")
@@ -189,7 +189,7 @@ export class TeamsVerifiedResourcesController {
}
@ApiOperation({
summary: "Get verified email of a team by id.",
summary: "Get verified email of a team by id",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@@ -207,7 +207,7 @@ export class TeamsVerifiedResourcesController {
}
@ApiOperation({
summary: "Get verified phone number of a team by id.",
summary: "Get verified phone number of a team by id",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@@ -85,6 +85,27 @@ export class UsersRepository {
});
}
async findOwnerByTeamIdWithProfile(teamId: number): Promise<UserWithProfile | null> {
return this.dbRead.prisma.user.findFirst({
where: {
teams: {
some: {
teamId,
role: "OWNER",
},
},
},
include: {
movedToProfile: {
include: { organization: { select: { isPlatform: true, name: true, slug: true, id: true } } },
},
profiles: {
include: { organization: { select: { isPlatform: true, name: true, slug: true, id: true } } },
},
},
});
}
async findByIdsWithEventTypes(userIds: number[]) {
return this.dbRead.prisma.user.findMany({
where: {
@@ -26,6 +26,7 @@ export class LocaleValidator implements ValidatorConstraintInterface {
"sr",
"sv",
"vi",
"bn",
];
if (localeValues.includes(locale)) return true;
@@ -34,8 +34,8 @@ import { SkipTakePagination } from "@calcom/platform-types";
export class UserVerifiedResourcesController {
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
@ApiOperation({
summary: "Request Email Verification Code",
description: `Sends a verification code to the email.`,
summary: "Request email verification code",
description: `Sends a verification code to the email`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/emails/verification-code/request")
@@ -57,8 +57,8 @@ export class UserVerifiedResourcesController {
}
@ApiOperation({
summary: "Request Phone Number Verification Code",
description: `Sends a verification code to the phone number.`,
summary: "Request phone number verification code",
description: `Sends a verification code to the phone number`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/phones/verification-code/request")
@@ -77,7 +77,7 @@ export class UserVerifiedResourcesController {
}
@ApiOperation({
summary: "Verify an email.",
summary: "Verify an email",
description: `Use code to verify an email`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@@ -96,7 +96,7 @@ export class UserVerifiedResourcesController {
}
@ApiOperation({
summary: "Verify a phone number.",
summary: "Verify a phone number",
description: `Use code to verify a phone number`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@@ -114,7 +114,7 @@ export class UserVerifiedResourcesController {
}
@ApiOperation({
summary: "Get list of verified emails.",
summary: "Get list of verified emails",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/emails")
@@ -135,7 +135,7 @@ export class UserVerifiedResourcesController {
}
@ApiOperation({
summary: "Get list of verified phone numbers.",
summary: "Get list of verified phone numbers",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/phones")
@@ -158,7 +158,7 @@ export class UserVerifiedResourcesController {
}
@ApiOperation({
summary: "Get verified email by id.",
summary: "Get verified email by id",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/emails/:id")
@@ -175,7 +175,7 @@ export class UserVerifiedResourcesController {
}
@ApiOperation({
summary: "Get verified phone number by id.",
summary: "Get verified phone number by id",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/phones/:id")
+328 -192
View File
@@ -3447,7 +3447,7 @@
"/v2/organizations/{orgId}/teams/{teamId}/bookings/{bookingUid}/references": {
"get": {
"operationId": "OrganizationsTeamsBookingsController_getBookingReferences",
"summary": "Get 'Booking References' for a booking",
"summary": "Get booking references",
"parameters": [
{
"name": "Authorization",
@@ -5133,6 +5133,147 @@
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": {
"get": {
"operationId": "OrganizationsStripeController_getTeamStripeConnectUrl",
"summary": "Get Stripe connect URL for a team",
"parameters": [
{
"name": "Authorization",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "returnTo",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "onErrorReturnTo",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StripConnectOutputResponseDto"
}
}
}
}
},
"tags": [
"Orgs / Teams / Stripe"
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/stripe/check": {
"get": {
"operationId": "OrganizationsStripeController_checkTeamStripeConnection",
"summary": "Check team Stripe connection",
"parameters": [
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto"
}
}
}
}
},
"tags": [
"Orgs / Teams / Stripe"
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/stripe/save": {
"get": {
"operationId": "OrganizationsStripeController_save",
"summary": "Save Stripe credentials",
"parameters": [
{
"name": "state",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "code",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StripCredentialsSaveOutputResponseDto"
}
}
}
}
},
"tags": [
"Orgs / Teams / Stripe"
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/users/{userId}/schedules": {
"get": {
"operationId": "OrganizationsTeamsSchedulesController_getUserSchedules",
@@ -8176,7 +8317,7 @@
"/v2/bookings/{bookingUid}/references": {
"get": {
"operationId": "BookingsController_2024_08_13_getBookingReferences",
"summary": "Get 'Booking References' for a booking",
"summary": "Get booking references",
"parameters": [
{
"name": "cal-api-version",
@@ -8244,7 +8385,7 @@
"/v2/calendars/{calendar}/event/{eventUid}": {
"get": {
"operationId": "CalUnifiedCalendarsController_getCalendarEventDetails",
"summary": "Get meeting Details from calendar",
"summary": "Get meeting details from calendar",
"description": "Returns detailed information about a meeting including attendance metrics",
"parameters": [
{
@@ -10194,8 +10335,8 @@
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/emails/verification-code/request": {
"post": {
"operationId": "OrgTeamsVerifiedResourcesController_requestEmailVerificationCode",
"summary": "Request Email Verification Code",
"description": "Sends a verification code to the email.",
"summary": "Request email verification code",
"description": "Sends a verification code to the email",
"parameters": [
{
"name": "Authorization",
@@ -10237,8 +10378,8 @@
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phones/verification-code/request": {
"post": {
"operationId": "OrgTeamsVerifiedResourcesController_requestPhoneVerificationCode",
"summary": "Request Phone Number Verification Code",
"description": "Sends a verification code to the phone number.",
"summary": "Request phone number verification code",
"description": "Sends a verification code to the phone number",
"parameters": [
{
"name": "Authorization",
@@ -10280,7 +10421,7 @@
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/emails/verification-code/verify": {
"post": {
"operationId": "OrgTeamsVerifiedResourcesController_verifyEmail",
"summary": "Verify an email for an org team.",
"summary": "Verify an email for an org team",
"description": "Use code to verify an email",
"parameters": [
{
@@ -10331,7 +10472,7 @@
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phones/verification-code/verify": {
"post": {
"operationId": "OrgTeamsVerifiedResourcesController_verifyPhoneNumber",
"summary": "Verify a phone number for an org team.",
"summary": "Verify a phone number for an org team",
"description": "Use code to verify a phone number",
"parameters": [
{
@@ -10382,7 +10523,7 @@
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/emails": {
"get": {
"operationId": "OrgTeamsVerifiedResourcesController_getVerifiedEmails",
"summary": "Get list of verified emails of an org team.",
"summary": "Get list of verified emails of an org team",
"parameters": [
{
"name": "teamId",
@@ -10447,7 +10588,7 @@
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phones": {
"get": {
"operationId": "OrgTeamsVerifiedResourcesController_getVerifiedPhoneNumbers",
"summary": "Get list of verified phone numbers of an org team.",
"summary": "Get list of verified phone numbers of an org team",
"parameters": [
{
"name": "teamId",
@@ -10512,7 +10653,7 @@
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/emails/{id}": {
"get": {
"operationId": "OrgTeamsVerifiedResourcesController_getVerifiedEmailById",
"summary": "Get verified email of an org team by id.",
"summary": "Get verified email of an org team by id",
"parameters": [
{
"name": "id",
@@ -10560,7 +10701,7 @@
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phones/{id}": {
"get": {
"operationId": "OrgTeamsVerifiedResourcesController_getVerifiedPhoneById",
"summary": "Get verified phone number of an org team by id.",
"summary": "Get verified phone number of an org team by id",
"parameters": [
{
"name": "teamId",
@@ -10605,147 +10746,6 @@
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": {
"get": {
"operationId": "OrganizationsStripeController_getTeamStripeConnectUrl",
"summary": "Get Stripe connect URL for a team",
"parameters": [
{
"name": "Authorization",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "returnTo",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "onErrorReturnTo",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StripConnectOutputResponseDto"
}
}
}
}
},
"tags": [
"Organizations/Teams Stripe"
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/stripe/check": {
"get": {
"operationId": "OrganizationsStripeController_checkTeamStripeConnection",
"summary": "Check team Stripe connection",
"parameters": [
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto"
}
}
}
}
},
"tags": [
"Organizations/Teams Stripe"
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/stripe/save": {
"get": {
"operationId": "OrganizationsStripeController_save",
"summary": "Save Stripe credentials",
"parameters": [
{
"name": "state",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "code",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StripCredentialsSaveOutputResponseDto"
}
}
}
}
},
"tags": [
"Organizations/Teams Stripe"
]
}
},
"/v2/routing-forms/{routingFormId}/calculate-slots": {
"post": {
"operationId": "RoutingFormsController_calculateSlotsBasedOnRoutingFormResponse",
@@ -12519,8 +12519,8 @@
"/v2/teams/{teamId}/verified-resources/emails/verification-code/request": {
"post": {
"operationId": "TeamsVerifiedResourcesController_requestEmailVerificationCode",
"summary": "Request Email Verification Code",
"description": "Sends a verification code to the Email.",
"summary": "Request email verification code",
"description": "Sends a verification code to the Email",
"parameters": [
{
"name": "Authorization",
@@ -12562,8 +12562,8 @@
"/v2/teams/{teamId}/verified-resources/phones/verification-code/request": {
"post": {
"operationId": "TeamsVerifiedResourcesController_requestPhoneVerificationCode",
"summary": "Request Phone Number Verification Code",
"description": "Sends a verification code to the phone number.",
"summary": "Request phone number verification code",
"description": "Sends a verification code to the phone number",
"parameters": [
{
"name": "Authorization",
@@ -12605,7 +12605,7 @@
"/v2/teams/{teamId}/verified-resources/emails/verification-code/verify": {
"post": {
"operationId": "TeamsVerifiedResourcesController_verifyEmail",
"summary": "Verify an email for a team.",
"summary": "Verify an email for a team",
"description": "Use code to verify an email",
"parameters": [
{
@@ -12656,7 +12656,7 @@
"/v2/teams/{teamId}/verified-resources/phones/verification-code/verify": {
"post": {
"operationId": "TeamsVerifiedResourcesController_verifyPhoneNumber",
"summary": "Verify a phone number for an org team.",
"summary": "Verify a phone number for an org team",
"description": "Use code to verify a phone number",
"parameters": [
{
@@ -12707,7 +12707,7 @@
"/v2/teams/{teamId}/verified-resources/emails": {
"get": {
"operationId": "TeamsVerifiedResourcesController_getVerifiedEmails",
"summary": "Get list of verified emails of a team.",
"summary": "Get list of verified emails of a team",
"parameters": [
{
"name": "teamId",
@@ -12772,7 +12772,7 @@
"/v2/teams/{teamId}/verified-resources/phones": {
"get": {
"operationId": "TeamsVerifiedResourcesController_getVerifiedPhoneNumbers",
"summary": "Get list of verified phone numbers of a team.",
"summary": "Get list of verified phone numbers of a team",
"parameters": [
{
"name": "teamId",
@@ -12837,7 +12837,7 @@
"/v2/teams/{teamId}/verified-resources/emails/{id}": {
"get": {
"operationId": "TeamsVerifiedResourcesController_getVerifiedEmailById",
"summary": "Get verified email of a team by id.",
"summary": "Get verified email of a team by id",
"parameters": [
{
"name": "id",
@@ -12885,7 +12885,7 @@
"/v2/teams/{teamId}/verified-resources/phones/{id}": {
"get": {
"operationId": "TeamsVerifiedResourcesController_getVerifiedPhoneById",
"summary": "Get verified phone number of a team by id.",
"summary": "Get verified phone number of a team by id",
"parameters": [
{
"name": "teamId",
@@ -12933,8 +12933,8 @@
"/v2/verified-resources/emails/verification-code/request": {
"post": {
"operationId": "UserVerifiedResourcesController_requestEmailVerificationCode",
"summary": "Request Email Verification Code",
"description": "Sends a verification code to the email.",
"summary": "Request email verification code",
"description": "Sends a verification code to the email",
"parameters": [
{
"name": "Authorization",
@@ -12976,8 +12976,8 @@
"/v2/verified-resources/phones/verification-code/request": {
"post": {
"operationId": "UserVerifiedResourcesController_requestPhoneVerificationCode",
"summary": "Request Phone Number Verification Code",
"description": "Sends a verification code to the phone number.",
"summary": "Request phone number verification code",
"description": "Sends a verification code to the phone number",
"parameters": [
{
"name": "Authorization",
@@ -13019,7 +13019,7 @@
"/v2/verified-resources/emails/verification-code/verify": {
"post": {
"operationId": "UserVerifiedResourcesController_verifyEmail",
"summary": "Verify an email.",
"summary": "Verify an email",
"description": "Use code to verify an email",
"parameters": [
{
@@ -13062,7 +13062,7 @@
"/v2/verified-resources/phones/verification-code/verify": {
"post": {
"operationId": "UserVerifiedResourcesController_verifyPhoneNumber",
"summary": "Verify a phone number.",
"summary": "Verify a phone number",
"description": "Use code to verify a phone number",
"parameters": [
{
@@ -13105,7 +13105,7 @@
"/v2/verified-resources/emails": {
"get": {
"operationId": "UserVerifiedResourcesController_getVerifiedEmails",
"summary": "Get list of verified emails.",
"summary": "Get list of verified emails",
"parameters": [
{
"name": "take",
@@ -13162,7 +13162,7 @@
"/v2/verified-resources/phones": {
"get": {
"operationId": "UserVerifiedResourcesController_getVerifiedPhoneNumbers",
"summary": "Get list of verified phone numbers.",
"summary": "Get list of verified phone numbers",
"parameters": [
{
"name": "take",
@@ -13219,7 +13219,7 @@
"/v2/verified-resources/emails/{id}": {
"get": {
"operationId": "UserVerifiedResourcesController_getVerifiedEmailById",
"summary": "Get verified email by id.",
"summary": "Get verified email by id",
"parameters": [
{
"name": "id",
@@ -13259,7 +13259,7 @@
"/v2/verified-resources/phones/{id}": {
"get": {
"operationId": "UserVerifiedResourcesController_getVerifiedPhoneById",
"summary": "Get verified phone number by id.",
"summary": "Get verified phone number by id",
"parameters": [
{
"name": "id",
@@ -13614,7 +13614,8 @@
"sk",
"ta",
"uk",
"zh-TW"
"zh-TW",
"bn"
],
"type": "string",
"example": "en"
@@ -13750,7 +13751,8 @@
"sk",
"ta",
"uk",
"zh-TW"
"zh-TW",
"bn"
],
"type": "string",
"example": "en"
@@ -13930,7 +13932,8 @@
"sk",
"ta",
"uk",
"zh-TW"
"zh-TW",
"bn"
],
"type": "string",
"example": "en"
@@ -23972,7 +23975,7 @@
"type": "object",
"properties": {}
},
"Attendee": {
"CreateBookingAttendee": {
"type": "object",
"properties": {
"name": {
@@ -24039,7 +24042,8 @@
"sk",
"ta",
"uk",
"zh-TW"
"zh-TW",
"bn"
],
"description": "The preferred language of the attendee. Used for booking confirmation.",
"example": "it",
@@ -24108,7 +24112,7 @@
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/CreateBookingAttendee"
}
]
},
@@ -24235,7 +24239,7 @@
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/CreateBookingAttendee"
}
]
},
@@ -24368,7 +24372,7 @@
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/CreateBookingAttendee"
}
]
},
@@ -24537,6 +24541,86 @@
"slug"
]
},
"BookingAttendee": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "John Doe"
},
"email": {
"type": "string",
"example": "john@example.com"
},
"timeZone": {
"type": "string",
"example": "America/New_York"
},
"language": {
"type": "string",
"enum": [
"ar",
"ca",
"de",
"es",
"eu",
"he",
"id",
"ja",
"lv",
"pl",
"ro",
"sr",
"th",
"vi",
"az",
"cs",
"el",
"es-419",
"fi",
"hr",
"it",
"km",
"nl",
"pt",
"ru",
"sv",
"tr",
"zh-CN",
"bg",
"da",
"en",
"et",
"fr",
"hu",
"iw",
"ko",
"no",
"pt-BR",
"sk",
"ta",
"uk",
"zh-TW",
"bn"
],
"example": "en"
},
"absent": {
"type": "boolean",
"example": false
},
"phoneNumber": {
"type": "string",
"example": "+1234567890"
}
},
"required": [
"name",
"email",
"timeZone",
"absent"
]
},
"BookingOutput_2024_08_13": {
"type": "object",
"properties": {
@@ -24590,7 +24674,13 @@
},
"rescheduledFromUid": {
"type": "string",
"example": "previous_uid_123"
"example": "previous_uid_123",
"description": "UID of the previous booking from which this booking was rescheduled."
},
"rescheduledToUid": {
"type": "string",
"example": "new_uid_456",
"description": "UID of the new booking to which this booking was rescheduled."
},
"start": {
"type": "string",
@@ -24653,7 +24743,7 @@
"attendees": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/BookingAttendee"
}
},
"guests": {
@@ -24747,7 +24837,13 @@
},
"rescheduledFromUid": {
"type": "string",
"example": "previous_uid_123"
"example": "previous_uid_123",
"description": "UID of the previous booking from which this booking was rescheduled."
},
"rescheduledToUid": {
"type": "string",
"example": "new_uid_456",
"description": "UID of the new booking to which this booking was rescheduled."
},
"start": {
"type": "string",
@@ -24810,7 +24906,7 @@
"attendees": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/BookingAttendee"
}
},
"guests": {
@@ -24915,7 +25011,8 @@
"sk",
"ta",
"uk",
"zh-TW"
"zh-TW",
"bn"
],
"example": "en"
},
@@ -25007,7 +25104,13 @@
},
"rescheduledFromUid": {
"type": "string",
"example": "previous_uid_123"
"example": "previous_uid_123",
"description": "UID of the previous booking from which this booking was rescheduled."
},
"rescheduledToUid": {
"type": "string",
"example": "new_uid_456",
"description": "UID of the new booking to which this booking was rescheduled."
},
"start": {
"type": "string",
@@ -25151,7 +25254,13 @@
},
"rescheduledFromUid": {
"type": "string",
"example": "previous_uid_123"
"example": "previous_uid_123",
"description": "UID of the previous booking from which this booking was rescheduled."
},
"rescheduledToUid": {
"type": "string",
"example": "new_uid_456",
"description": "UID of the new booking to which this booking was rescheduled."
},
"start": {
"type": "string",
@@ -25340,7 +25449,13 @@
},
"rescheduledFromUid": {
"type": "string",
"example": "previous_uid_123"
"example": "previous_uid_123",
"description": "UID of the previous booking from which this booking was rescheduled."
},
"rescheduledToUid": {
"type": "string",
"example": "new_uid_456",
"description": "UID of the new booking to which this booking was rescheduled."
},
"start": {
"type": "string",
@@ -25479,7 +25594,13 @@
},
"rescheduledFromUid": {
"type": "string",
"example": "previous_uid_123"
"example": "previous_uid_123",
"description": "UID of the previous booking from which this booking was rescheduled."
},
"rescheduledToUid": {
"type": "string",
"example": "new_uid_456",
"description": "UID of the new booking to which this booking was rescheduled."
},
"start": {
"type": "string",
@@ -25921,6 +26042,21 @@
"data"
]
},
"MarkAbsentAttendee": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"absent": {
"type": "boolean"
}
},
"required": [
"email",
"absent"
]
},
"MarkAbsentBookingInput_2024_08_13": {
"type": "object",
"properties": {
@@ -25932,7 +26068,7 @@
"attendees": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/MarkAbsentAttendee"
}
}
}
@@ -75,6 +75,8 @@ export class CalendarsServiceMock {
id: 0,
delegationCredentialId: null,
domainWideDelegationCredentialId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
} satisfies Awaited<ReturnType<typeof CalendarsService.prototype.getCalendars>>;
}
+2 -1
View File
@@ -25,7 +25,8 @@
"@calcom/platform-libraries/schedules": ["../../../packages/platform/libraries/schedules.ts"],
"@calcom/platform-libraries/app-store": ["../../../packages/platform/libraries/app-store.ts"],
"@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"],
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"]
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"],
"@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"]
},
"incremental": true,
"skipLibCheck": true,
@@ -70,9 +70,7 @@ export const ComplexExample: React.FC = () => (
<div className="flex flex-col items-center gap-2">
<Dropdown>
<DropdownMenuTrigger asChild>
<button className="cursor-pointer">
<Button variant="icon" color="secondary" StartIcon="calendar" />
</button>
<Button variant="icon" color="secondary" StartIcon="calendar" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownItem>Rename</DropdownItem>
+1 -1
View File
@@ -30,7 +30,7 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^9",
"eslint": "^8.34.0",
"eslint-config-next": "15.1.6",
"postcss": "^8",
"tailwindcss": "^3.4.1",
@@ -11,6 +11,15 @@ import { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps
import TypePage, { type PageProps as ClientPageProps } from "~/users/views/users-type-public-view";
export const generateMetadata = async () => {
return {
robots: {
follow: false,
index: false,
},
};
};
const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
@@ -8,6 +8,15 @@ import { getServerSideProps } from "@server/lib/[user]/getServerSideProps";
import User, { type PageProps as ClientPageProps } from "~/users/views/users-public-view";
export const generateMetadata = async () => {
return {
robots: {
follow: false,
index: false,
},
};
};
const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
@@ -17,6 +17,15 @@ const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
export type ClientPageProps = UserTypePageProps | TeamTypePageProps;
export const generateMetadata = async () => {
return {
robots: {
follow: false,
index: false,
},
};
};
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
@@ -14,6 +14,15 @@ const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
export type ClientPageProps = UserPageProps | TeamPageProps;
export const generateMetadata = async () => {
return {
robots: {
follow: false,
index: false,
},
};
};
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
@@ -10,6 +10,15 @@ import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
import TypePage, { type PageProps as ClientPageProps } from "~/team/type-view";
export const generateMetadata = async () => {
return {
robots: {
follow: false,
index: false,
},
};
};
const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
@@ -7,6 +7,15 @@ import { getServerSideProps } from "@lib/team/[slug]/getServerSideProps";
import TeamPage, { type PageProps as ClientPageProps } from "~/team/team-view";
export const generateMetadata = async () => {
return {
robots: {
follow: false,
index: false,
},
};
};
const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
@@ -6,6 +6,7 @@ import { unstable_cache } from "next/cache";
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamService } from "@calcom/lib/server/service/team";
import prisma from "@calcom/prisma";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
import { TRPCError } from "@trpc/server";
@@ -14,7 +15,8 @@ import { TeamsCTA } from "./CTA";
const getCachedTeams = unstable_cache(
async (userId: number) => {
return await TeamRepository.findTeamsByUserId({
const teamRepo = new TeamRepository(prisma);
return await teamRepo.findTeamsByUserId({
userId,
includeOrgs: true,
});
@@ -4,6 +4,7 @@ import { cookies, headers } from "next/headers";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import type { AppCategories } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -25,7 +26,8 @@ const ServerPage = async () => {
const session = await getServerSession({ req });
let appStore, userAdminTeamsIds: number[];
if (session?.user?.id) {
const userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id);
const userRepo = new UserRepository(prisma);
const userAdminTeams = await userRepo.getUserAdminTeams({ userId: session.user.id });
userAdminTeamsIds = userAdminTeams?.teams?.map(({ team }) => team.id) ?? [];
appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeamsIds);
} else {
@@ -6,6 +6,7 @@ import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequir
import { UsersEditView } from "@calcom/features/ee/users/pages/users-edit-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
const userIdSchema = z.object({ id: z.coerce.number() });
@@ -21,7 +22,8 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
);
}
const user = await UserRepository.adminFindById(input.data.id);
const userRepo = new UserRepository(prisma);
const user = await userRepo.adminFindById(input.data.id);
return await _generateMetadata(
(t) => `${t("editing_user")}: ${user.username}`,
@@ -37,7 +39,8 @@ const Page = async ({ params }: { params: Params }) => {
if (!input.success) throw new Error("Invalid access");
const user = await UserRepository.adminFindById(input.data.id);
const userRepo = new UserRepository(prisma);
const user = await userRepo.adminFindById(input.data.id);
const t = await getTranslate();
return (
@@ -10,6 +10,8 @@ import React, { useEffect, useState, useMemo } from "react";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import type { OrganizationBranding } from "@calcom/features/ee/organizations/context/provider";
import type { TeamFeatures } from "@calcom/features/flags/config";
import { useIsFeatureEnabledForTeam } from "@calcom/features/flags/hooks/useIsFeatureEnabledForTeam";
import Shell from "@calcom/features/shell/Shell";
import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
@@ -177,7 +179,13 @@ const organizationAdminKeys = [
"delegation_credential",
];
const useTabs = ({ isDelegationCredentialEnabled }: { isDelegationCredentialEnabled: boolean }) => {
const useTabs = ({
isDelegationCredentialEnabled,
isPbacEnabled,
}: {
isDelegationCredentialEnabled: boolean;
isPbacEnabled: boolean;
}) => {
const session = useSession();
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
const orgBranding = useOrgBranding();
@@ -213,6 +221,14 @@ const useTabs = ({ isDelegationCredentialEnabled }: { isDelegationCredentialEnab
});
}
// Add pbac menu item only if feature flag is enabled
if (isPbacEnabled) {
newArray.push({
name: "roles_and_permissions",
href: "/settings/organizations/roles",
});
}
return {
...tab,
children: newArray,
@@ -272,6 +288,7 @@ interface SettingsSidebarContainerProps {
className?: string;
navigationIsOpenedOnMobile?: boolean;
bannersHeight?: number;
teamFeatures?: Record<number, TeamFeatures>;
}
const TeamListCollapsible = () => {
@@ -438,6 +455,7 @@ const SettingsSidebarContainer = ({
className = "",
navigationIsOpenedOnMobile,
bannersHeight,
teamFeatures,
}: SettingsSidebarContainerProps) => {
const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding();
@@ -449,11 +467,24 @@ const SettingsSidebarContainer = ({
}[]
>();
const session = useSession();
const { data } = trpc.viewer.delegationCredential.check.useQuery(undefined, {
enabled: !!session.data?.user?.org,
const organizationId = session.data?.user?.org?.id;
const isDelegationCredentialEnabled = useIsFeatureEnabledForTeam({
teamFeatures,
teamId: organizationId,
feature: "delegation-credential",
});
const isPbacEnabled = useIsFeatureEnabledForTeam({
teamFeatures,
teamId: organizationId,
feature: "pbac",
});
const tabsWithPermissions = useTabs({
isDelegationCredentialEnabled: data?.hasDelegationCredential ?? false,
isDelegationCredentialEnabled,
isPbacEnabled,
});
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
@@ -727,9 +758,10 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
export type SettingsLayoutProps = {
children: React.ReactNode;
containerClassName?: string;
teamFeatures?: Record<number, TeamFeatures>;
} & ComponentProps<typeof Shell>;
export default function SettingsLayoutAppDirClient({ children, ...rest }: SettingsLayoutProps) {
export default function SettingsLayoutAppDirClient({ children, teamFeatures, ...rest }: SettingsLayoutProps) {
const pathname = usePathname();
const state = useState(false);
const [sideContainerOpen, setSideContainerOpen] = state;
@@ -761,6 +793,7 @@ export default function SettingsLayoutAppDirClient({ children, ...rest }: Settin
<SidebarContainerElement
sideContainerOpen={sideContainerOpen}
setSideContainerOpen={setSideContainerOpen}
teamFeatures={teamFeatures}
/>
}
drawerState={state}
@@ -778,10 +811,18 @@ export default function SettingsLayoutAppDirClient({ children, ...rest }: Settin
);
}
type SidebarContainerElementProps = {
sideContainerOpen: boolean;
bannersHeight?: number;
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
teamFeatures?: Record<number, TeamFeatures>;
};
const SidebarContainerElement = ({
sideContainerOpen,
bannersHeight,
setSideContainerOpen,
teamFeatures,
}: SidebarContainerElementProps) => {
const { t } = useLocale();
return (
@@ -797,13 +838,8 @@ const SidebarContainerElement = ({
<SettingsSidebarContainer
navigationIsOpenedOnMobile={sideContainerOpen}
bannersHeight={bannersHeight}
teamFeatures={teamFeatures}
/>
</>
);
};
type SidebarContainerElementProps = {
sideContainerOpen: boolean;
bannersHeight?: number;
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
@@ -1,14 +1,28 @@
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import React from "react";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { TeamFeatures } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import type { SettingsLayoutProps } from "./SettingsLayoutAppDirClient";
import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
const getTeamFeatures = unstable_cache(
async (teamId: number) => {
const featuresRepository = new FeaturesRepository();
return await featuresRepository.getTeamFeatures(teamId);
},
["team-features"],
{
revalidate: 120,
}
);
export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const userId = session?.user?.id;
@@ -16,9 +30,21 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
return redirect("/auth/login");
}
let teamFeatures: Record<number, TeamFeatures> | null = null;
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
if (orgId) {
const features = await getTeamFeatures(orgId);
if (features) {
teamFeatures = {
[orgId]: features,
};
}
}
return (
<>
<SettingsLayoutAppDirClient {...props} />
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} />
</>
);
}
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { Checkbox, Label } from "@calcom/ui/form";
import { usePermissions } from "./usePermissions";
interface AdvancedPermissionGroupProps {
resource: Resource;
selectedPermissions: string[];
onChange: (permissions: string[]) => void;
}
const INTERNAL_DATAACCESS_KEY = "_resource";
export function AdvancedPermissionGroup({
resource,
selectedPermissions,
onChange,
}: AdvancedPermissionGroupProps) {
const { t } = useLocale();
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions();
const resourceConfig = PERMISSION_REGISTRY[resource];
const [isExpanded, setIsExpanded] = useState(false);
const isAllResources = resource === "*";
const allResourcesSelected = selectedPermissions.includes("*.*");
// Get all possible permissions for this resource
const allPermissions = isAllResources
? ["*.*"]
: Object.entries(resourceConfig)
.filter(([action]) => action !== INTERNAL_DATAACCESS_KEY)
.map(([action]) => `${resource}.${action}`);
// Check if all permissions for this resource are selected
const isAllSelected = isAllResources
? allResourcesSelected
: allPermissions.every((p) => selectedPermissions.includes(p));
const handleToggleAll = (e: React.MouseEvent) => {
e.stopPropagation(); // Stop event from triggering parent click
onChange(toggleResourcePermissionLevel(resource, isAllSelected ? "none" : "all", selectedPermissions));
};
// Helper function to check if read permission is auto-enabled
const isReadAutoEnabled = (action: string) => {
if (action === CrudAction.Read) return false;
if (action === CrudAction.Create || action === CrudAction.Update || action === CrudAction.Delete) {
return selectedPermissions.includes(`${resource}.${CrudAction.Read}`);
}
return false;
};
return (
<div className="bg-muted border-subtle mb-2 rounded-xl border">
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1.5 p-4"
onClick={() => setIsExpanded(!isExpanded)}>
<Icon
name={isAllResources ? "chevron-right" : "chevron-down"}
className={classNames(
"h-4 w-4 transition-transform",
isExpanded && !isAllResources ? "rotate-180" : ""
)}
/>
<div className="flex items-center gap-2">
<Checkbox
checked={isAllSelected}
onCheckedChange={() => handleToggleAll}
onClick={handleToggleAll}
/>
<span className="text-default text-sm font-medium leading-none">
{t(resourceConfig._resource?.i18nKey || "")}
</span>
<span className="text-muted text-sm font-medium leading-none">{t("all_permissions")}</span>
</div>
</button>
{isExpanded && !isAllResources && (
<div
className="bg-default border-muted m-1 flex flex-col gap-2.5 rounded-xl border p-3"
onClick={(e) => e.stopPropagation()} // Stop clicks in the permission list from affecting parent
>
{Object.entries(resourceConfig).map(([action, actionConfig]) => {
const permission = `${resource}.${action}`;
if (action === INTERNAL_DATAACCESS_KEY) {
return null;
}
const isChecked = selectedPermissions.includes(permission);
const isReadPermission = action === CrudAction.Read;
const isAutoEnabled = isReadAutoEnabled(action);
return (
<div key={action} className="flex items-center">
<Checkbox
id={permission}
checked={isChecked}
className="mr-2"
onCheckedChange={(checked) => {
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
}}
onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
/>
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()} // Stop label clicks from affecting parent
>
<Label htmlFor={permission} className="mb-0">
<span className={classNames(isAutoEnabled && "text-muted-foreground")}>
{t(actionConfig?.i18nKey || "")}
</span>
</Label>
<span className="text-sm text-gray-500">
{t(
actionConfig && "descriptionI18nKey" in actionConfig
? actionConfig.descriptionI18nKey
: ""
)}
</span>
{isAutoEnabled && (
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
<Icon name="info" className="text-muted-foreground h-3 w-3" />
</Tooltip>
)}
</div>
</div>
);
})}{" "}
</div>
)}
</div>
);
}
@@ -0,0 +1,17 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/button";
import { useRoleSheetState } from "../hooks/useRoleQueryStates";
export function CreateRoleCTA() {
const { t } = useLocale();
const { setIsOpen } = useRoleSheetState();
return (
<Button StartIcon="plus" onClick={() => setIsOpen(true)}>
{t("create_role")}
</Button>
);
}
@@ -0,0 +1,65 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Dialog } from "@calcom/ui/components/dialog";
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
import { DropdownItem } from "@calcom/ui/components/dropdown";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamRoles } from "../actions";
interface DeleteRoleModalProps {
roleId: string;
roleName: string;
teamId: number;
onDeleted?: () => void;
}
export function DeleteRoleModal({ roleId, roleName, teamId, onDeleted }: DeleteRoleModalProps) {
const { t } = useLocale();
const [isModalOpen, setIsModalOpen] = useState(false);
const router = useRouter();
const deleteMutation = trpc.viewer.pbac.deleteRole.useMutation({
onSuccess: async () => {
showToast(t("role_deleted_successfully"), "success");
setIsModalOpen(false);
await revalidateTeamRoles();
router.refresh();
onDeleted?.();
},
onError: (error) => {
showToast(error.message || t("error_deleting_role"), "error");
},
});
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DropdownItem StartIcon="trash" color="destructive" onClick={() => setIsModalOpen(true)}>
{t("delete")}
</DropdownItem>
<ConfirmationDialogContent
variety="danger"
title={t("delete_role")}
confirmBtnText={t("delete")}
cancelBtnText={t("cancel")}
onConfirm={() => {
deleteMutation.mutate({
teamId,
roleId,
});
}}
loadingText={t("deleting_role")}>
<p className="mb-4">
{t("confirm_delete_role", {
roleName,
})}
</p>
</ConfirmationDialogContent>
</Dialog>
);
}
@@ -0,0 +1,49 @@
"use client";
import { useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
export interface RoleColorPickerProps {
value: string;
onChange: (color: string) => void;
}
export default function RoleColorPicker({ value, onChange }: RoleColorPickerProps) {
const { t } = useLocale();
const [, setCustomColor] = useState(value);
const colorInputRef = useRef<HTMLInputElement>(null);
const handleCustomColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newColor = e.target.value;
setCustomColor(newColor);
onChange(newColor);
};
const handleColorSquareClick = () => {
colorInputRef.current?.click();
};
return (
<div className="mt-6">
<button
type="button"
onClick={handleColorSquareClick}
className="bg-default text-default border-default hover:bg-muted hover:text-emphasis focus:bg-subtle focus-visible:shadow-outline-gray-focused shadow-outline-gray-rested enabled:hover:shadow-outline-gray-hover enabled:active:shadow-outline-gray-active flex h-8 w-8 items-center justify-center gap-2 rounded-[10px] border text-sm transition-shadow duration-200 focus-visible:outline-none focus-visible:ring-0">
<div
className="h-5 w-5 rounded border shadow-sm"
style={{ backgroundColor: value }}
aria-label={t("select_color")}
/>
</button>
<input
ref={colorInputRef}
type="color"
value={value}
onChange={handleCustomColorChange}
className="sr-only"
aria-hidden="true"
/>
</div>
);
}
@@ -0,0 +1,261 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useState, useMemo, useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/button";
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@calcom/ui/components/sheet";
import { showToast } from "@calcom/ui/components/toast";
import { Form, TextField, Checkbox, Label } from "@calcom/ui/form";
import { revalidateTeamRoles } from "../actions";
import { AdvancedPermissionGroup } from "./AdvancedPermissionGroup";
import RoleColorPicker from "./RoleColorPicker";
import { SimplePermissionItem } from "./SimplePermissionItem";
type Role = {
id: string;
name: string;
description?: string;
teamId?: number;
color?: string;
createdAt: Date;
updatedAt: Date;
type: "SYSTEM" | "CUSTOM";
permissions: {
id: string;
resource: string;
action: string;
}[];
};
const formSchema = z.object({
name: z.string().min(1, { message: "role_name_required" }),
description: z.string().optional(),
isAdvancedMode: z.boolean().default(false),
permissions: z.array(z.string()),
color: z.string().default("#FF5733"),
});
type FormValues = z.infer<typeof formSchema>;
interface RoleSheetProps {
role?: Role;
open: boolean;
onOpenChange: (open: boolean) => void;
teamId: number;
}
export function RoleSheet({ role, open, onOpenChange, teamId }: RoleSheetProps) {
const { t } = useLocale();
const router = useRouter();
const isEditing = Boolean(role);
const [searchQuery, setSearchQuery] = useState("");
const defaultValues = useMemo(
() => ({
name: role?.name || "",
description: role?.description || "",
color: role?.color || "#FF5733",
isAdvancedMode: false,
permissions: role?.permissions?.map((p) => `${p.resource}.${p.action}`) || [],
}),
[role]
); // Memoize default values based on role
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues,
});
// Update form values when role changes
useEffect(() => {
// Only reset if the sheet is open to prevent unnecessary resets
if (open) {
if (role) {
form.reset({
name: role.name,
description: role.description || "",
color: role.color || "#FF5733",
isAdvancedMode: form.getValues("isAdvancedMode"), // Preserve the current mode
permissions: role.permissions.map((p) => `${p.resource}.${p.action}`),
});
} else {
form.reset({
name: "",
description: "",
color: "#FF5733",
isAdvancedMode: false,
permissions: [],
});
}
}
}, [role, form, open]);
const { isAdvancedMode, permissions, color } = form.watch();
const filteredResources = useMemo(() => {
return Object.keys(PERMISSION_REGISTRY).filter((resource) =>
t(
PERMISSION_REGISTRY[resource as Resource][
CrudAction.All as keyof (typeof PERMISSION_REGISTRY)[Resource]
]?.i18nKey || ""
)
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
}, [searchQuery, t]);
const createMutation = trpc.viewer.pbac.createRole.useMutation({
onSuccess: async () => {
showToast(t("role_created_successfully"), "success");
form.reset();
onOpenChange(false);
await revalidateTeamRoles();
router.refresh();
},
onError: (error) => {
showToast(error.message || t("error_creating_role"), "error");
},
});
const updateMutation = trpc.viewer.pbac.updateRole.useMutation({
onSuccess: async () => {
showToast(t("role_updated_successfully"), "success");
onOpenChange(false);
await revalidateTeamRoles();
router.refresh();
},
onError: (error) => {
showToast(error.message || t("error_updating_role"), "error");
},
});
const onSubmit = (values: FormValues) => {
// Store the color in localStorage
const roleKey = isEditing && role ? role.id : `new_role_${values.name}`;
localStorage.setItem(`role_color_${roleKey}`, values.color);
if (isEditing && role) {
updateMutation.mutate({
teamId,
roleId: role.id,
name: values.name,
permissions: values.permissions as any,
color: values.color,
});
} else {
createMutation.mutate({
teamId,
name: values.name,
description: values.description,
color: values.color,
permissions: values.permissions as any,
});
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>{isEditing ? t("edit_role") : t("create_role")}</SheetTitle>
</SheetHeader>
<Form form={form} handleSubmit={onSubmit}>
<div className="space-y-4 py-5">
<div className="flex items-end justify-end gap-2">
<div className="flex-1">
<TextField
label={t("role_name")}
{...form.register("name")}
placeholder={t("role_name_placeholder")}
/>
</div>
<RoleColorPicker
value={color}
onChange={(value) => form.setValue("color", value, { shouldDirty: true })}
/>
</div>
<div className="">
{isAdvancedMode ? (
<div className="space-y-4">
<div>
<div className="mb-2 flex items-center justify-between">
<Label className="mb-0">{t("permissions")}</Label>
<div className="flex items-center gap-2">
<Checkbox
checked={form.watch("isAdvancedMode")}
onCheckedChange={(checked: boolean) => form.setValue("isAdvancedMode", checked)}
/>
<span className="text-sm">{t("advanced")}</span>
</div>
</div>
<TextField
id="permissions_search"
placeholder={t("search_permissions")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{filteredResources.map((resource) => (
<AdvancedPermissionGroup
key={resource}
resource={resource as Resource}
selectedPermissions={permissions}
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
/>
))}
</div>
) : (
<div className="bg-muted rounded-xl p-1">
<div className="flex items-center justify-between px-3 py-2">
<Label>{t("permissions")}</Label>
<div className="flex items-center gap-2">
<Checkbox
checked={form.watch("isAdvancedMode")}
onCheckedChange={(checked: boolean) => form.setValue("isAdvancedMode", checked)}
/>
<span className="text-sm">{t("advanced")}</span>
</div>
</div>
<div className="bg-default border-subtle divide-subtle divide-y rounded-[10px] border">
{Object.keys(PERMISSION_REGISTRY).map((resource) => (
<SimplePermissionItem
key={resource}
resource={resource}
permissions={permissions}
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
/>
))}
</div>
</div>
)}
</div>
</div>
<SheetFooter>
<Button
type="button"
color="secondary"
onClick={() => onOpenChange(false)}
disabled={createMutation.isPending || updateMutation.isPending}>
{t("cancel")}
</Button>
<Button type="submit" loading={createMutation.isPending || updateMutation.isPending}>
{isEditing ? t("save") : t("create")}
</Button>
</SheetFooter>
</Form>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,174 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/badge";
import { Button } from "@calcom/ui/button";
import classNames from "@calcom/ui/classNames";
import {
Dropdown,
DropdownMenuContent,
DropdownItem,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { useRoleStates } from "../hooks/useRoleQueryStates";
import { DeleteRoleModal } from "./DeleteRoleModal";
import { RoleSheet } from "./RoleSheet";
type Role = {
id: string;
name: string;
description?: string;
teamId?: number;
color?: string;
createdAt: Date;
updatedAt: Date;
type: "SYSTEM" | "CUSTOM";
permissions: {
id: string;
resource: string;
action: string;
}[];
};
type Roles = Role[];
type Permissions = {
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
canRead: boolean;
};
interface RolesListProps {
roles: Roles;
permissions: Permissions;
initialSelectedRole?: Role;
initialSheetOpen?: boolean;
teamId: number;
}
export function RolesList({
roles,
permissions,
initialSelectedRole,
initialSheetOpen,
teamId,
}: RolesListProps) {
const { t } = useLocale();
const { isOpen, setIsOpen, selectedRoleId, setSelectedRoleId, handleSheetOpenChange } = useRoleStates(
initialSheetOpen,
initialSelectedRole?.id
);
const selectedRole = roles.find((role) => role.id === selectedRoleId);
return (
<>
<div className="mt-4">
<div className="bg-muted border-muted flex flex-col rounded-xl border p-[1px]">
{/* Roles list header */}
<div className="px-5 py-4">
<h2 className="text-default text-sm font-semibold leading-none">{t("role")}</h2>
</div>
{/* Role List Items */}
<div className="bg-default border-subtle divide-subtle flex flex-col divide-y rounded-[10px] border">
{roles.map((role) => (
<RoleItem
role={role}
key={role.id}
onClick={() => {
// Cant edit system roles
if (role.type === "SYSTEM") {
return;
}
if (permissions.canUpdate) {
setSelectedRoleId(role.id);
setIsOpen(true);
}
}}
canUpdate={permissions.canUpdate}
permissions={permissions}
teamId={teamId}
/>
))}
</div>
{/* Footer */}
<div className="px-5 py-4 ">
{/* Commenting out for now as we don't have a learn more permissions page in docs */}
{/* <p className="text-subtle text-sm font-medium leading-tight">{t("learn_more_permissions")}</p> */}
</div>
</div>
</div>
<RoleSheet
role={selectedRole}
open={isOpen ?? false}
onOpenChange={handleSheetOpenChange}
teamId={teamId}
/>
</>
);
}
function RoleItem({
role,
onClick,
canUpdate,
permissions,
teamId,
}: {
role: Role;
onClick: () => void;
canUpdate: boolean;
permissions: Permissions;
teamId: number;
}) {
const { t } = useLocale();
const showDropdown = role.type !== "SYSTEM" && (permissions.canUpdate || permissions.canDelete);
return (
<div
className={classNames(
"border-subtle flex p-3",
canUpdate && role.type !== "SYSTEM" && "hover:bg-subtle cursor-pointer"
)}
onClick={onClick}>
<div className="flex w-full items-center gap-3 truncate">
{/* Icon */}
<div className="flex items-center justify-center">
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: role.color ?? "gray" }} />
</div>
{/* Role Name */}
<div className="text-default w-24 truncate text-sm font-semibold leading-none">
<span>{role.name}</span>
</div>
<Badge variant={role.type === "SYSTEM" ? "grayWithoutHover" : "green"}>
{t(role.type === "SYSTEM" ? "default_role" : "custom_role")}
</Badge>
{showDropdown && (
<div className="ml-auto" onClick={(e) => e.stopPropagation()}>
<Dropdown>
<DropdownMenuTrigger asChild>
<Button variant="icon" color="secondary" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent className="flex flex-col">
{permissions.canUpdate && (
<DropdownItem StartIcon="pencil" onClick={onClick}>
{t("edit")}
</DropdownItem>
)}
{permissions.canDelete && role.type !== "SYSTEM" && (
<DeleteRoleModal roleId={role.id} roleName={role.name} teamId={teamId} />
)}
</DropdownMenuContent>
</Dropdown>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,39 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SkeletonText } from "@calcom/ui/components/skeleton";
export const SkeletonLoader = () => {
const { t } = useLocale();
return (
<div className="mt-4">
<div className="bg-muted border-muted flex flex-col rounded-xl border p-[1px]">
{/* Roles list header */}
<div className="px-5 py-4">
<h2 className="text-default text-sm font-semibold leading-none">{t("role")}</h2>
</div>
{/* Role List Items */}
<div className="bg-default border-subtle divide divide-subtle flex flex-col rounded-[10px] border">
{/* Generate 3 skeleton items */}
{[...Array(3)].map((_, index) => (
<div key={index} className="border-subtle flex border-b p-3">
<div className="flex items-center gap-3 truncate">
{/* Icon skeleton */}
<div className="flex items-center justify-center">
<div className="h-3 w-3 animate-pulse rounded-full bg-gray-200" />
</div>
{/* Role name skeleton */}
<SkeletonText className="h-4 w-20" />
{/* Badge skeleton */}
<div className="h-5 w-24 animate-pulse rounded-full bg-gray-200" />
</div>
</div>
))}
</div>
<div className="px-5 py-4 ">
<p className="text-subtle text-sm font-medium leading-tight">{t("learn_more_permissions")}</p>
</div>
</div>
</div>
);
};
@@ -0,0 +1,47 @@
"use client";
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ToggleGroup } from "@calcom/ui/components/form";
import type { PermissionLevel } from "./usePermissions";
import { usePermissions } from "./usePermissions";
interface SimplePermissionItemProps {
resource: string;
permissions: string[];
onChange: (permissions: string[]) => void;
}
export function SimplePermissionItem({ resource, permissions, onChange }: SimplePermissionItemProps) {
const { t } = useLocale();
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions();
const isAllResources = resource === "*";
const options = isAllResources
? [
{ value: "none", label: t("none") },
{ value: "all", label: t("all") },
]
: [
{ value: "none", label: t("none") },
{ value: "read", label: t("read_only") },
{ value: "all", label: t("all") },
];
return (
<div className="flex items-center justify-between px-3 py-2">
<span className="text-default text-sm font-medium leading-none">
{t(PERMISSION_REGISTRY[resource as Resource]._resource?.i18nKey || resource)}
</span>
<ToggleGroup
onValueChange={(val) => {
if (val) onChange(toggleResourcePermissionLevel(resource, val as PermissionLevel, permissions));
}}
value={getResourcePermissionLevel(resource, permissions)}
options={options}
/>
</div>
);
}
@@ -0,0 +1,10 @@
import { createSearchParamsCache, parseAsBoolean, parseAsString } from "nuqs/server";
export const roleParsers = {
// Sheet visibility state
"role-sheet": parseAsBoolean.withDefault(false),
// Selected role ID for editing
role: parseAsString.withDefault(""),
};
export const roleSearchParamsCache = createSearchParamsCache(roleParsers);
@@ -0,0 +1,159 @@
import { CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
export type PermissionLevel = "none" | "read" | "all";
interface UsePermissionsReturn {
hasAllPermissions: (permissions: string[]) => boolean;
getResourcePermissionLevel: (resource: string, permissions: string[]) => PermissionLevel;
toggleResourcePermissionLevel: (
resource: string,
level: PermissionLevel,
currentPermissions: string[]
) => string[];
toggleSinglePermission: (permission: string, enabled: boolean, currentPermissions: string[]) => string[];
}
export function usePermissions(): UsePermissionsReturn {
const getAllPossiblePermissions = () => {
const permissions: string[] = [];
Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
if (resource !== "*") {
Object.keys(config).forEach((action) => {
permissions.push(`${resource}.${action}`);
});
}
});
return permissions;
};
const hasAllPermissions = (permissions: string[]) => {
return Object.entries(PERMISSION_REGISTRY).every(([resource, config]) => {
if (resource === "*") return true;
return Object.keys(config).every((action) => permissions.includes(`${resource}.${action}`));
});
};
const getResourcePermissionLevel = (resource: string, permissions: string[]): PermissionLevel => {
if (resource === "*") {
return permissions.includes("*.*") ? "all" : "none";
}
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
if (!resourceConfig) return "none";
const allResourcePerms = Object.keys(resourceConfig).map((action) => `${resource}.${action}`);
const hasAllPerms = allResourcePerms.every((p) => permissions.includes(p));
const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`);
if (hasAllPerms) return "all";
if (hasReadPerm) return "read";
return "none";
};
const toggleResourcePermissionLevel = (
resource: string,
level: PermissionLevel,
currentPermissions: string[]
): string[] => {
// First, remove *.* since we're modifying permissions
let newPermissions = currentPermissions.filter((p) => p !== "*.*");
if (resource === "*") {
if (level === "all") {
// When selecting all resources, add *.* and all individual permissions
const allPossiblePerms = getAllPossiblePermissions();
newPermissions = ["*.*", ...allPossiblePerms];
}
// When deselecting all resources, we've already removed *.* above
} else {
// Filter out current resource permissions
newPermissions = newPermissions.filter((p) => !p.startsWith(`${resource}.`));
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
if (!resourceConfig) return currentPermissions;
switch (level) {
case "none":
// No permissions to add, just keep other permissions
break;
case "read":
// Only add read permission
newPermissions.push(`${resource}.${CrudAction.Read}`);
break;
case "all":
// Add all permissions for this resource
const allResourcePerms = Object.keys(resourceConfig).map((action) => `${resource}.${action}`);
newPermissions.push(...allResourcePerms);
break;
}
// Only add *.* back if all permissions are now selected
if (hasAllPermissions(newPermissions)) {
newPermissions.push("*.*");
}
}
return newPermissions;
};
const toggleSinglePermission = (
permission: string,
enabled: boolean,
currentPermissions: string[]
): string[] => {
// First, remove *.* since we're modifying individual permissions
let newPermissions = currentPermissions.filter((p) => p !== "*.*");
// Parse the permission to get resource and action
const [resource, action] = permission.split(".");
if (enabled) {
// Add the requested permission
newPermissions.push(permission);
// If enabling create, update, or delete, automatically enable read permission
if (action === CrudAction.Create || action === CrudAction.Update || action === CrudAction.Delete) {
const readPermission = `${resource}.${CrudAction.Read}`;
if (!newPermissions.includes(readPermission)) {
newPermissions.push(readPermission);
}
}
} else {
// When disabling a permission, check if we need to disable related permissions
if (action === CrudAction.Read) {
// If disabling read, also disable create, update, and delete since they depend on read
const dependentActions = [CrudAction.Create, CrudAction.Update, CrudAction.Delete];
dependentActions.forEach((dependentAction) => {
const dependentPermission = `${resource}.${dependentAction}`;
newPermissions = newPermissions.filter((p) => p !== dependentPermission);
});
} else if (
action === CrudAction.Create ||
action === CrudAction.Update ||
action === CrudAction.Delete
) {
// If disabling create, update, or delete, just remove that specific permission
// Read permission remains enabled
newPermissions = newPermissions.filter((p) => p !== permission);
} else {
// For other actions (custom actions), just remove the specific permission
newPermissions = newPermissions.filter((p) => p !== permission);
}
}
// Only add *.* back if all permissions are now selected
if (hasAllPermissions(newPermissions)) {
newPermissions.push("*.*");
}
return newPermissions;
};
return {
hasAllPermissions,
getResourcePermissionLevel,
toggleResourcePermissionLevel,
toggleSinglePermission,
};
}
@@ -0,0 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
export async function revalidateTeamRoles() {
revalidatePath("/settings/organizations/roles");
}
@@ -0,0 +1,43 @@
"use client";
import { useQueryState } from "nuqs";
import { roleParsers } from "../_components/searchParams";
export function useRoleSheetState(initialOpen?: boolean) {
const [isOpen, setIsOpen] = useQueryState("role-sheet", roleParsers["role-sheet"]);
return {
isOpen,
setIsOpen,
};
}
export function useSelectedRoleState(initialRoleId?: string) {
const [selectedRoleId, setSelectedRoleId] = useQueryState("role", roleParsers.role);
return {
selectedRoleId,
setSelectedRoleId,
};
}
export function useRoleStates(initialOpen?: boolean, initialRoleId?: string) {
const { isOpen, setIsOpen } = useRoleSheetState(initialOpen);
const { selectedRoleId, setSelectedRoleId } = useSelectedRoleState(initialRoleId);
const handleSheetOpenChange = (open: boolean) => {
setIsOpen(open);
if (!open) {
setSelectedRoleId("");
}
};
return {
isOpen,
setIsOpen,
selectedRoleId,
setSelectedRoleId,
handleSheetOpenChange,
};
}
@@ -0,0 +1,5 @@
import { SkeletonLoader } from "./_components/RolesSkeletonLoader";
export default function Loading() {
return <SkeletonLoader />;
}
@@ -0,0 +1,118 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { AppFlags } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { RoleService } from "@calcom/features/pbac/services/role.service";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { CreateRoleCTA } from "./_components/CreateRoleCta";
import { RolesList } from "./_components/RolesList";
import { roleSearchParamsCache } from "./_components/searchParams";
const getCachedTeamRoles = unstable_cache(
async (teamId: number) => {
const roleService = new RoleService();
return roleService.getTeamRoles(teamId);
},
["team-roles"],
{ revalidate: 3600 }
);
const getCachedTeamFeature = unstable_cache(
async (teamId: number, feature: keyof AppFlags) => {
const featureRepo = new FeaturesRepository();
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
return res;
},
["team-feature"],
{ revalidate: 3600 }
);
const getCachedResourcePermissions = unstable_cache(
async (userId: number, teamId: number, resource: Resource) => {
const permissionService = new PermissionCheckService();
return permissionService.getResourcePermissions({ userId, teamId, resource });
},
["resource-permissions"],
{ revalidate: 3600 }
);
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("roles_and_permissions"),
(t) => t("roles_and_permissions_description"),
undefined,
undefined,
"/settings/organizations/roles"
);
const Page = async ({ searchParams }: { searchParams: Record<string, string | string[] | undefined> }) => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.org?.id || !session.user.id) {
return notFound();
}
const teamHasPBACFeature = await getCachedTeamFeature(session.user.org.id, "pbac");
if (!teamHasPBACFeature) {
return notFound();
}
roleSearchParamsCache.parse(searchParams);
const [roles, rolePermissions] = await Promise.all([
getCachedTeamRoles(session.user.org.id),
getCachedResourcePermissions(session.user.id, session.user.org.id, Resource.Role),
]);
// NOTE: this approach of fetching permssions per resource does not account for fall back roles.
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
const canCreate = roleActions[CrudAction.Create] ?? false;
const canRead = roleActions[CrudAction.Read] ?? false;
const canUpdate = roleActions[CrudAction.Update] ?? false;
const canDelete = roleActions[CrudAction.Delete] ?? false;
if (!canRead) {
return notFound();
}
// Get sheet state from server cache
const isSheetOpen = roleSearchParamsCache.get("role-sheet");
const selectedRoleId = roleSearchParamsCache.get("role");
const selectedRole = roles.find((role) => role.id === selectedRoleId);
return (
<SettingsHeader
title={t("roles_and_permissions")}
description={t("roles_and_permissions_description")}
borderInShellHeader={false}
CTA={canCreate ? <CreateRoleCTA /> : null}>
<RolesList
teamId={session.user.org.id}
roles={roles}
permissions={{
canCreate: canCreate,
canRead: canRead,
canUpdate: canUpdate,
canDelete: canDelete,
}}
initialSelectedRole={selectedRole}
initialSheetOpen={isSheetOpen}
/>
</SettingsHeader>
);
};
export default Page;
@@ -2,8 +2,8 @@ import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { AttributeRepository } from "@calcom/lib/server/repository/attribute";
import { MembershipRole } from "@calcom/prisma/enums";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
import { MembersView } from "~/members/members-view";
@@ -25,12 +25,24 @@ const getCachedAttributes = unstable_cache(
{ revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour
);
const getCachedRoles = unstable_cache(
async (orgId: number) => {
const roleManager = await RoleManagementFactory.getInstance().createRoleManager(orgId);
return await roleManager.getAllRoles(orgId);
},
undefined,
{ revalidate: 3600, tags: ["pbac.roles.list"] } // Cache for 1 hour
);
const Page = async () => {
const orgCaller = await createRouterCaller(viewerOrganizationsRouter);
const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]);
const attributes = await getCachedAttributes(org.id);
const roles = await getCachedRoles(org.id);
const facetedTeamValues = {
roles: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER],
roles,
teams,
attributes: attributes.map((attribute) => ({
id: attribute.id,
@@ -6,6 +6,7 @@ import { notFound } from "next/navigation";
import { z } from "zod";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { prisma } from "@calcom/prisma";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/video/meeting-not-started/[uid]/getServerSideProps";
@@ -22,7 +23,8 @@ export const generateMetadata = async ({ params }: ServerPageProps) => {
if (!parsed.success) {
notFound();
}
const booking = await BookingRepository.findBookingByUid({
const bookingRepo = new BookingRepository(prisma);
const booking = await bookingRepo.findBookingByUid({
bookingUid: parsed.data.uid,
});
@@ -11,6 +11,7 @@ import { HttpError } from "@calcom/lib/http-error";
import notEmpty from "@calcom/lib/notEmpty";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -29,7 +30,10 @@ async function authMiddleware() {
throw new HttpError({ statusCode: 401, message: "Not authenticated" });
}
const userWithCredentials = await UserRepository.findUserWithCredentials({ id: session.user.id });
const userRepo = new UserRepository(prisma);
const userWithCredentials = await userRepo.findUserWithCredentials({
id: session.user.id,
});
if (!userWithCredentials) {
throw new HttpError({ statusCode: 401, message: "Not authenticated" });
+4 -2
View File
@@ -8,6 +8,7 @@ import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { CardComponent } from "@lib/plain/card-components";
@@ -474,7 +475,8 @@ async function handler(request: NextRequest) {
// Validate request body
const { cardKeys, customer } = inputSchema.parse(requestBody);
const user = await UserRepository.findByEmail({ email: customer.email });
const userRepo = new UserRepository(prisma);
const user = await userRepo.findByEmail({ email: customer.email });
if (!user) {
return NextResponse.json({
@@ -502,7 +504,7 @@ async function handler(request: NextRequest) {
}
// Fetch team details including userId and team name
const teamMemberships = await UserRepository.findTeamsByUserId({ userId: user.id });
const teamMemberships = await userRepo.findTeamsByUserId({ userId: user.id });
const firstTeam = teamMemberships.teams[0] ?? null;
// Parse user metadata
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { beforeEach, describe, it, expect, vi } from "vitest";
import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
@@ -8,6 +8,11 @@ import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/for
import { queuedResponseHandler } from "../route";
vi.mock("@calcom/lib/server/repository/formResponse");
const mockRoutingFormResponseRepository = {
getQueuedFormResponseFromId: vi.fn(),
recordFormResponse: vi.fn(),
};
vi.mock("@calcom/app-store/routing-forms/lib/getSerializableForm");
vi.mock("@calcom/app-store/routing-forms/lib/getResponseToStore");
vi.mock("@calcom/app-store/routing-forms/lib/formSubmissionUtils");
@@ -43,8 +48,14 @@ const mockQueuedFormResponse = {
};
describe("queuedResponseHandler", () => {
beforeEach(() => {
vi.mocked(RoutingFormResponseRepository).mockImplementation(
() => mockRoutingFormResponseRepository as any
);
});
it("should process a queued form response", async () => {
vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(
vi.mocked(mockRoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(
mockQueuedFormResponse
);
@@ -90,7 +101,7 @@ describe("queuedResponseHandler", () => {
team: null,
} as unknown as Awaited<ReturnType<typeof onSubmissionOfFormResponse>>);
vi.mocked(RoutingFormResponseRepository.recordFormResponse).mockResolvedValue({
vi.mocked(mockRoutingFormResponseRepository.recordFormResponse).mockResolvedValue({
id: "mock-form-id",
name: "Test Form",
description: "Test Form Description",
@@ -102,7 +113,7 @@ describe("queuedResponseHandler", () => {
},
team: null,
chosenRouteId: "mock-chosen-route-id",
} as unknown as Awaited<ReturnType<typeof RoutingFormResponseRepository.recordFormResponse>>);
} as unknown as Awaited<ReturnType<typeof mockRoutingFormResponseRepository.recordFormResponse>>);
const response = await queuedResponseHandler({
queuedFormResponseId: "1",
@@ -115,7 +126,7 @@ describe("queuedResponseHandler", () => {
});
it("if no queued form response is found, should return early", async () => {
vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(null);
vi.mocked(mockRoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(null);
const response = await queuedResponseHandler({
queuedFormResponseId: "1",
params: {},
@@ -127,7 +138,7 @@ describe("queuedResponseHandler", () => {
});
it("should throw if form has no fields", async () => {
vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(
vi.mocked(mockRoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(
mockQueuedFormResponse
);
vi.mocked(getSerializableForm).mockResolvedValue({
@@ -8,6 +8,7 @@ import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSeri
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
import prisma from "@calcom/prisma";
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
@@ -23,10 +24,10 @@ export const queuedResponseHandler = async ({
queuedFormResponseId: string;
params: Record<string, string | string[]>;
}) => {
const formResponseRepo = new RoutingFormResponseRepository(prisma);
// Get the queued response
const queuedFormResponse = await RoutingFormResponseRepository.getQueuedFormResponseFromId(
queuedFormResponseId
);
const queuedFormResponse = await formResponseRepo.getQueuedFormResponseFromId(queuedFormResponseId);
if (!queuedFormResponse) {
return {
@@ -48,7 +49,7 @@ export const queuedResponseHandler = async ({
fieldsResponses: params,
});
const formResponse = await RoutingFormResponseRepository.recordFormResponse({
const formResponse = await formResponseRepo.recordFormResponse({
formId: queuedFormResponse.formId,
queuedFormResponseId: queuedFormResponse.id,
// We record new response here as that might be different from the queued response depending on if the user changed something in b/w before clicking CTA and that something wasn't prerendered
@@ -199,7 +199,7 @@ export default function CancelBooking(props: Props) {
data-testid="confirm_cancel"
disabled={
props.isHost &&
(!cancellationReason || (props.internalNotePresets.length > 0 && !internalNote?.id))
(!cancellationReason?.trim() || (props.internalNotePresets.length > 0 && !internalNote?.id))
}
onClick={async () => {
setLoading(true);
@@ -440,7 +440,11 @@ describe("RerouteDialog", () => {
render(
<SessionProvider session={mockSession}>
<TooltipProvider>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
<RerouteDialog
isOpenDialog={true}
setIsOpenDialog={mockSetIsOpenDialog}
booking={mockBooking}
/>
</TooltipProvider>
</SessionProvider>
);
@@ -483,7 +487,11 @@ describe("RerouteDialog", () => {
render(
<SessionProvider session={mockSession}>
<TooltipProvider>
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
<RerouteDialog
isOpenDialog={true}
setIsOpenDialog={mockSetIsOpenDialog}
booking={mockBooking}
/>
</TooltipProvider>
</SessionProvider>
);
+17 -2
View File
@@ -9,6 +9,7 @@ import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { emailRegex } from "@calcom/lib/emailSchema";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { EmailField, Label, TextField, PasswordField } from "@calcom/ui/components/form";
@@ -34,7 +35,12 @@ export const AdminUserContainer = (props: React.ComponentProps<typeof AdminUser>
return <AdminUser {...props} />;
};
export const AdminUser = (props: { onSubmit: () => void; onError: () => void; onSuccess: () => void }) => {
export const AdminUser = (props: {
onSubmit: () => void;
onError: () => void;
onSuccess: () => void;
nav: { onNext: () => void; onPrev: () => void };
}) => {
const { t } = useLocale();
const formSchema = z.object({
@@ -118,7 +124,7 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
<TextField
addOnLeading={
!longWebsiteUrl && (
<span className="text-subtle inline-flex items-center rounded-none px-3 text-sm">
<span className="text-subtle inline-flex items-center rounded-none text-sm">
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
</span>
)
@@ -187,6 +193,15 @@ export const AdminUser = (props: { onSubmit: () => void; onError: () => void; on
)}
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="submit"
color="primary"
loading={formMethods.formState.isSubmitting}
disabled={!formMethods.formState.isValid || formMethods.formState.isSubmitting}>
{t("next")}
</Button>
</div>
</form>
</FormProvider>
);
@@ -0,0 +1,255 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import * as RadioGroup from "@radix-ui/react-radio-group";
import classNames from "classnames";
import Link from "next/link";
import { useCallback, useState } from "react";
import { Controller, FormProvider, useForm, useFormState } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
type LicenseSelectionFormValues = {
licenseKey: string;
signatureToken?: string;
};
type LicenseOption = "FREE" | "EXISTING";
const LicenseSelection = (
props: {
value: LicenseOption;
onChange: (value: LicenseOption) => void;
onSubmit: (value: LicenseSelectionFormValues) => void;
onSuccess?: (
data: RouterOutputs["viewer"]["deploymentSetup"]["update"],
variables: RouterInputs["viewer"]["deploymentSetup"]["update"]
) => void;
onPrevStep: () => void;
onNextStep: () => void;
} & Omit<JSX.IntrinsicElements["form"], "onSubmit" | "onChange">
) => {
const {
value: initialValue = "FREE",
onChange,
onSubmit,
onSuccess,
onPrevStep,
onNextStep,
...rest
} = props;
const [value, setValue] = useState<LicenseOption>(initialValue);
const { t } = useLocale();
const mutation = trpc.viewer.deploymentSetup.update.useMutation({
onSuccess,
});
const [licenseKeyInput, setLicenseKeyInput] = useState("");
const [licenseTouched, setLicenseTouched] = useState(false);
// Use TRPC query for validation
const { data: licenseValidation, isLoading: checkLicenseLoading } =
trpc.viewer.deploymentSetup.validateLicense.useQuery(
{ licenseKey: licenseKeyInput },
{
enabled: licenseTouched && licenseKeyInput.trim().length > 0,
}
);
const schemaLicenseKey = useCallback(
() =>
z.object({
licenseKey: z
.string()
.min(1, t("license_key_required"))
.refine(() => !licenseTouched || (licenseValidation ? licenseValidation.valid : true), {
message: licenseValidation?.message || t("invalid_license_key"),
}),
signatureToken: z.string().optional(),
}),
[licenseValidation, licenseTouched, t]
);
const formMethods = useForm<LicenseSelectionFormValues>({
defaultValues: {
licenseKey: "",
signatureToken: "",
},
resolver: zodResolver(schemaLicenseKey()),
});
const handleSubmit = formMethods.handleSubmit((values) => {
onSubmit(values);
if (value === "EXISTING" && values.licenseKey) {
mutation.mutate(values);
}
});
const { isDirty, errors } = useFormState(formMethods);
const handleRadioChange = (newValue: LicenseOption) => {
setValue(newValue);
onChange(newValue);
};
return (
<form
{...rest}
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
if (value === "FREE") {
onSubmit({ licenseKey: "", signatureToken: "" });
} else {
handleSubmit();
}
}}>
<RadioGroup.Root
defaultValue={initialValue}
value={value}
aria-label={t("choose_a_license")}
className="grid grid-rows-2 gap-4 md:grid-cols-2 md:grid-rows-1"
onValueChange={(value) => handleRadioChange(value as LicenseOption)}>
<RadioGroup.Item value="FREE" className="h-full">
<div
className={classNames(
"bg-default h-full cursor-pointer space-y-2 rounded-md border p-4 hover:border-black",
value === "FREE" && "ring-2 ring-black"
)}>
<h2 className="font-cal text-emphasis text-xl">{t("agplv3_license")}</h2>
<p className="font-medium text-green-800">{t("free_license_fee")}</p>
<p className="text-subtle">{t("forever_open_and_free")}</p>
<ul className="text-subtle ml-4 list-disc text-left text-xs">
<li>{t("required_to_keep_your_code_open_source")}</li>
<li>{t("cannot_repackage_and_resell")}</li>
<li>{t("no_enterprise_features")}</li>
</ul>
</div>
</RadioGroup.Item>
<RadioGroup.Item value="EXISTING" className="h-full">
<div
className={classNames(
"bg-default h-full cursor-pointer space-y-2 rounded-md border p-4 hover:border-black",
value === "EXISTING" && "ring-2 ring-black"
)}>
<h2 className="font-cal text-emphasis text-xl">{t("enter_license_key")}</h2>
<p className="font-medium text-green-800">{t("enter_existing_license")}</p>
<p className="text-subtle">{t("enter_your_license_key")}</p>
<p className="text-subtle text-xs">
{t("need_a_license")}{" "}
<Link
href="https://go.cal.com/self-hosted"
target="_blank"
rel="noreferrer noopener"
className="text-blue-600 hover:underline">
{t("purchase_license")}
</Link>
</p>
</div>
</RadioGroup.Item>
</RadioGroup.Root>
{value === "EXISTING" && (
<FormProvider {...formMethods}>
<div className="bg-muted space-y-4 rounded-md px-4 py-3">
<div className="space-y-4">
<div>
<Controller
name="licenseKey"
control={formMethods.control}
render={({ field: { onBlur, onChange, value } }) => (
<TextField
name="licenseKey"
label={t("license_key")}
className={classNames(
"group-hover:border-emphasis mb-0",
(checkLicenseLoading || (errors.licenseKey === undefined && isDirty)) && "border-r-0"
)}
placeholder="cal_live_XXXXXXXXXXX"
value={licenseKeyInput}
addOnClassname={classNames(
"hover:border-default",
errors.licenseKey === undefined && isDirty && "group-hover:border-emphasis"
)}
addOnSuffix={
checkLicenseLoading ? (
<Icon name="loader" className="h-5 w-5 animate-spin" />
) : licenseValidation?.valid && licenseTouched ? (
<Icon name="check" className="h-5 w-5 text-green-700" />
) : undefined
}
color={errors.licenseKey ? "warn" : ""}
onBlur={(e) => {
setLicenseTouched(true);
onBlur();
}}
onChange={(e) => {
setLicenseKeyInput(e.target.value);
onChange(e.target.value);
}}
/>
)}
/>
{errors.licenseKey && (
<p className="mt-1 text-sm text-red-600">{errors.licenseKey.message}</p>
)}
</div>
<div>
<Controller
name="signatureToken"
control={formMethods.control}
render={({ field: { onBlur, onChange, value } }) => (
<TextField
name="signatureToken"
label={t("signature_token_optional")}
placeholder="cal_sk_XXXXXXXXXXX"
value={value || ""}
onBlur={onBlur}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
formMethods.setValue("signatureToken", e.target.value);
}}
/>
)}
/>
<p className="text-subtle mt-1 text-sm">{t("signature_token_description")}</p>
</div>
</div>
</div>
</FormProvider>
)}
<div className="flex justify-end gap-2">
<Button type="button" color="secondary" onClick={onPrevStep}>
{t("prev_step")}
</Button>
{value === "EXISTING" ? (
<Button
type="submit"
color="primary"
loading={checkLicenseLoading || mutation.isPending}
disabled={
value === "EXISTING" &&
(!formMethods.formState.isValid || checkLicenseLoading || mutation.isPending)
}>
{t("save_license_key")}
</Button>
) : (
<Button color="primary" onClick={onNextStep} type="button">
{t("next_step")}
</Button>
)}
</div>
</form>
);
};
export default LicenseSelection;
@@ -18,7 +18,8 @@ import { STEPS } from "~/apps/installation/[[...step]]/constants";
import type { OnboardingPageProps, TEventTypeGroup } from "~/apps/installation/[[...step]]/step-view";
const getUser = async (userId: number) => {
const userAdminTeams = await UserRepository.getUserAdminTeams(userId);
const userRepo = new UserRepository(prisma);
const userAdminTeams = await userRepo.getUserAdminTeams({ userId });
if (!userAdminTeams?.id) {
return null;
@@ -101,7 +101,8 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
name = profileUsername || username;
const [user] = await UserRepository.findUsersByUsername({
const userRepo = new UserRepository(prisma);
const [user] = await userRepo.findUsersByUsername({
usernameList: [name],
orgSlug: org,
});
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req } = context;
@@ -12,7 +13,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return { redirect: { permanent: false, destination: "/auth/login" } };
}
const user = await UserRepository.findUserTeams({
const userRepo = new UserRepository(prisma);
const user = await userRepo.findUserTeams({
id: session.user.id,
});
@@ -101,8 +101,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
const userRepo = new UserRepository(prisma);
const enrichedBookingUser = booking.user
? await UserRepository.enrichUserWithItsProfile({ user: booking.user })
? await userRepo.enrichUserWithItsProfile({ user: booking.user })
: null;
const eventUrl = await buildEventUrlFromBooking({
@@ -94,7 +94,8 @@ const checkIfUserIsHost = async ({
return booking.user?.id === sessionUserId;
}
const eventType = await EventTypeRepository.findByIdWithUserAccess({
const eventTypeRepo = new EventTypeRepository(prisma);
const eventType = await eventTypeRepo.findByIdWithUserAccess({
id: booking.eventTypeId,
userId: sessionUserId,
});
@@ -106,7 +107,8 @@ const checkIfUserIsHost = async ({
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { req } = context;
const booking = await BookingRepository.findBookingForMeetingPage({
const bookingRepo = new BookingRepository(prisma);
const booking = await bookingRepo.findBookingForMeetingPage({
bookingUid: context.query.uid as string,
});
@@ -140,9 +142,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
})
: false;
const userRepo = new UserRepository(prisma);
const profile = booking.user
? (
await UserRepository.enrichUserWithItsProfile({
await userRepo.enrichUserWithItsProfile({
user: booking.user,
})
).profile

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