Compare commits

..
Author SHA1 Message Date
github-actions[bot]andLauris Skraucis 18059e50fa chore: version packages (#22813)
* chore: version packages

* Update CHANGELOG.md

* Update package.json

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2025-08-04 10:41:55 +02:00
Carina WollendorferandCarinaWolli 852eb346ed fix: minor UI improvements for end of month design (#22837)
* minor UI improvements

* revert classname changes

* remove not needed code

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-08-04 03:02:42 +00:00
b7e11dc112 chore: Add logging to handleNewBooking (#22868)
* Add logging

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update logger config, fix Create booking log

* Added oooExcludedDateRanges + type error fix

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-02 17:15:30 +01:00
Syed Ali Shahbaz 35a373e70a docs: Create add-new-booking-charts.mdx (#22865)
* Create add-charts.mdx

* Update mint.json

* Rename add-charts.mdx to add-new-booking-charts.mdx

* Update mint.json
2025-08-01 17:20:27 +00:00
Alex van Andel 02c0b721b9 Revert "fix: refactor i18n loadTranslations and set timeout to 3s (#22633)"
This reverts commit 6ff20b98f4.
2025-08-01 16:42:31 +01:00
Ashish manhas 29349845f8 fix:safe HTML rendering for radio label (#22645) 2025-08-01 15:56:05 +01:00
Eunjae Lee 28cb2cff64 chore: upgrade TypeScript to v5.9 (#22861) 2025-08-01 15:16:11 +01:00
Anik Dhabal Babu 9cb2eb3b8c fix: store attendee phone number on instant booking (#22823)
* fix: flaky e2e

* fix: not storing attendee phone number
2025-08-01 13:34:57 +00:00
Benny Joo 6ff20b98f4 fix: refactor i18n loadTranslations and set timeout to 3s (#22633)
* refactor

* use abort controller

* address comment

* fix tests

* fix time

* fix tests
2025-08-01 22:30:28 +09:00
Hariom Balhara aa618dbaa2 fix: Missing bookingId in BOOKING_CANCELLED webhook payload (#22713)
* send bookingId in BOOKING_CANCELLED through requestReschedule

* fix: make customInputs nullable in BookingWebhookFactory

* fix: make customInputs nullable in BookingWebhookFactory
2025-08-01 06:04:53 +00:00
emrysal 6e3de84067 chore: release v5.5.10
Post release / release (push) Canceled after 0s
2025-08-01 01:08:17 +00:00
Alex van Andel c6b19200a9 chore: Add node_modules/dist to ignore + convert config to .ts (#22852) 2025-08-01 00:02:46 +00:00
Joe Au-Yeung 91932212e3 fix: Cal Video sidebar not showing meeting time in timezone (#22821)
* Pass params as object to `formatToLocalizedTime`

* Refactor videos-single-view

* Refactor bookings-single-view
2025-07-31 16:34:02 -04:00
f81bb92beb fix: increment iCalSequence when changing booking location (#22847)
* fix: increment iCalSequence when changing booking location

- Add iCalSequence parameter to buildCalEventFromBooking function
- Update editLocation handler to increment sequence by 1 in CalendarEvent
- Update database with incremented iCalSequence when location changes
- Update buildCalEventFromBooking test to include new fields
- Ensures ICS files properly update existing calendar events instead of creating duplicates

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* `BookingRepository.updateLocationById` to accept responses and iCalSequence

* `editLocation.handler` use `BookingRepository`

* Update iCalSequence

* Use iCalSequence from booking param

* Add iCalUID to evt object

* Clean up

* Fix test

* Fix test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-31 20:58:48 +01:00
1d1a242a72 refactor: convert getShouldServeCache to CacheService with dependency injection (#22814)
* refactor: convert getShouldServeCache to CacheService with dependency injection

- Create CacheService class following AvailableSlotsService DI pattern
- Add FeaturesRepository and CacheService to DI tokens and modules
- Create cache container with proper dependency injection setup
- Update handleNewBooking.ts and slots/util.ts to use new service
- Maintain backward compatibility with error-throwing wrapper function
- Follow established service patterns for clean architecture

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

* feat: inject CacheService into AvailableSlotsService via dependency injection

- Add cacheService to IAvailableSlotsService interface
- Update available-slots container to load cache modules
- Update available-slots module to inject CacheService dependency
- Replace direct getShouldServeCache call with injected service method
- Add CacheService import to util.ts for proper typing

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

* chore: DI api v2 cache service

* refactor: convert FeaturesRepository to use factory pattern in DI

- Change from constructor injection to factory pattern to avoid PRISMA_CLIENT binding issues in tests
- FeaturesRepository now uses default prisma instance instead of DI injection
- Resolves test failures while maintaining DI container compatibility
- Tests reduced from 123+ failures to only 5 unrelated failures

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

* revert: use direct FeaturesRepository instantiation in most usage points

- Revert getFeaturesRepository() calls back to new FeaturesRepository()
- Tests require direct instantiation for mocking compatibility
- Keep DI container for specific use cases that need dependency injection
- Resolves test failures while maintaining both DI and direct usage patterns

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

* fix: resolve FeaturesRepository DI container issues

- Update cache module to use factory pattern with proper ICacheService interface
- Remove featuresModule loading from cache and available-slots containers
- Use direct FeaturesRepository instantiation via getFeaturesRepository()
- Resolves 'No binding found for key: Symbol(FeaturesRepository)' errors
- Reduces test failures from 125 to 7 (remaining failures appear unrelated)

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

* fix: update all FeaturesRepository instantiations to include prisma parameter

- Add prisma parameter to all new FeaturesRepository() calls across the codebase
- Update API v2 services to match main repo interfaces
- Fix PrismaFeaturesRepository to implement IFeaturesRepository directly
- Update CacheService in API v2 to expose required dependencies and getShouldServeCache
- Implement CheckBookingLimitsService in API v2 with proper interface
- Resolves type assignment errors between API v2 and main repo implementations

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

* fix: add prisma parameter to remaining FeaturesRepository instantiations in apps/web/lib

- Update getServerSideProps files to pass prisma parameter to FeaturesRepository
- Ensures all FeaturesRepository instantiations follow the new constructor pattern
- Completes the refactoring to use direct instantiation with prisma parameter

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

* refactor clean and fix devin issues

* chore: bump platform libs

* chore: bump platform libs

* chore: bump platform libs

* chore: bump platform libs

* fix: missing di

* fix workflow test

* fix workflow test

* fix integration test

---------

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-31 20:39:27 +03:00
Syed Ali Shahbaz ad4af64036 fix: return rescheduledByEmail in V2 bookings (#22804)
* Update output.service.ts

* Update output.service.ts

* Update output.service.ts

* Update user-bookings.e2e-spec.ts

* fix type err

* Update failing test
2025-07-31 16:27:08 +01:00
+5 2cc7827b0e test: add comprehensive unit tests for handleInstantMeeting (#22820)
* fix: resolve username constraint violation in removeMember

- Update username to - format when removing users from organizations
- Fix unique constraint violation on (username, organizationId) when organizationId is null
- Add test case to verify username format change and successful removal
- Fix skipped test by adding proper imports and removing describe.skip

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

* test: update removeMember test to verify constraint violation scenario

- Create two users with same username (one with null orgId, one with orgId)
- Verify removing org user updates username without unique constraint error
- Test ensures username gets updated to - format

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

* test: add comprehensive unit tests for handleInstantMeeting

- Revert previous removeMember changes
- Add full test coverage for instant meeting functionality
- Test team validation, booking creation, token generation
- Test webhook triggers and browser notifications
- Mock external dependencies for isolated unit tests
- Translation issue to be addressed in future iteration

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

* update

* Update handleInstantMeeting.test.ts

* fix: redir parameter for connect atoms (#22815)

* encode redirect url to make sure it has all parameters

* add changesets

* feat: Support an array response for a field when used as `Value of Field` (#22740)

* Passing tests and fixed

* self review addressed adn more tests

* fix: flaky e2e (#22819)

* fix: merge working hours when adjacent (#21912)

* fix: Adjacency issue when working hours connect over multiple days

* Add tests to validate the new merging of day end logic

* Update to correct annotation.

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

* Implement subsequent date ranges for date overrides also

* The map needs to be updated on successful resolve.

* test: add failing test for overlapping ranges with same end time

Demonstrates bug where overlapping working hour ranges (6:00-10:00 and 8:00-10:00)
lose the earlier portion (6:00-8:00), showing only 8:00 and 9:00 slots
instead of all 4 slots (6:00, 7:00, 8:00, 9:00).

Related to Carina's comment on PR #21912.

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

* fix: properly merge overlapping ranges with same end time

Fixes bug where overlapping working hour ranges with the same end time
(e.g., 6:00-10:00 and 8:00-10:00) would lose the earlier portion of the
first range. The merging logic now correctly preserves the earliest
start time when ranges overlap and share the same end time.

This ensures all expected time slots are available (6:00, 7:00, 8:00, 9:00)
instead of losing the earlier slots (6:00, 7:00).

Resolves the issue identified in Carina's comment on PR #21912.

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

* perf: optimize overlapping range detection from O(n²) to O(n)

Replaces Object.keys().find() with Map-based lookup for ranges with same end time.
This optimization handles 2000+ date ranges efficiently, reducing complexity from
4M operations to linear time while maintaining the same merging behavior.

Performance improvement for high-volume event types with many availability ranges.

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

---------

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

* feat: Improving Booking Visibility at Month-End (#22770)

* remove first weeks and add last

* fix added last week

* prefetch availability of next month

* don't switch month

* On hover show month

* only show new UI in monthly view

* show month tooldtip only when needed

* show month on first day of month

* remove isFirstDayOfNextMonth

* fix prefetching next month

* fix datePicker tests

* preventMonthSwitching in monthly view

* add tests

* code clean up

* code clean up

* code clena up for ooo days

* push first day of month

* remove bg color for the month badge

* fix text colour

* remove not needed

* use object param

* revert: use object param

* use object param

* fix DatePicker tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>

* chore: Refactor logs (#22824)

* Refactor logs

* Add specific info to log

* fix: Errors in org onboarding in some edge cases (#22711)

* Automatically enable migration of a team that conflicts with Orgs slug

* Allow changing the orgs slug and name if user goes back and changes it

* avoid crashing on some scenarios

* Revert "Allow changing the orgs slug and name if user goes back and changes it"

This reverts commit f8872b0116868d59757ef911c4940df79f93c6e0.

* fix: handle unpublished teams gracefully in org migration

- Change 'No oldSlug for team' from error to warning for unpublished teams
- Keep 'No slug for team' as error since org onboarding ensures teams have names
- Add test for migrating unpublished teams without oldSlug
- Add clarifying comments about when each condition can occur

* feat: enable PBAC checking on organization settings page (#22467)

* refactor: convert checkBookingLimits to class service with dependency injection (#22768)

* refactor: convert checkBookingLimits to class service with dependency injection

- Create CheckBookingLimitsService class following AvailableSlotsService pattern
- Add countBookingsByEventTypeAndDateRange method to BookingRepository
- Move direct prisma calls from service to repository layer
- Implement dependency injection with proper DI tokens and modules
- Update all usage points to use the new service through DI
- Maintain backward compatibility with error-throwing wrapper functions
- Update tests to use the new service pattern
- Resolve TODO comment in AvailableSlotsService for DI integration

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

* chore: DI CheckBookingLimitsService in v2 slots service

* chore: bump libraries

* chore: create getCheckBookingLimitsService

* refactor: convert checkBookingAndDurationLimits to service class with DI

- Create CheckBookingAndDurationLimitsService class following DI pattern
- Add DI tokens and module for the new service
- Update booking-limits container to provide the new service
- Refactor handleNewBooking.ts to use service through DI
- Maintain backward compatibility with deprecated function export
- Preserve all existing functionality while improving code organization

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

* chore: CheckBookingAndDurationLimitsService

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

* fix: Return empty available days if error querying calendar (#22828)

* Return a busy block placeholder if calendar throws an error

* Refactor `getCalendarsEvents` to return an object with a success prop

* Throw error in `getBusyTimes` if failed to fetch calendar availability

* Return empty available days if error getting busy times

* yeet.

* Type fix

* Fix type error in getLuckyUsers

* Type fixes

* Type fix

* Type fix

* Fix test

* Fix test mocks

* Refactor calendars.service to use new calendarBusyTimesQuery

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>

* chore: release v5.5.9

* include mobile layout (#22836)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>

* test: fix handleInstantMeeting test with setupAndTeardown and proper mocking

- Add setupAndTeardown() for proper test environment setup
- Use mockNoTranslations() to fix translation function mocking
- Simplify NextApiRequest mock to resolve TypeScript errors
- Both test cases now pass successfully

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

* fix typo

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@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-31 10:46:53 +00:00
ef6618743d feat: add optional startTime prop to BookerPlatformWrapper (#22731)
* feat: add optional startTime prop to BookerPlatformWrapper

- Add startTime prop to BookerPlatformWrapperAtomProps interface
- Allow users to override calculated startTime from useTimesForSchedule
- Maintain backward compatibility when prop is not provided
- Add JSDoc documentation for proper usage

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

* feat: add custom start time support to Booker component with date object input

* feat: update documentation and added changelog

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: somay@cal.com <somay@cal.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2025-07-31 10:25:11 +00:00
Carina WollendorferandCarinaWolli 3601a9adf1 include mobile layout (#22836)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-07-31 09:27:56 +00:00
emrysal 0cc2367556 chore: release v5.5.9
Post release / release (push) Canceled after 0s
2025-07-31 01:16:31 +00:00
Joe Au-YeungandAlex van Andel f7b201b0b5 fix: Return empty available days if error querying calendar (#22828)
* Return a busy block placeholder if calendar throws an error

* Refactor `getCalendarsEvents` to return an object with a success prop

* Throw error in `getBusyTimes` if failed to fetch calendar availability

* Return empty available days if error getting busy times

* yeet.

* Type fix

* Fix type error in getLuckyUsers

* Type fixes

* Type fix

* Type fix

* Fix test

* Fix test mocks

* Refactor calendars.service to use new calendarBusyTimesQuery

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-07-31 02:09:09 +01:00
82063cc9a1 refactor: convert checkBookingLimits to class service with dependency injection (#22768)
* refactor: convert checkBookingLimits to class service with dependency injection

- Create CheckBookingLimitsService class following AvailableSlotsService pattern
- Add countBookingsByEventTypeAndDateRange method to BookingRepository
- Move direct prisma calls from service to repository layer
- Implement dependency injection with proper DI tokens and modules
- Update all usage points to use the new service through DI
- Maintain backward compatibility with error-throwing wrapper functions
- Update tests to use the new service pattern
- Resolve TODO comment in AvailableSlotsService for DI integration

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

* chore: DI CheckBookingLimitsService in v2 slots service

* chore: bump libraries

* chore: create getCheckBookingLimitsService

* refactor: convert checkBookingAndDurationLimits to service class with DI

- Create CheckBookingAndDurationLimitsService class following DI pattern
- Add DI tokens and module for the new service
- Update booking-limits container to provide the new service
- Refactor handleNewBooking.ts to use service through DI
- Maintain backward compatibility with deprecated function export
- Preserve all existing functionality while improving code organization

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

* chore: CheckBookingAndDurationLimitsService

* 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-31 02:03:18 +01:00
sean-brydon 4b2f89d6e8 feat: enable PBAC checking on organization settings page (#22467) 2025-07-31 00:56:25 +00:00
Hariom Balhara 5360cb511d fix: Errors in org onboarding in some edge cases (#22711)
* Automatically enable migration of a team that conflicts with Orgs slug

* Allow changing the orgs slug and name if user goes back and changes it

* avoid crashing on some scenarios

* Revert "Allow changing the orgs slug and name if user goes back and changes it"

This reverts commit f8872b0116868d59757ef911c4940df79f93c6e0.

* fix: handle unpublished teams gracefully in org migration

- Change 'No oldSlug for team' from error to warning for unpublished teams
- Keep 'No slug for team' as error since org onboarding ensures teams have names
- Add test for migrating unpublished teams without oldSlug
- Add clarifying comments about when each condition can occur
2025-07-31 01:53:41 +01:00
Joe Au-Yeung 704bbf2757 chore: Refactor logs (#22824)
* Refactor logs

* Add specific info to log
2025-07-31 01:47:38 +01:00
7fef683abf feat: Improving Booking Visibility at Month-End (#22770)
* remove first weeks and add last

* fix added last week

* prefetch availability of next month

* don't switch month

* On hover show month

* only show new UI in monthly view

* show month tooldtip only when needed

* show month on first day of month

* remove isFirstDayOfNextMonth

* fix prefetching next month

* fix datePicker tests

* preventMonthSwitching in monthly view

* add tests

* code clean up

* code clean up

* code clena up for ooo days

* push first day of month

* remove bg color for the month badge

* fix text colour

* remove not needed

* use object param

* revert: use object param

* use object param

* fix DatePicker tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-07-30 16:20:16 +00:00
63df3d9c14 fix: merge working hours when adjacent (#21912)
* fix: Adjacency issue when working hours connect over multiple days

* Add tests to validate the new merging of day end logic

* Update to correct annotation.

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

* Implement subsequent date ranges for date overrides also

* The map needs to be updated on successful resolve.

* test: add failing test for overlapping ranges with same end time

Demonstrates bug where overlapping working hour ranges (6:00-10:00 and 8:00-10:00)
lose the earlier portion (6:00-8:00), showing only 8:00 and 9:00 slots
instead of all 4 slots (6:00, 7:00, 8:00, 9:00).

Related to Carina's comment on PR #21912.

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

* fix: properly merge overlapping ranges with same end time

Fixes bug where overlapping working hour ranges with the same end time
(e.g., 6:00-10:00 and 8:00-10:00) would lose the earlier portion of the
first range. The merging logic now correctly preserves the earliest
start time when ranges overlap and share the same end time.

This ensures all expected time slots are available (6:00, 7:00, 8:00, 9:00)
instead of losing the earlier slots (6:00, 7:00).

Resolves the issue identified in Carina's comment on PR #21912.

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

* perf: optimize overlapping range detection from O(n²) to O(n)

Replaces Object.keys().find() with Map-based lookup for ranges with same end time.
This optimization handles 2000+ date ranges efficiently, reducing complexity from
4M operations to linear time while maintaining the same merging behavior.

Performance improvement for high-volume event types with many availability ranges.

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

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-30 16:15:40 +00:00
Anik Dhabal Babu 32d2fd51e5 fix: flaky e2e (#22819) 2025-07-30 15:14:16 +00:00
Hariom Balhara 016a966057 feat: Support an array response for a field when used as Value of Field (#22740)
* Passing tests and fixed

* self review addressed adn more tests
2025-07-30 15:07:16 +00:00
Rajiv Sahal dc967cffbe fix: redir parameter for connect atoms (#22815)
* encode redirect url to make sure it has all parameters

* add changesets
2025-07-30 15:00:54 +00:00
4489cb8fde refactor: rename repository files to include Repository suffix (#22717)
* refactor: rename repository files to include Repository suffix

- Rename attribute.ts -> attributeRepository.ts
- Rename attributeOption.ts -> attributeOptionRepository.ts
- Rename attributeToUser.ts -> attributeToUserRepository.ts
- Update all import statements throughout codebase

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

* refactor: rename repository files and classes with Prisma prefix

- Rename attribute.ts -> PrismaAttributeRepository.ts
- Rename attributeOption.ts -> PrismaAttributeOptionRepository.ts
- Rename attributeToUser.ts -> PrismaAttributeToUserRepository.ts
- Update class names to PrismaAttributeRepository, PrismaAttributeOptionRepository, PrismaAttributeToUserRepository
- Update all import statements and references throughout codebase

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

* fix: update missed AttributeRepository import to PrismaAttributeRepository in teams members page

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-07-30 14:33:25 +02:00
Rajiv Sahal babd514c64 fix: date overrides for availability settings atom (#22701)
* only make api call once access token is verified

* add date fns tz

* use date-fns to format ranges for platform

* update yarn.lock

* fix merge conflicts

* fix updating overrides for atoms

* add changesets
2025-07-30 14:28:37 +03:00
Lauris Skraucis 2e5cc11f29 refactor: remove atoms dev from package.json (#22811) 2025-07-30 11:17:35 +00:00
Eunjae LeeandDevin AI 84bc330fa8 feat: rename repository files to include Repository suffix (#22716)
* feat: rename repository files to include Repository suffix

- Rename apiKey.ts to apiKeyRepository.ts
- Rename assignmentReason.ts to assignmentReasonRepository.ts
- Update all import statements to use new file names

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

* feat: rename repository files to PrismaRepository with Prisma prefix

- Rename apiKeyRepository.ts to PrismaApiKeyRepository.ts
- Rename assignmentReasonRepository.ts to PrismaAssignmentReasonRepository.ts
- Update class names to PrismaApiKeyRepository and PrismaAssignmentReasonRepository
- Update all import statements and usages across 3 files

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-30 13:17:18 +02:00
Eunjae LeeandDevin AI 4cb7e4a91a refactor: rename app.ts to appRepository.ts for better naming consistency (#22693)
* refactor: rename app.ts to appRepository.ts for better naming consistency

- Renamed packages/lib/server/repository/app.ts to appRepository.ts
- Updated import statement in deleteCredential.test.ts
- No functional changes, only file rename and import path update

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

* rename

* update usages

* add missing import

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-30 13:17:13 +02:00
Morgan d1bd05a791 chore: hash rate limit trackers api v2 (#22767) 2025-07-30 13:35:12 +03:00
Rajiv SahalandDevanshu Sharma 96a6350143 skip last name check when creating a booking (#22794)
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-07-30 14:37:24 +05:30
Sahitya Chandra c8bea5b143 fix: center-align timezone and edit availability button on mobile layout (#22760)
* fix: center-align timezone andavailability button on mobile layout

* chore

* chore
2025-07-30 10:17:41 +05:30
Anirban Singha 1cdd8ebc53 fix: alignment of time unit select box (#22800) 2025-07-30 10:16:11 +05:30
d78ed21a9f feat: add race condition test with dynamic date selection (#22533)
* feat: add race condition test with dynamic date selection

- Recreate race condition test from PR #22401 for calendar-cache-serve booking flow
- Replace hardcoded TEST_DATE with dynamic first available slot selection
- Use getDynamicBookingDate function to select first available day dynamically
- Prevent test flakiness by avoiding hardcoded tomorrow dates
- Test validates concurrent booking prevention and cache functionality

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

* fix: replace dynamic date selection with fixed future date for efficiency

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

* Revert "fix: replace dynamic date selection with fixed future date for efficiency"

This reverts commit b4b7d358de719ec8fb495352180eea774a5d76c1.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-30 10:13:26 +05:30
Joe Au-Yeung 4e92b6edeb chore: Log availability snapshot when booking is created (#22782)
* Add userAvailability type

* Add user availability to return value of getUsersAvailability

* Log availabilitySnapshot when booking is created

* Type fix
2025-07-29 12:10:21 -04:00
7b2f811bfe feat: BTCPay Server App (#21197)
* app initialization

* btcpay-calcom payment

* include logo and images

* resolve comments

* include USD and webhook cleaning

* currency display

* fix type error

* payment service create error

* type error fix

* icon update

* bot feedback update

* Remove console

* Remove currency suffix in price

* fix coderRabbit comment

* resolve extra comments

* Use repositories and declarative installation ocode for app

* use PrismaBookingPaymentRepository as well as fix UI view

* Avoid fetching booking just for title which is already passed to create fn in handlePayment

* fix type issues

* return 200 if payment is already processed

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2025-07-29 16:08:19 +00:00
Sahitya Chandra 7b6d5d6de3 fix: improve error messages for PayPal credential validation (#22786)
* fix: improve error messages for PayPal credential validation

* chore

* chore
2025-07-29 15:43:10 +00:00
Anik Dhabal Babu 12b418b532 fix: fix type error failing in ci (#22793) 2025-07-29 15:17:18 +00:00
Eunjae Lee 3e299f9511 refactor: update charts on /insights to use InsightsBookingService (#22744)
Post release / release (push) Canceled after 0s
* refactor: update PopuplarEvents chart to use InsightsBookingService

* use buildHashMapForUsers

* refactor least booked

* refactor charts

* combine methods

* refactor booking kpi cards

* remove unused code

* fix booking kpi cards

* restore code that was removed accidentally

* Revert "restore code that was removed accidentally"

This reverts commit 0c0b444b02498b94fb29ef470da065bad3ad7ece.

* Revert "fix booking kpi cards"

This reverts commit b1e8e9afbd3632a5239495566551ae12bd926b66.

* refactor booking kpi cards

* revert method and remove unused code

* fix previous period

* fix metrics

* clean up trpc call params

* clean up Download.tsx

* split utils

* restructure folders for components

* clean up imports

* rename TotalUserFeedbackTable to UserStatsTable

* add guide

* use client

* add curly braces to case

* fix tests
2025-07-29 12:37:18 +02:00
emrysal 1b9691ddaf chore: release v5.5.8 2025-07-29 10:30:09 +00:00
Anik Dhabal Babu a11ea3dd1e feat: Ability to set a locked timezone for event type (#22531)
* feat: ability to set locked timezone

* update

* Update update-event-type.input.ts

* Update get-event-type-public.output.ts

* Update documentation.json

* revert

* nit

* feat: locked timezone

* update

* fix

* Update handleChildrenEventTypes.test.ts

* update

* fix test
2025-07-29 10:20:35 +00:00
Anik Dhabal Babu b21400c613 fix: show seated attendees to host even when seatsShowAttendees is disabled (#22565)
* fix: show attendee

* Update get.handler.ts

* update
2025-07-29 11:01:46 +01:00
6c936472d2 feat: add cancelledBy and rescheduledBy parameters to workflow links (#22438)
* feat: add cancelledBy and rescheduledBy parameters to workflow links

- Extended workflow reminder selection to include user and team data
- Added getWorkflowAssigneeEmail helper function to resolve assignee email
- Updated email and SMS workflow templates to include assignee in cancel/reschedule links
- For user workflows, uses user email; for team workflows, uses team owner email
- Maintains backward compatibility when assignee data is unavailable

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use workflow recipient email instead of assignee email for link parameters

- Updated getWorkflowRecipientEmail to determine recipient based on workflow action type
- EMAIL_ADDRESS uses sendTo email, EMAIL_HOST uses organizer, EMAIL_ATTENDEE uses attendee
- SMS_ATTENDEE/WHATSAPP_ATTENDEE use attendee email for cancelledBy/rescheduledBy params
- Maintains backward compatibility when recipient data unavailable

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: use emailReminderManager for email workflows with recipient parameters

- Updated emailReminderManager to include recipient email in cancel/reschedule links
- Added getEmailWorkflowRecipientEmail function for proper recipient determination
- Removed changes from deprecated scheduleEmailReminders.ts
- SMS_ATTENDEE correctly uses attendee email like EMAIL_ATTENDEE

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Get attendee's email when sending SMS

* Remove unused query

* feat: add cancelledBy and rescheduledBy parameters to getSMSMessageWithVariables

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Clean up Devin

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-28 16:43:23 +00:00
Anik Dhabal Babu 3b823670ea fix: type error (#22780) 2025-07-28 16:21:51 +00:00
Lauris SkraucisandMorgan 6b10127d97 fix: changesets atoms release env variables (#22773)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-28 15:52:03 +02:00
Anik Dhabal Babu 9cd806ff61 fix: CSV generation from insights returns empty results (#22775)
* fix: CSV generation from insights returns empty results

* update

* fix type error
2025-07-28 13:17:14 +00:00
mintlify[bot] 628b169f53 Documentation edits made through Mintlify web editor (#22774)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-07-28 12:22:27 +00:00
Lauris Skraucis 19d7764e9d refactor: platform managed user org admin access (#22597)
* refactor: examples app setup

* wip: view org event types as org administrator

* fix: create event type has org admin

* fix: create event type has org admin

* fix: allow org admin team event type access

* fix: uncaught error

* chore: bump platform libs

* refactor: getting event type for org admin

* add test

* chore: libraries
2025-07-28 13:50:12 +02:00
sean-brydon 30061c0d58 fix: remove fallback permssions on organization Id. (#22769)
* add concept of manage permissions instea of fallback

* fix common json

* fix usePermission hook
2025-07-28 11:49:36 +00:00
329 changed files with 13022 additions and 3542 deletions
+4 -1
View File
@@ -34,4 +34,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
VITE_BOOKER_EMBED_OAUTH_CLIENT_ID: ${{ secrets.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID }}
VITE_BOOKER_EMBED_API_URL: ${{ secrets.VITE_BOOKER_EMBED_API_URL }}
NEXT_PUBLIC_WEBAPP_URL: ${{ secrets.NEXT_PUBLIC_WEBAPP_URL }}
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+1 -1
View File
@@ -38,7 +38,7 @@
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
"next-validations": "^0.2.0",
"typescript": "^5.7.2",
"typescript": "^5.9.0-beta",
"tzdata": "^1.0.30",
"uuid": "^8.3.2",
"zod": "^3.22.4"
-18
View File
@@ -1,18 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"moduleNameMapper": {
"@/(.*)": "<rootDir>/src/$1",
"test/(.*)": "<rootDir>/test/$1"
},
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
"reporters": ["default", "jest-summarizing-reporter"],
"workerIdleMemoryLimit": "512MB",
"maxWorkers": 8
}
+25
View File
@@ -0,0 +1,25 @@
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
moduleFileExtensions: ["js", "json", "ts"],
rootDir: ".",
moduleNameMapper: {
"@/(.*)": "<rootDir>/src/$1",
"test/(.*)": "<rootDir>/test/$1",
},
testEnvironment: "node",
testRegex: ".e2e-spec.ts$",
transform: {
"^.+\\.ts$": "ts-jest",
},
setupFiles: ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
setupFilesAfterEnv: ["<rootDir>/test/jest.setup-e2e.ts"],
reporters: ["default", "jest-summarizing-reporter"],
workerIdleMemoryLimit: "512MB",
maxWorkers: 8,
testPathIgnorePatterns: ["/dist/", "/node_modules/"],
transformIgnorePatterns: ["/dist/", "/node_modules/"],
};
export default config;
-14
View File
@@ -1,14 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"moduleNameMapper": {
"@/(.*)": "<rootDir>/src/$1",
"test/(.*)": "<rootDir>/test/$1"
},
"testEnvironment": "node",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.ts$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
moduleFileExtensions: ["ts", "js", "json"],
rootDir: ".",
moduleNameMapper: {
"@/(.*)": "<rootDir>/src/$1",
"test/(.*)": "<rootDir>/test/$1",
},
testEnvironment: "node",
testRegex: ".*\\.spec\\.ts$",
transform: {
"^.+\\.ts$": "ts-jest",
},
setupFiles: ["<rootDir>/test/setEnvVars.ts"],
testPathIgnorePatterns: ["/dist/", "/node_modules/"],
transformIgnorePatterns: ["/dist/", "/node_modules/"],
};
export default config;
+4 -4
View File
@@ -25,9 +25,9 @@
"test:watch": "yarn dev:build && jest --watch",
"test:cov": "yarn dev:build && jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.json",
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.ts",
"test:e2e:local": "yarn test:e2e --maxWorkers=4",
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.ts --watch",
"prisma": "yarn workspace @calcom/prisma prisma",
"generate-schemas": "yarn prisma generate && yarn prisma format",
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.267",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -108,7 +108,7 @@
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0",
"typescript": "^5.7.2"
"typescript": "^5.9.0-beta"
},
"prisma": {
"schema": "../../../packages/prisma/schema.prisma"
@@ -5,7 +5,7 @@ import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ge
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/mark-no-show.output";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
@@ -386,7 +386,7 @@ export class BookingsController_2024_04_15 {
if (bearerToken) {
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = hashAPIKey(strippedApiKey);
const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
return keyData?.userId;
} else {
@@ -31,12 +31,7 @@ function ValidateBookingName(validationOptions?: ValidationOptions) {
return value.trim().length > 0;
}
if (typeof value === "object" && value !== null) {
return (
typeof value.firstName === "string" &&
typeof value.lastName === "string" &&
value.firstName.trim().length > 0 &&
value.lastName.trim().length > 0
);
return typeof value.firstName === "string" && value.firstName.trim().length > 0;
}
return false;
},
@@ -1834,6 +1834,88 @@ describe("Bookings Endpoints 2024-08-13", () => {
}
});
});
it("should return who rescheduled the booking, in the new booking", async () => {
const rescheduledByEmail = `user-bookings-rescheduler-${randomString()}@rescheduler.com`;
// Create the original booking that will be rescheduled
const originalBooking = await bookingsRepositoryFixture.create({
uid: `original-booking-uid-${eventTypeId}`,
title: "original booking title",
startTime: "2050-09-05T10:00:00.000Z",
endTime: "2050-09-05T11:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
status: "CANCELLED",
rescheduledBy: rescheduledByEmail,
metadata: {},
responses: {
name: "original tester",
email: "original@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
// Create the new booking that is the result of the reschedule
const newBooking = await bookingsRepositoryFixture.create({
uid: `new-booking-uid-${eventTypeId}`,
title: "rescheduled booking title",
startTime: "2050-09-05T14:00:00.000Z",
endTime: "2050-09-05T15:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
status: "ACCEPTED",
fromReschedule: originalBooking.uid,
metadata: {},
responses: {
name: "new tester",
email: "newtester@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
return request(app.getHttpServer())
.get(`/v2/bookings/${newBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
// Fetch the original booking to get its rescheduledBy value
const originalBookingFromDb = await bookingsRepositoryFixture.getByUid(originalBooking.uid);
const expectedRescheduledBy = originalBookingFromDb?.rescheduledBy;
await bookingsRepositoryFixture.deleteById(originalBooking.id);
await bookingsRepositoryFixture.deleteById(newBooking.id);
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.uid).toEqual(newBooking.uid);
expect(rescheduledByEmail).toEqual(expectedRescheduledBy);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
describe("book by username and event type slug", () => {
@@ -11,7 +11,7 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { apiToInternalintegrationsMapping } from "@/ee/event-types/event-types_2024_06_14/transformers";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { defaultBookingResponses } from "@/lib/safe-parse/default-responses-booking";
import { safeParse } from "@/lib/safe-parse/safe-parse";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
@@ -675,7 +675,7 @@ export class InputBookingsService_2024_08_13 {
if (bearerToken) {
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = hashAPIKey(strippedApiKey);
const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
return keyData?.userId;
} else {
@@ -101,9 +101,13 @@ 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)
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -116,7 +120,6 @@ export class OutputBookingsService_2024_08_13 {
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -142,6 +145,7 @@ export class OutputBookingsService_2024_08_13 {
rating: databaseBooking.rating,
icsUid: databaseBooking.iCalUID,
rescheduledToUid,
rescheduledByEmail,
};
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
@@ -151,11 +155,15 @@ export class OutputBookingsService_2024_08_13 {
return bookingTransformed;
}
async getRescheduledToUid(bookingUid: string) {
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
return rescheduledTo?.uid;
return {
uid: rescheduledTo?.uid,
rescheduledBy: rescheduledTo?.rescheduledBy
};
}
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
if (databaseMetadata === null) return {};
@@ -218,7 +226,6 @@ export class OutputBookingsService_2024_08_13 {
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -269,9 +276,12 @@ export class OutputBookingsService_2024_08_13 {
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)
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -299,6 +309,7 @@ export class OutputBookingsService_2024_08_13 {
rating: databaseBooking.rating,
icsUid: databaseBooking.iCalUID,
rescheduledToUid,
rescheduledByEmail,
};
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
@@ -83,32 +83,31 @@ export class CalendarsService {
calendarsToLoad,
userId
);
try {
const calendarBusyTimes = await getBusyCalendarTimes(
this.buildNonDelegationCredentials(credentials),
dateFrom,
dateTo,
composedSelectedCalendars
);
const calendarBusyTimesConverted = calendarBusyTimes.map(
(busyTime: EventBusyDate & { timeZone?: string }) => {
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
const busyTimeStartDate = busyTimeStart.toJSDate();
const busyTimeEndDate = busyTimeEnd.toJSDate();
return {
...busyTime,
start: busyTimeStartDate,
end: busyTimeEndDate,
};
}
);
return calendarBusyTimesConverted;
} catch (error) {
const calendarBusyTimesQuery = await getBusyCalendarTimes(
this.buildNonDelegationCredentials(credentials),
dateFrom,
dateTo,
composedSelectedCalendars
);
if (!calendarBusyTimesQuery.success) {
throw new InternalServerErrorException(
"Unable to fetch connected calendars events. Please try again later."
);
}
const calendarBusyTimesConverted = calendarBusyTimesQuery.data.map(
(busyTime: EventBusyDate & { timeZone?: string }) => {
const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
const busyTimeStartDate = busyTimeStart.toJSDate();
const busyTimeEndDate = busyTimeEnd.toJSDate();
return {
...busyTime,
start: busyTimeStartDate,
end: busyTimeEndDate,
};
}
);
return calendarBusyTimesConverted;
}
async getUniqCalendarCredentials(calendarsToLoad: Calendar[], userId: User["id"]) {
+1 -1
View File
@@ -1,6 +1,6 @@
import { createHash } from "crypto";
export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex");
export const sha256Hash = (token: string): string => createHash("sha256").update(token).digest("hex");
export const isApiKey = (authString: string, prefix: string): boolean =>
authString?.startsWith(prefix ?? "cal_");
@@ -1,5 +1,6 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.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";
@@ -7,6 +8,8 @@ import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected
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 { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
@@ -21,6 +24,9 @@ import { Module } from "@nestjs/common";
PrismaEventTypeRepository,
PrismaRoutingFormResponseRepository,
PrismaTeamRepository,
PrismaFeaturesRepository,
CheckBookingLimitsService,
CacheService,
AvailableSlotsService,
],
exports: [AvailableSlotsService],
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaFeaturesRepository as PrismaFeaturesRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaFeaturesRepository extends PrismaFeaturesRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -1,11 +1,14 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.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 { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { Injectable } from "@nestjs/common";
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
@@ -20,7 +23,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
bookingRepository: PrismaBookingRepository,
selectedSlotRepository: PrismaSelectedSlotRepository,
eventTypeRepository: PrismaEventTypeRepository,
userRepository: PrismaUserRepository
userRepository: PrismaUserRepository,
featuresRepository: PrismaFeaturesRepository
) {
super({
oooRepo: oooRepoDependency,
@@ -31,6 +35,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
selectedSlotRepo: selectedSlotRepository,
eventTypeRepo: eventTypeRepository,
userRepo: userRepository,
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
cacheService: new CacheService(featuresRepository),
});
}
}
@@ -0,0 +1,11 @@
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { Injectable } from "@nestjs/common";
import { CacheService as BaseCacheService } from "@calcom/platform-libraries";
@Injectable()
export class CacheService extends BaseCacheService {
constructor(featuresRepository: PrismaFeaturesRepository) {
super({ featuresRepository });
}
}
@@ -0,0 +1,13 @@
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { Injectable } from "@nestjs/common";
import { CheckBookingAndDurationLimitsService as BaseCheckBookingAndDurationLimitsService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class CheckBookingAndDurationLimitsService extends BaseCheckBookingAndDurationLimitsService {
constructor(checkBookingLimitsService: CheckBookingLimitsService) {
super({
checkBookingLimitsService: checkBookingLimitsService,
});
}
}
@@ -0,0 +1,13 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { Injectable } from "@nestjs/common";
import { CheckBookingLimitsService as BaseCheckBookingLimitsService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class CheckBookingLimitsService extends BaseCheckBookingLimitsService {
constructor(bookingRepository: PrismaBookingRepository) {
super({
bookingRepo: bookingRepository,
});
}
}
+5 -6
View File
@@ -1,5 +1,5 @@
import { getEnv } from "@/env";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
@@ -10,7 +10,6 @@ import {
ThrottlerException,
ThrottlerRequest,
ThrottlerModuleOptions,
seconds,
} from "@nestjs/throttler";
import { Request, Response } from "express";
import { z } from "zod";
@@ -218,18 +217,18 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
if (authorizationHeader) {
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
return isApiKey(authorizationHeader, apiKeyPrefix)
? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}`
: `access_token_${authorizationHeader}`;
? `api_key_${sha256Hash(stripApiKey(authorizationHeader, apiKeyPrefix))}`
: `access_token_${sha256Hash(authorizationHeader)}`;
}
const oauthClientId = request.get(X_CAL_CLIENT_ID);
if (oauthClientId) {
return `oauth_client_${oauthClientId}`;
return `oauth_client_${sha256Hash(oauthClientId)}`;
}
if (IP) {
return `ip_${IP}`;
return `ip_${sha256Hash(IP.toString())}`;
}
this.logger.verbose(`no tracker found: ${request.url}`);
@@ -1,4 +1,4 @@
import { hashAPIKey, stripApiKey } from "@/lib/api-key";
import { sha256Hash, stripApiKey } from "@/lib/api-key";
import { AuthMethods } from "@/lib/enums/auth-methods";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { CreateApiKeyInput } from "@/modules/api-keys/inputs/create-api-key.input";
@@ -61,7 +61,7 @@ export class ApiKeysService {
async refreshApiKey(authUserId: number, apiKey: string, refreshApiKeyInput: RefreshApiKeyInput) {
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = hashAPIKey(strippedApiKey);
const apiKeyHash = sha256Hash(strippedApiKey);
const apiKeyInDb = await this.apiKeysRepository.getApiKeyFromHash(apiKeyHash);
if (!apiKeyInDb) {
throw new UnauthorizedException("ApiKeysService - provided api key is not valid.");
@@ -11,6 +11,7 @@ import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.s
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisService } from "@/modules/redis/redis.service";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
@@ -21,6 +22,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule, TeamsEventTypesModule],
providers: [
OrganizationsTeamsRepository,
EventTypesAtomService,
ConferencingAtomsService,
AttributesAtomsService,
@@ -3,6 +3,7 @@ import { systemBeforeFieldEmail } from "@/ee/event-types/event-types_2024_06_14/
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
@@ -53,7 +54,8 @@ export class EventTypesAtomService {
private readonly dbWrite: PrismaWriteService,
private readonly dbRead: PrismaReadService,
private readonly eventTypeService: EventTypesService_2024_06_14,
private readonly teamEventTypeService: TeamsEventTypesService
private readonly teamEventTypeService: TeamsEventTypesService,
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository
) {}
private async getTeamSlug(teamId: number): Promise<string> {
@@ -88,10 +90,12 @@ export class EventTypesAtomService {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
if (eventType?.team?.id) {
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
} else {
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
if (!isUserOrganizationAdmin) {
if (eventType?.team?.id) {
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
} else {
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
}
}
// note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type
@@ -115,7 +119,8 @@ export class EventTypesAtomService {
user: UserWithProfile,
teamId: number
) {
await this.checkCanUpdateTeamEventType(user.id, eventTypeId, teamId, body.scheduleId);
await this.checkCanUpdateTeamEventType(user, eventTypeId, teamId, body.scheduleId);
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
const bookingFields = body.bookingFields ? [...body.bookingFields] : undefined;
@@ -175,14 +180,31 @@ export class EventTypesAtomService {
}
async checkCanUpdateTeamEventType(
userId: number,
user: UserWithProfile,
eventTypeId: number,
teamId: number,
scheduleId: number | null | undefined
) {
await this.checkTeamOwnsEventType(userId, eventTypeId, teamId);
const organizationId = this.usersService.getUserMainOrgId(user);
if (organizationId) {
const isUserOrganizationAdmin = await this.membershipsRepository.isUserOrganizationAdmin(
user.id,
organizationId
);
if (isUserOrganizationAdmin) {
const orgTeam = await this.organizationsTeamsRepository.findOrgTeam(organizationId, teamId);
if (orgTeam) {
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
return;
}
}
}
await this.checkTeamOwnsEventType(user.id, eventTypeId, teamId);
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
await this.eventTypeService.checkUserOwnsSchedule(userId, scheduleId);
await this.eventTypeService.checkUserOwnsSchedule(user.id, scheduleId);
}
async checkTeamOwnsEventType(userId: number, eventTypeId: number, teamId: number) {
@@ -1,4 +1,4 @@
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { AuthMethods } from "@/lib/enums/auth-methods";
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
import { BaseStrategy } from "@/lib/passport/strategies/types";
@@ -226,7 +226,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
);
}
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = hashAPIKey(strippedApiKey);
const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
if (!keyData) {
throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is not valid");
@@ -27,6 +27,17 @@ export class OrganizationsMembershipService {
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
async isOrgAdminOrOwner(organizationId: number, userId: number) {
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
organizationId,
userId
);
if (!membership) {
return false;
}
return membership.role === "ADMIN" || membership.role === "OWNER";
}
async getOrgMembershipByUserId(organizationId: number, userId: number) {
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
organizationId,
@@ -1,7 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { getEnv } from "@/env";
import { hashAPIKey, stripApiKey } from "@/lib/api-key";
import { sha256Hash, stripApiKey } from "@/lib/api-key";
import { RefreshApiKeyOutput } from "@/modules/api-keys/outputs/refresh-api-key.output";
import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto";
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
@@ -283,7 +283,7 @@ describe("Organizations Organizations Endpoints", () => {
expect(managedOrgApiKeys?.length).toEqual(1);
expect(managedOrgApiKeys?.[0]?.id).toBeDefined();
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
const hashedApiKey = `${hashAPIKey(stripApiKey(managedOrg?.apiKey, apiKeyPrefix))}`;
const hashedApiKey = `${sha256Hash(stripApiKey(managedOrg?.apiKey, apiKeyPrefix))}`;
expect(managedOrgApiKeys?.[0]?.hashedKey).toEqual(hashedApiKey);
const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 30 }).toJSDate();
expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt);
@@ -484,7 +484,7 @@ describe("Organizations Organizations Endpoints", () => {
expect(managedOrgApiKeys?.length).toEqual(1);
expect(managedOrgApiKeys?.[0]?.id).toBeDefined();
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
const hashedApiKey = `${hashAPIKey(stripApiKey(newApiKey, apiKeyPrefix))}`;
const hashedApiKey = `${sha256Hash(stripApiKey(newApiKey, apiKeyPrefix))}`;
expect(managedOrgApiKeys?.[0]?.hashedKey).toEqual(hashedApiKey);
const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 60 }).toJSDate();
expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt);
@@ -14,6 +14,7 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input";
import { UpdateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/update-organization-team.input";
import {
@@ -56,7 +57,10 @@ import { Team } from "@calcom/prisma/client";
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsController {
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
constructor(
private organizationsTeamsService: OrganizationsTeamsService,
private organizationsMembershipService: OrganizationsMembershipService
) {}
@Get()
@ApiOperation({ summary: "Get all teams" })
@@ -84,12 +88,11 @@ export class OrganizationsTeamsController {
@GetUser() user: UserWithProfile
): Promise<OrgMeTeamsOutputResponseDto> {
const { skip, take } = queryParams;
const teams = await this.organizationsTeamsService.getPaginatedOrgUserTeams(
orgId,
user.id,
skip ?? 0,
take ?? 250
);
const isOrgAdminOrOwner = await this.organizationsMembershipService.isOrgAdminOrOwner(orgId, user.id);
const teams = isOrgAdminOrOwner
? await this.organizationsTeamsService.getPaginatedOrgTeamsWithMembers(orgId, skip ?? 0, take ?? 250)
: await this.organizationsTeamsService.getPaginatedOrgUserTeams(orgId, user.id, skip ?? 0, take ?? 250);
return {
status: SUCCESS_STATUS,
data: teams.map((team) => {
@@ -107,4 +107,17 @@ export class OrganizationsTeamsRepository {
take,
});
}
async findOrgTeamsPaginatedWithMembers(organizationId: number, skip: number, take: number) {
return this.dbRead.prisma.team.findMany({
where: {
parentId: organizationId,
},
include: {
members: { select: { accepted: true, userId: true, role: true } },
},
skip,
take,
});
}
}
@@ -24,6 +24,15 @@ export class OrganizationsTeamsService {
return teams;
}
async getPaginatedOrgTeamsWithMembers(organizationId: number, skip = 0, take = 250) {
const teams = await this.organizationsTeamRepository.findOrgTeamsPaginatedWithMembers(
organizationId,
skip,
take
);
return teams;
}
async getPaginatedOrgTeams(organizationId: number, skip = 0, take = 250) {
const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take);
return teams;
+2 -1
View File
@@ -26,7 +26,8 @@
"@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/repositories": ["../../../packages/platform/libraries/repositories.ts"]
"@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"],
"@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"]
},
"incremental": true,
"skipLibCheck": true,
+1 -1
View File
@@ -34,6 +34,6 @@
"eslint-config-next": "15.1.6",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
"typescript": "^5.9.0-beta"
}
}
@@ -7,6 +7,7 @@ import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
@@ -17,7 +18,7 @@ import type { PageProps as LegacyPageProps } from "~/team/type-view";
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"team-booking-page-cache"
);
@@ -102,7 +102,7 @@ export async function getEnrichedEventType({
}
export async function shouldUseApiV2ForTeamSlots(teamId: number): Promise<boolean> {
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(teamId, "use-api-v2-for-team-slots");
const useApiV2 = teamHasApiV2Route && Boolean(process.env.NEXT_PUBLIC_API_V2_URL);
@@ -4,11 +4,12 @@ import { notFound } from "next/navigation";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import Shell from "@calcom/features/shell/Shell";
import { prisma } from "@calcom/prisma";
import UpgradeTipWrapper from "./UpgradeTipWrapper";
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
@@ -1,7 +1,7 @@
"use client";
import { BookingsByHourChartContent } from "@calcom/features/insights/components/BookingsByHourChart";
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
import { BookingsByHourChartContent } from "@calcom/features/insights/components/booking/BookingsByHourChart";
import { useLocale } from "@calcom/lib/hooks/useLocale";
// Sample data for playground testing
@@ -1,7 +1,10 @@
"use client";
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
import { RoutingFunnelContent, legend } from "@calcom/features/insights/components/RoutingFunnelContent";
import {
RoutingFunnelContent,
legend,
} from "@calcom/features/insights/components/routing/RoutingFunnelContent";
import { useLocale } from "@calcom/lib/hooks/useLocale";
// Random sample data for playground testing
@@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { ApiKeyRepository } from "@calcom/lib/server/repository/apiKey";
import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -22,7 +22,7 @@ export const generateMetadata = async () =>
const getCachedApiKeys = unstable_cache(
async (userId: number) => {
return await ApiKeyRepository.findApiKeysFromUserId({ userId });
return await PrismaApiKeyRepository.findApiKeysFromUserId({ userId });
},
undefined,
{ revalidate: 3600, tags: ["viewer.apiKeys.list"] } // Cache for 1 hour
@@ -9,6 +9,7 @@ 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 { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -17,7 +18,7 @@ import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
const getTeamFeatures = unstable_cache(
async (teamId: number) => {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
return await featuresRepository.getTeamFeatures(teamId);
},
["team-features"],
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import OrgSettingsAttributesPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-list-view";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,10 +22,40 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const { canRead, canEdit, canDelete, canCreate } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Attributes,
userRole: session.user.org.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
delete: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
create: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canRead) {
return redirect("/settings/profile");
}
return (
<SettingsHeader title={t("attributes")} description={t("attribute_meta_description")}>
<OrgSettingsAttributesPage />
<OrgSettingsAttributesPage permissions={{ canEdit, canDelete, canCreate }} />
</SettingsHeader>
);
};
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import DirectorySyncTeamView from "@calcom/features/ee/dsync/page/team-dsync-view";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,10 +22,27 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/organizations/general");
}
const { canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
return (
<SettingsHeader title={t("directory_sync")} description={t("directory_sync_description")}>
<DirectorySyncTeamView />
<DirectorySyncTeamView permissions={{ canEdit }} />
</SettingsHeader>
);
};
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import PrivacyView from "@calcom/features/ee/organizations/pages/settings/privacy";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,9 +23,34 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const { canRead, canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canRead) {
return redirect("/settings/profile");
}
return (
<SettingsHeader title={t("privacy")} description={t("privacy_organization_description")}>
<PrivacyView />
<PrivacyView permissions={{ canRead, canEdit }} />
</SettingsHeader>
);
};
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import OrgSSOView from "@calcom/features/ee/sso/page/orgs-sso-view";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,10 +22,27 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/organizations/general");
}
const { canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
return (
<SettingsHeader title={t("sso_configuration")} description={t("sso_configuration_description_orgs")}>
<OrgSSOView />
<OrgSSOView permissions={{ canEdit }} />
</SettingsHeader>
);
};
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/general";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,9 +23,30 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const { canRead, canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
return (
<SettingsHeader title={t("general")} description={t("general_description")} borderInShellHeader={true}>
<LegacyPage />
<LegacyPage permissions={{ canRead, canEdit }} />
</SettingsHeader>
);
};
@@ -1,7 +1,16 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/profile";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import type { Membership } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -13,14 +22,47 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const t = await getTranslate();
const orgRole = session?.user.profile?.organization.members?.find(
(member: Membership) => member.userId === session?.user.id
)?.role;
if (!session?.user.id || !session?.user.profile?.organizationId || !orgRole) {
return redirect("/settings/profile");
}
const { canRead, canEdit, canDelete } = await getResourcePermissions({
userId: session.user.id,
teamId: session?.user.profile?.organizationId,
resource: Resource.Organization,
userRole: orgRole,
fallbackRoles: {
read: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
delete: {
roles: [MembershipRole.OWNER],
},
},
});
return (
<SettingsHeader
title={t("profile")}
description={t("profile_org_description")}
borderInShellHeader={true}>
<LegacyPage />
<LegacyPage
permissions={{
canEdit,
canRead,
canDelete,
}}
/>
</SettingsHeader>
);
};
@@ -44,5 +44,17 @@ describe("usePermissions", () => {
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
});
it("should return 'all' for resource with manage permission", () => {
const permissions = ["eventType.manage"];
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
});
it("should return 'all' for resource with manage permission even if other permissions are missing", () => {
const permissions = ["eventType.manage", "eventType.read"]; // Has manage and read, but missing create, update, delete
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
});
});
});
@@ -51,14 +51,20 @@ export function usePermissions(): UsePermissionsReturn {
return "all";
}
// Filter out internal keys like _resource when checking permissions
const allResourcePerms = Object.keys(resourceConfig)
.filter((action) => !action.startsWith("_"))
// Check if user has manage permission for this resource
const hasManagePermission = permissions.includes(`${resource}.manage`);
if (hasManagePermission) {
return "all";
}
// Filter out internal keys like _resource and manage when checking for individual permissions
const crudPermissions = Object.keys(resourceConfig)
.filter((action) => !action.startsWith("_") && action !== "manage")
.map((action) => `${resource}.${action}`);
const hasAllPerms = allResourcePerms.every((p) => permissions.includes(p));
const hasAllCrudPerms = crudPermissions.every((p) => permissions.includes(p));
const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`);
if (hasAllPerms) return "all";
if (hasAllCrudPerms) return "all";
if (hasReadPerm) return "read";
return "none";
};
@@ -11,6 +11,7 @@ import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permiss
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 { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -29,7 +30,7 @@ const getCachedTeamRoles = unstable_cache(
const getCachedTeamFeature = unstable_cache(
async (teamId: number, feature: keyof AppFlags) => {
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
return res;
},
@@ -4,7 +4,7 @@ import { unstable_cache } from "next/cache";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { AttributeRepository } from "@calcom/lib/server/repository/attribute";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { TeamMembersView } from "~/teams/team-members-view";
@@ -37,7 +37,7 @@ const getCachedTeamAttributes = unstable_cache(
async (organizationId?: number) => {
if (!organizationId) return [];
try {
return await AttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
} catch (error) {
return [];
}
@@ -12,6 +12,7 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio
import { RoleService } from "@calcom/features/pbac/services/role.service";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -32,7 +33,7 @@ const getCachedTeamRoles = (teamId: number) =>
const getCachedTeamFeature = (teamId: number, feature: keyof AppFlags) =>
unstable_cache(
async () => {
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
return res;
},
@@ -3,7 +3,7 @@ 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 { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
import { MembersView } from "~/members/members-view";
@@ -19,7 +19,7 @@ export const generateMetadata = async () =>
const getCachedAttributes = unstable_cache(
async (orgId: number) => {
return await AttributeRepository.findAllByOrgIdWithOptions({ orgId });
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId });
},
undefined,
{ revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour
+2 -1
View File
@@ -10,6 +10,7 @@ import getIP from "@calcom/lib/getIP";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { prisma } from "@calcom/prisma";
import { signupSchema } from "@calcom/prisma/zod-utils";
async function ensureSignupIsEnabled(body: Record<string, string>) {
@@ -22,7 +23,7 @@ async function ensureSignupIsEnabled(body: Record<string, string>) {
// Still allow signups if there is a team invite
if (token) return;
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || signupDisabled) {
+1 -1
View File
@@ -208,7 +208,7 @@ async function getHandler(request: NextRequest) {
}
// Create a new response with the image buffer
const imageResponse = new NextResponse(buffer);
const imageResponse = new NextResponse(buffer as BodyInit);
// Set the appropriate headers
imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png");
@@ -515,7 +515,9 @@ function BookingListItem(booking: BookingItemProps) {
</div>
{!isPending && (
<div>
{(provider?.label || locationToDisplay?.startsWith("https://")) &&
{(provider?.label ||
(typeof locationToDisplay === "string" &&
locationToDisplay?.startsWith("https://"))) &&
locationToDisplay.startsWith("http") && (
<a
href={locationToDisplay}
@@ -120,7 +120,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
// Check if team has API v2 feature flag enabled (same logic as team pages)
let useApiV2 = false;
if (isTeamEvent && hashedLink.eventType.team?.id) {
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(
hashedLink.eventType.team.id,
"use-api-v2-for-team-slots"
@@ -1,9 +1,10 @@
import type { GetServerSidePropsResult } from "next";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma";
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
// Check if organizations are enabled
if (!organizations) {
+1 -1
View File
@@ -24,7 +24,7 @@ const querySchema = z.object({
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"email-verification"
);
@@ -127,7 +127,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const organizationSettings = getOrganizationSEOSettings(team);
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
@@ -71,7 +71,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
// Provided by Rewrite from next.config.js
const isOrgProfile = context.query?.isOrgProfile === "1";
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
log.debug("getServerSideProps", {
@@ -247,7 +247,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const videoReference = getCalVideoReference(bookingObj.references);
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const displayLogInOverlay = profile?.organizationId
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
: false;
@@ -1229,8 +1229,21 @@ function RecurringBookings({
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
<br />
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "}
{formatToLocalizedTime(dayjs(dateStr).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
{formatToLocalizedTime({
date: dayjs(dateStr),
locale: language,
timeStyle: undefined,
hour12: !is24h,
timeZone: tz,
})}{" "}
-{" "}
{formatToLocalizedTime({
date: dayjs(dateStr).add(duration, "m"),
locale: language,
timeStyle: undefined,
hour12: !is24h,
timeZone: tz,
})}{" "}
<span className="text-bookinglight">
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
</span>
@@ -1249,14 +1262,19 @@ function RecurringBookings({
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
<br />
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "}
{formatToLocalizedTime(
dayjs(dateStr).add(duration, "m"),
language,
undefined,
!is24h,
tz
)}{" "}
{formatToLocalizedTime({
date: dayjs(dateStr),
locale: language,
hour12: !is24h,
timeZone: tz,
})}{" "}
-{" "}
{formatToLocalizedTime({
date: dayjs(dateStr).add(duration, "m"),
locale: language,
hour12: !is24h,
timeZone: tz,
})}{" "}
<span className="text-bookinglight">
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
</span>
@@ -1273,8 +1291,13 @@ function RecurringBookings({
<div className={classNames(isCancelled ? "line-through" : "")}>
{formatToLocalizedDate(date, language, "full", tz)}
<br />
{formatToLocalizedTime(date, language, undefined, !is24h, tz)} -{" "}
{formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
{formatToLocalizedTime({ date, locale: language, hour12: !is24h, timeZone: tz })} -{" "}
{formatToLocalizedTime({
date: dayjs(date).add(duration, "m"),
locale: language,
hour12: !is24h,
timeZone: tz,
})}{" "}
<span className="text-bookinglight">({formatToLocalizedTimezone(date, language, tz)})</span>
</div>
);
@@ -7,7 +7,7 @@ import {
FailedBookingsByField,
RoutedToPerPeriod,
RoutingFunnel,
} from "@calcom/features/insights/components";
} from "@calcom/features/insights/components/routing";
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
import { useLocale } from "@calcom/lib/hooks/useLocale";
+2 -2
View File
@@ -10,10 +10,10 @@ import {
import {
AverageEventDurationChart,
BookingKPICards,
BookingsByHourChart,
EventTrendsChart,
HighestNoShowHostTable,
HighestRatedMembersTable,
BookingsByHourChart,
LeastBookedTeamMembersTable,
LowestRatedMembersTable,
MostBookedTeamMembersTable,
@@ -21,7 +21,7 @@ import {
PopularEventsTable,
RecentFeedbackTable,
TimezoneBadge,
} from "@calcom/features/insights/components";
} from "@calcom/features/insights/components/booking";
import "@calcom/features/insights/components/tremor.css";
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
import { Download } from "@calcom/features/insights/filters/Download";
@@ -78,9 +78,11 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
teams: teamsToCreateFromStore.length ? teamsToCreateFromStore : [{ name: "" }],
moveTeams: teams.map((team) => {
const teamToMigrateInStore = teamsToMigrateFromStore.find((t) => t.id === team.id);
const slugConflictsWithOrg = team.slug === orgSlug;
return {
id: team.id,
shouldMove: !!teamToMigrateInStore,
// The team with conflicting slug must be moved
shouldMove: slugConflictsWithOrg || !!teamToMigrateInStore,
newSlug: teamToMigrateInStore?.slug || getSuggestedSlug({ teamSlug: team.slug, orgSlug }),
name: team.name,
};
@@ -160,6 +162,9 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
</label>
<ul className="mb-8 space-y-4">
{moveTeams.map((team, index) => {
const currentTeam = teams.find((t) => t.id === team.id);
// If the team slug conflicts with the org slug, this team must be moved
const slugConflictsWithOrg = currentTeam?.slug === orgSlug;
return (
<li key={team.id}>
<Controller
@@ -167,9 +172,11 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
render={({ field: { value, onChange } }) => (
<CheckboxField
defaultValue={value}
checked={value}
checked={value || slugConflictsWithOrg}
onChange={onChange}
description={teams.find((t) => t.id === team.id)?.name ?? ""}
description={currentTeam?.name ?? ""}
// Must not allow toggling off if the slug conflicts with the org slug
disabled={slugConflictsWithOrg}
/>
)}
/>
@@ -288,6 +288,7 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
const endTime = new Date(booking.endTime);
const startTime = new Date(booking.startTime);
const timeZone = booking.user?.timeZone;
useDailyEvent("left-meeting", () => {
if (rediectAttendeeToOnExit) {
@@ -306,11 +307,11 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
<h3>{t("what")}:</h3>
<p>{booking.title}</p>
<h3>{t("invitee_timezone")}:</h3>
<p>{booking.user?.timeZone}</p>
<p>{timeZone}</p>
<h3>{t("when")}:</h3>
<p suppressHydrationWarning={true}>
{formatToLocalizedDate(startTime)} <br />
{formatToLocalizedTime(startTime)}
{formatToLocalizedTime({ date: startTime, timeZone })}
</p>
<h3>{t("time_left")}</h3>
<ProgressBar
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.5.7",
"version": "5.5.10",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -195,7 +195,7 @@
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.6",
"ts-node": "^10.9.1",
"typescript": "^5.7.2"
"typescript": "^5.9.0-beta"
},
"nextBundleAnalysis": {
"budget": 358400,
@@ -0,0 +1 @@
export { default, config } from "@calcom/app-store/btcpayserver/api/webhook";
@@ -0,0 +1,444 @@
import { expect } from "@playwright/test";
import type { Page, Browser, Route, Response } from "@playwright/test";
import type { Team, EventType, User } from "@prisma/client";
import type { z } from "zod";
import { CalendarCacheRepository } from "@calcom/features/calendar-cache/calendar-cache.repository";
import { getTimeMin, getTimeMax } from "@calcom/features/calendar-cache/lib/datesForCache";
import { prisma } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { test } from "./lib/fixtures";
import type { Fixtures } from "./lib/fixtures";
import { bookTimeSlot, doOnOrgDomain, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
/**
* Booking Race Condition Prevention Test
*
* This test validates that the calendar caching system and booking logic correctly
* prevent double-booking race conditions that occurred in production. It serves as
* a regression test to ensure the following mechanisms work properly:
*
* 1. **Race Condition Prevention**: Ensures concurrent booking requests for the same
* time slot don't result in double bookings to the same host.
*
* 2. **Calendar Cache Functionality**: Validates that the calendar cache system works
* correctly with proper cache key generation using expanded date ranges.
*
* 3. **Round-Robin Distribution**: Verifies that when both bookings succeed, they are
* distributed to different hosts as expected in round-robin scheduling.
*
* 4. **Database Constraint Protection**: Confirms that unique constraints prevent
* duplicate bookings when race conditions are detected.
*
* **Test Setup**:
* - Creates a round-robin team with multiple hosts
* - Populates calendar cache with deterministic availability data
* - Mocks Google Calendar API to simulate real-world scenarios
* - Executes concurrent booking requests at the same time slot
*
* **Expected Outcomes**:
* - [200, 409]: One booking succeeds, one fails due to constraint violation (preferred)
* - [200, 200]: Both succeed but distributed to different hosts (acceptable)
* - [200, 200] with same host: Double booking occurred (test failure - race condition)
*
* **Production Context**:
* The original race condition was caused by:
* - Stale calendar cache showing hosts as available
* - Microsecond timing differences in Date.valueOf() for idempotency keys
* - Both requests selecting the same host due to identical cache state
*
* This test recreates similar conditions but in a controlled way to verify
* the prevention mechanisms work correctly.
*/
test.describe("Booking Race Condition Prevention", () => {
test.skip("Prevents double-booking race condition and validates cache functionality", async ({
page,
users,
orgs,
browser,
}) => {
const { org, team, teamEvent, teamMembers } = await setupTeamWithRoundRobin(users, orgs);
await setupGoogleCalendarCredentials(teamMembers);
await createIdenticalBookingHistories(teamMembers, teamEvent.id);
const { selectedDate, selectedDateISO } = await getDynamicBookingDate(page, org, team, teamEvent);
const { targetHost, calendarCacheHits } = await setupCalendarCache(teamMembers, selectedDateISO);
await enableCalendarCacheFeatures(team.id);
const { firstResponse, secondResponse } = await performConcurrentBookings(
page,
browser,
org,
team,
teamEvent,
selectedDate
);
const bookingResults = await analyzeBookingResults(teamEvent.id, firstResponse, secondResponse);
expect(bookingResults.isRaceConditionPrevented).toBe(true);
console.log("Race condition prevention test results:", {
totalBookings: bookingResults.bookings.length,
responseStatuses: bookingResults.responseStatuses,
sameHostSelected: bookingResults.sameHostSelected,
preventionMechanism: bookingResults.preventionMechanism,
});
});
});
async function setupTeamWithRoundRobin(users: Fixtures["users"], orgs: Fixtures["orgs"]) {
const org = await orgs.create({ name: "TestOrg" });
const teamMatesObj = [{ name: "teammate-1" }, { name: "teammate-2" }];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const { team } = await owner.getFirstTeamMembership();
const teamEvent = await owner.getFirstTeamEvent(team.id);
const teamMemberships = await prisma.membership.findMany({
where: { teamId: team.id },
select: {
user: true,
},
});
const teamMembers = teamMemberships.map((membership) => membership.user);
return { org, team, teamEvent, teamMembers };
}
async function setupGoogleCalendarCredentials(teamMembers: User[]) {
const googleCalendarApp = await prisma.app.findFirst({
where: { slug: "google-calendar" },
});
if (!googleCalendarApp) {
await prisma.app.create({
data: {
slug: "google-calendar",
dirName: "google-calendar",
},
});
}
for (const member of teamMembers) {
await prisma.credential.create({
data: {
type: "google_calendar",
key: {
access_token: "test_access_token",
refresh_token: "test_refresh_token",
scope: "https://www.googleapis.com/auth/calendar.events",
token_type: "Bearer",
expiry_date: Date.now() + 3600000,
},
userId: member.id,
appId: "google-calendar",
invalid: false,
},
});
}
}
async function createIdenticalBookingHistories(teamMembers: User[], eventTypeId: number) {
const identicalTimestamp = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const member of teamMembers) {
for (let i = 0; i < 3; i++) {
await prisma.booking.create({
data: {
uid: `test-booking-${member.id}-${i}-${Date.now()}`,
title: `Test booking ${i}`,
startTime: new Date(Date.now() - (i + 2) * 24 * 60 * 60 * 1000),
endTime: new Date(Date.now() - (i + 2) * 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
eventTypeId,
userId: member.id,
status: "ACCEPTED",
createdAt: identicalTimestamp,
updatedAt: identicalTimestamp,
attendees: {
create: {
email: `test${i}@example.com`,
name: `Test Attendee ${i}`,
timeZone: "UTC",
},
},
},
});
}
}
}
async function getDynamicBookingDate(
page: Page,
org: any,
team: any,
teamEvent: EventType
): Promise<{ selectedDate: string; selectedDateISO: string }> {
await doOnOrgDomain({ orgSlug: org.slug, page }, async () => {
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await page.waitForSelector('[data-testid="day"]');
await page.getByTestId("incrementMonth").click();
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor();
});
const firstAvailableDayText = await page
.locator('[data-testid="day"][data-disabled="false"]')
.nth(0)
.textContent();
if (!firstAvailableDayText) {
throw new Error("No available day found");
}
const currentDate = new Date();
const nextMonth = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
parseInt(firstAvailableDayText)
);
const selectedDateISO = nextMonth.toISOString();
return {
selectedDate: firstAvailableDayText,
selectedDateISO,
};
}
async function setupCalendarCache(teamMembers: User[], selectedDateISO: string) {
const cacheTimeRange = {
timeMin: getTimeMin(selectedDateISO),
timeMax: getTimeMax(selectedDateISO),
};
const credentials = await prisma.credential.findMany({
where: {
userId: { in: teamMembers.map((m) => m.id) },
type: "google_calendar",
},
});
const calendarCacheRepo = new CalendarCacheRepository(null);
const targetHost = teamMembers[0];
const calendarCacheHits: string[] = [];
for (let i = 0; i < credentials.length; i++) {
const credential = credentials[i];
const member = teamMembers[i];
const cacheArgs = {
timeMin: cacheTimeRange.timeMin,
timeMax: cacheTimeRange.timeMax,
items: [{ id: member.email! }],
};
const availabilityData = {
kind: "calendar#freeBusy",
calendars: {
[member.email!]: {
busy:
member.id === targetHost.id
? []
: [
{
start: `${selectedDateISO.slice(0, 10)}T08:00:00.000Z`,
end: `${selectedDateISO.slice(0, 10)}T08:30:00.000Z`,
},
],
},
},
};
await calendarCacheRepo.upsertCachedAvailability({
credentialId: credential.id,
userId: member.id,
args: cacheArgs,
value: availabilityData,
});
calendarCacheHits.push(`${member.email}-${credential.id}`);
}
return { targetHost, calendarCacheHits };
}
async function enableCalendarCacheFeatures(teamId: number) {
await prisma.teamFeatures.createMany({
data: [
{
teamId,
featureId: "calendar-cache",
assignedAt: new Date(),
assignedBy: "race-condition-test",
},
{
teamId,
featureId: "calendar-cache-serve",
assignedAt: new Date(),
assignedBy: "race-condition-test",
},
],
});
}
async function mockGoogleCalendarAPI(page: Page, selectedDateISO: string) {
const busyStart = `${selectedDateISO.slice(0, 10)}T08:00:00.000Z`;
const busyEnd = `${selectedDateISO.slice(0, 10)}T09:00:00.000Z`;
await page.route("**/calendar/v3/freeBusy**", async (route: Route) => {
const mockResponse = {
kind: "calendar#freeBusy",
calendars: {
"pro-user@example.com": {
busy: [
{
start: busyStart,
end: busyEnd,
},
],
},
"teammate-1@example.com": {
busy: [
{
start: busyStart,
end: busyEnd,
},
],
},
"teammate-2@example.com": {
busy: [
{
start: busyStart,
end: busyEnd,
},
],
},
},
};
await route.fulfill({
status: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(mockResponse),
});
});
}
export type TeamWithMetadata = Team & { metadata: z.infer<typeof teamMetadataSchema> };
async function performConcurrentBookings(
page: Page,
browser: Browser,
org: TeamWithMetadata,
team: TeamWithMetadata,
teamEvent: EventType,
selectedDate: string
) {
let firstResponse: Response | undefined;
let secondResponse: Response | undefined;
await doOnOrgDomain({ orgSlug: org.slug, page }, async () => {
const context1 = await browser.newContext();
const context2 = await browser.newContext();
const page1 = await context1.newPage();
const page2 = await context2.newPage();
const currentDate = new Date();
const nextMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, parseInt(selectedDate));
const selectedDateISO = nextMonth.toISOString();
await mockGoogleCalendarAPI(page1, selectedDateISO);
await mockGoogleCalendarAPI(page2, selectedDateISO);
await page1.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await page2.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page1);
await selectFirstAvailableTimeSlotNextMonth(page2);
const responsePromise1 = page1.waitForResponse(
(response) => response.url().includes("/api/book/event") && response.request().method() === "POST"
);
const responsePromise2 = page2.waitForResponse(
(response) => response.url().includes("/api/book/event") && response.request().method() === "POST"
);
const bookingPromise1 = bookTimeSlot(page1, {
name: "Test User 1",
email: "test1@example.com",
});
const bookingPromise2 = bookTimeSlot(page2, {
name: "Test User 2",
email: "test2@example.com",
});
const [response1, response2] = await Promise.all([responsePromise1, responsePromise2]);
await Promise.allSettled([bookingPromise1, bookingPromise2]);
firstResponse = response1;
secondResponse = response2;
await context1.close();
await context2.close();
});
return { firstResponse, secondResponse };
}
async function analyzeBookingResults(
eventTypeId: number,
firstResponse?: Response,
secondResponse?: Response
) {
const bookings = await prisma.booking.findMany({
where: { eventTypeId },
include: { attendees: true },
orderBy: { createdAt: "desc" },
take: 10,
});
const responseStatuses = [firstResponse?.status(), secondResponse?.status()].filter(Boolean);
const recentBookings = bookings.slice(0, 2);
const sameHostSelected =
recentBookings.length === 2 && recentBookings[0].userId === recentBookings[1].userId;
let preventionMechanism = "unknown";
if (responseStatuses.includes(409)) {
preventionMechanism = "database-constraint";
} else if (responseStatuses.every((status) => status === 200) && !sameHostSelected) {
preventionMechanism = "round-robin-distribution";
} else if (sameHostSelected) {
preventionMechanism = "race-condition-occurred";
}
const isRaceConditionPrevented = preventionMechanism !== "race-condition-occurred";
return {
bookings,
responseStatuses,
sameHostSelected,
preventionMechanism,
isRaceConditionPrevented,
};
}
+27
View File
@@ -420,6 +420,33 @@ test.describe("Event Types tests", () => {
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
await expect(offerSeatsToggle).toBeDisabled();
});
test("should enable timezone lock in event advanced settings and verify disabled timezone selector on booking page", async ({
page,
users,
}) => {
await gotoFirstEventType(page);
await expect(page.locator("[data-testid=event-title]")).toBeVisible();
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
await page.click("[data-testid=lock-timezone-toggle]");
await page.click("[data-testid=timezone-select]");
await page.locator('[aria-label="Timezone Select"]').fill("New York");
await page.keyboard.press("Enter");
await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", {
action: () => page.locator("[data-testid=update-eventtype]").click(),
});
await page.goto("/event-types");
const previewLink = await page
.locator("[data-testid=preview-link-button]")
.first()
.getAttribute("href");
await page.goto(previewLink ?? "");
const currentTimezone = page.locator('[data-testid="event-meta-current-timezone"]');
await expect(currentTimezone).toBeVisible();
await expect(currentTimezone).toHaveClass(/cursor-not-allowed/);
await expect(page.getByText("New York")).toBeVisible();
});
});
test.describe("Interface Language Tests", () => {
+15 -4
View File
@@ -871,6 +871,10 @@
"create_team": "Create Team",
"name": "Name",
"nameless_team": "Nameless Team",
"oauth_clients": "OAuth Clients",
"oauth_clients_description": "Manage OAuth clients for your organization",
"create_oauth_client": "Create OAuth Client",
"create_oauth_client_description": "Create a new OAuth client for third-party integrations",
"oauth_client_deletion_message": "OAuth client deleted successfully",
"create_new_team_description": "Create a new team to collaborate with users.",
"create_new_team": "Create a new team",
@@ -1836,6 +1840,8 @@
"edit_event_type": "Edit event type",
"only_admin_can_see_members_of_org": "This Organization is private, and only the organization's admin or owner can view its members.",
"only_admin_can_manage_sso_org": "Only the organization's admin or owner can manage SSO settings",
"only_admin_can_manage_directory_sync": "Only the organization's admin or owner can manage directory sync settings",
"only_admin_can_manage_oauth_clients": "Only the organization's admin or owner can manage OAuth clients",
"collective_scheduling": "Collective Scheduling",
"make_it_easy_to_book": "Make it easy to book your team when everyone is available.",
"find_the_best_person": "Find the best person available and cycle through your team.",
@@ -3302,15 +3308,20 @@
"error_creating_role": "Error creating role",
"error_updating_role": "Error updating role",
"pbac_desc_create_roles": "Create roles",
"pbac_resource_attributes": "Attributes",
"pbac_desc_view_organization_attributes": "View organization attributes",
"pbac_desc_update_organization_attributes": "Update organization attributes",
"pbac_desc_delete_organization_attributes": "Delete organization attributes",
"pbac_desc_create_organization_attributes": "Create organization attributes",
"pbac_desc_view_roles": "View roles",
"pbac_desc_update_roles": "Update roles",
"pbac_desc_delete_roles": "Delete roles",
"pbac_desc_manage_roles": "All actions on roles",
"pbac_desc_manage_roles": "All actions on roles across organization teams",
"pbac_desc_create_event_types": "Create event types",
"pbac_desc_view_event_types": "View event types",
"pbac_desc_update_event_types": "Update event types",
"pbac_desc_delete_event_types": "Delete event types",
"pbac_desc_manage_event_types": "All actions on event types",
"pbac_desc_manage_event_types": "All actions on event types across organization teams",
"pbac_desc_create_teams": "Create teams",
"pbac_desc_view_team_details": "View team details",
"pbac_desc_update_team_settings": "Update team settings",
@@ -3318,7 +3329,7 @@
"pbac_desc_invite_team_members": "Invite team members",
"pbac_desc_remove_team_members": "Remove team members",
"pbac_desc_change_team_member_role": "Change role of team members",
"pbac_desc_manage_teams": "All actions on teams",
"pbac_desc_manage_teams": "All actions on teams across organization teams",
"pbac_desc_create_organization": "Create organization",
"pbac_desc_view_organization_details": "View organization details",
"pbac_desc_list_organization_members": "List organization members",
@@ -3333,7 +3344,7 @@
"pbac_desc_view_organization_bookings": "View organization bookings",
"pbac_desc_view_booking_recordings": "View booking recordings",
"pbac_desc_update_bookings": "Update bookings",
"pbac_desc_manage_bookings": "All actions on bookings",
"pbac_desc_manage_bookings": "All actions on bookings across organization teams",
"pbac_desc_view_team_insights": "View team insights",
"pbac_desc_manage_team_insights": "Manage team insights",
"read_permission_auto_enabled_tooltip": "Read permission is automatically enabled when creating, updating, or deleting a resource",
@@ -60,6 +60,7 @@ type UserPageProps = {
| "length"
| "hidden"
| "lockTimeZoneToggleOnBookingPage"
| "lockedTimeZone"
| "requiresConfirmation"
| "canSendCalVideoTranscriptionEmails"
| "requiresBookerEmailVerification"
+10 -7
View File
@@ -3,8 +3,8 @@ import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { describe, expect, it } from "vitest";
import dayjs from "@calcom/dayjs";
import { getCheckBookingLimitsService } from "@calcom/lib/di/containers/booking-limits";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import { checkBookingLimits, checkBookingLimit } from "@calcom/lib/intervalLimits/server/checkBookingLimits";
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
type Mockdata = {
@@ -21,25 +21,27 @@ const MOCK_DATA: Mockdata = {
},
};
const checkBookingLimitsService = getCheckBookingLimitsService();
describe("Check Booking Limits Tests", () => {
it("Should return no errors", async () => {
prismaMock.booking.count.mockResolvedValue(0);
expect(
checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
checkBookingLimitsService.checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
).resolves.toBeTruthy();
});
it("Should throw an error", async () => {
// Mock there being two a day
prismaMock.booking.count.mockResolvedValue(2);
expect(
checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
checkBookingLimitsService.checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
).rejects.toThrowError();
});
it("Should pass with multiple booking limits", async () => {
prismaMock.booking.count.mockResolvedValue(0);
expect(
checkBookingLimits(
checkBookingLimitsService.checkBookingLimits(
{
PER_DAY: 1,
PER_WEEK: 2,
@@ -51,8 +53,9 @@ describe("Check Booking Limits Tests", () => {
});
it("Should pass with multiple booking limits with one undefined", async () => {
prismaMock.booking.count.mockResolvedValue(0);
expect(
checkBookingLimits(
checkBookingLimitsService.checkBookingLimits(
{
PER_DAY: 1,
PER_WEEK: undefined,
@@ -65,7 +68,7 @@ describe("Check Booking Limits Tests", () => {
it("Should handle multiple limits correctly", async () => {
prismaMock.booking.count.mockResolvedValue(1);
expect(
checkBookingLimit({
checkBookingLimitsService.checkBookingLimit({
key: "PER_DAY",
limitingNumber: 2,
eventStartDate: MOCK_DATA.startDate,
@@ -74,7 +77,7 @@ describe("Check Booking Limits Tests", () => {
).resolves.not.toThrow();
prismaMock.booking.count.mockResolvedValue(3);
expect(
checkBookingLimit({
checkBookingLimitsService.checkBookingLimit({
key: "PER_WEEK",
limitingNumber: 2,
eventStartDate: MOCK_DATA.startDate,
+19 -13
View File
@@ -1281,12 +1281,15 @@ describe("getSchedule", () => {
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([
{
start: `${plus3DateString}T04:00:00.000Z`,
end: `${plus3DateString}T05:59:59.000Z`,
},
]);
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({
success: true,
data: [
{
start: `${plus3DateString}T04:00:00.000Z`,
end: `${plus3DateString}T05:59:59.000Z`,
},
],
});
const scenarioData = {
eventTypes: [
@@ -1347,12 +1350,15 @@ describe("getSchedule", () => {
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([
{
start: `${plus3DateString}T04:00:00.000Z`,
end: `${plus3DateString}T05:59:59.000Z`,
},
]);
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({
success: true,
data: [
{
start: `${plus3DateString}T04:00:00.000Z`,
end: `${plus3DateString}T05:59:59.000Z`,
},
],
});
const scenarioData = {
eventTypes: [
@@ -1421,7 +1427,7 @@ describe("getSchedule", () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] });
const scenarioData = {
eventTypes: [
@@ -438,7 +438,6 @@ describe("handleChildrenEventTypes", () => {
schedulingType: SchedulingType.MANAGED,
requiresBookerEmailVerification: false,
lockTimeZoneToggleOnBookingPage: false,
lockedTimeZone: "Europe/London",
useEventTypeDestinationCalendarEmail: false,
workflows: [],
parentId: 1,
@@ -0,0 +1,180 @@
---
title: "How to Add a New Booking Chart to Cal.com Insights Page"
---
This guide walks you through creating a new booking chart component for the insights page, covering the entire stack from UI component to backend service.
## Overview
The insights booking system follows this architecture:
```
UI Component → tRPC Handler → Insights Service → Database Query → Response
```
<Steps>
<Step title="Create the UI Component">
Create your chart component in `packages/features/insights/components/booking/`:
```typescript
// packages/features/insights/components/booking/MyNewChart.tsx
import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Line, ResponsiveContainer } from "recharts";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
export const MyNewChart = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.myNewChartData.useQuery(insightsBookingParams, {
staleTime: 180000, // 3 minutes
refetchOnWindowFocus: false,
trpc: { context: { skipBatch: true } },
});
if (isPending) return <LoadingInsight />;
return (
<ChartCard title={t("my_new_chart_title")}>
{isSuccess && data?.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="value" stroke="#8884d8" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex h-64 items-center justify-center">
<p className="text-gray-500">{t("no_data_yet")}</p>
</div>
)}
</ChartCard>
);
};
```
</Step>
<Step title="Add Component to Barrel Export">
Update the booking components index file:
```typescript
// packages/features/insights/components/booking/index.ts
export { AverageEventDurationChart } from "./AverageEventDurationChart";
export { BookingKPICards } from "./BookingKPICards";
// ... existing exports
export { MyNewChart } from "./MyNewChart"; // Add this line
```
</Step>
<Step title="Add Component to Insights View">
Add your component to the main insights page:
```typescript
// apps/web/modules/insights/insights-view.tsx
import {
AverageEventDurationChart,
BookingKPICards, // ... existing imports
MyNewChart, // Add this import
} from "@calcom/features/insights/components/booking";
export default function InsightsPage() {
// ... existing code
return (
<div className="space-y-6">
{/* Existing components */}
<BookingKPICards />
<EventTrendsChart />
{/* Add your new chart */}
<MyNewChart />
{/* Other existing components */}
</div>
);
}
```
</Step>
<Step title="Create tRPC Handler">
Add the tRPC endpoint in the insights router using the `getInsightsBookingService()` DI container function:
```typescript
// packages/features/insights/server/trpc-router.ts
import { bookingRepositoryBaseInputSchema } from "@calcom/features/insights/server/raw-data.schema";
import { userBelongsToTeamProcedure } from "@calcom/trpc/server/procedures/authedProcedure";
import { TRPCError } from "@trpc/server";
export const insightsRouter = router({
// ... existing procedures
myNewChartData: userBelongsToTeamProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
// `createInsightsBookingService` is defined at the root level in this file
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getMyNewChartData();
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
});
```
</Step>
<Step title="Add Service Method to InsightsBookingBaseService">
Add your new method to the `InsightsBookingBaseService` class:
```typescript
// packages/lib/server/service/InsightsBookingBaseService.ts
export class InsightsBookingBaseService {
// ... existing methods
async getMyNewChartData() {
const baseConditions = await this.getBaseConditions();
// Example: Get booking counts by day using raw SQL for performance
const data = await this.prisma.$queryRaw<
Array<{
date: Date;
bookingsCount: number;
}>
>`
SELECT
DATE("createdAt") as date,
COUNT(*)::int as "bookingsCount"
FROM "BookingTimeStatusDenormalized"
WHERE ${baseConditions}
GROUP BY DATE("createdAt")
ORDER BY date ASC
`;
// Transform the data for the chart
return data.map((item) => ({
date: item.date.toISOString().split("T")[0], // Format as YYYY-MM-DD
value: item.bookingsCount,
}));
}
}
```
</Step>
## Best Practices
1. **Use `getInsightsBookingService()`**: Always use the DI container function for consistent service creation
2. **Raw SQL for Performance**: Use `$queryRaw` for complex aggregations and better performance
3. **Base Conditions**: Always use `await this.getBaseConditions()` for proper filtering and permissions
4. **Error Handling**: Wrap service calls in try-catch blocks with `TRPCError`
5. **Loading States**: Always show loading indicators with `LoadingInsight`
6. **Consistent Styling**: Use `recharts` for new charts.
7. **Date Handling**: Use `getDateRanges()` and `getTimeView()` for time-based charts
@@ -56,11 +56,11 @@ To ensure consistency and make files easy to fuzzy-find, we follow the naming co
**Pattern:**
Prisma<Entity>Repository.ts
Prisma`<Entity>`Repository.ts
**Examples:**
```ts
```js
// File: PrismaAppRepository.ts
export class PrismaAppRepository { ... }
@@ -80,11 +80,11 @@ Keep naming specific — avoid generic names like AppService.ts.
**Pattern:**
<Entity>Service.ts
`<Entity>`Service.ts
**Examples:**
```ts
```js
// File: MembershipService.ts
export class MembershipService { ... }
+5
View File
@@ -110,6 +110,11 @@
"icon": "code-simple",
"pages": ["developing/guides/embeds/embed-events"]
}
{
"group": "Insights",
"icon": "chart-bar",
"pages": ["developing/guides/insights/add-new-booking-charts"]
}
]
},
{
+1
View File
@@ -90,6 +90,7 @@ Below is a list of props that can be passed to the booker atom.
| customClassNames | No | To pass in custom classnames from outside for styling the atom |
| month | No | The exact month for displaying a user's availability; defaults to the current month |
| selectedDate | No | Default selected date for which the slot picker opens |
| startTime | No | Custom start time for the Booker that allows users to decide the first available date. Accepts JavaScript Date object or date string in format `YYYY-MM-DD` (e.g., `"2025-08-20"` or `new Date("2025-08-20")`) |
| hideBranding | No | For hiding any branding on the booker |
| isAway | No | Sets the booker component to the away state |
| allowsDynamicBooking | No | Boolean indicating if the booking is a dynamic booking |
+2 -1
View File
@@ -109,7 +109,7 @@
"prismock": "^1.33.4",
"resize-observer-polyfill": "^1.5.1",
"tsc-absolute": "^1.0.0",
"typescript": "^5.7.2",
"typescript": "5.9.0-beta",
"vitest": "^2.1.1",
"vitest-fetch-mock": "^0.3.0",
"vitest-mock-extended": "^2.0.2"
@@ -120,6 +120,7 @@
"@next/third-parties": "^14.2.5",
"@vercel/functions": "^1.4.0",
"city-timezones": "^1.2.1",
"date-fns-tz": "^3.2.0",
"eslint": "^8.34.0",
"p-limit": "^6.2.0",
"turbo": "^1.10.1"
+1 -1
View File
@@ -31,6 +31,6 @@
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"ts-node": "^10.9.1",
"typescript": "^5.7.2"
"typescript": "^5.9.0-beta"
}
}
@@ -6,6 +6,7 @@ export const AppSetupPageMap = {
zapier: import("../../zapier/pages/setup/_getServerSideProps"),
stripe: import("../../stripepayment/pages/setup/_getServerSideProps"),
hitpay: import("../../hitpay/pages/setup/_getServerSideProps"),
btcpayserver: import("../../btcpayserver/pages/setup/_getServerSideProps"),
};
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
@@ -16,6 +16,7 @@ export const AppSetupMap = {
stripe: dynamic(() => import("../../stripepayment/pages/setup")),
paypal: dynamic(() => import("../../paypal/pages/setup")),
hitpay: dynamic(() => import("../../hitpay/pages/setup")),
btcpayserver: dynamic(() => import("../../btcpayserver/pages/setup")),
};
export const AppSetupPage = (props: { slug: string }) => {
-1
View File
@@ -49,7 +49,6 @@ export const getCalendar = async (
log.warn(`calendar of type ${calendarType} is not implemented`);
return null;
}
log.info("Got calendarApp", calendarApp.lib.CalendarService);
const CalendarService = calendarApp.lib.CalendarService;
return new CalendarService(credential);
};
@@ -22,6 +22,7 @@ export const AppSettingsComponentsMap = {
export const EventTypeAddonMap = {
alby: dynamic(() => import("./alby/components/EventTypeAppCardInterface")),
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")),
btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppCardInterface")),
closecom: dynamic(() => import("./closecom/components/EventTypeAppCardInterface")),
fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")),
ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")),
@@ -54,6 +55,7 @@ export const EventTypeAddonMap = {
export const EventTypeSettingsMap = {
alby: dynamic(() => import("./alby/components/EventTypeAppSettingsInterface")),
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppSettingsInterface")),
btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppSettingsInterface")),
fathom: dynamic(() => import("./fathom/components/EventTypeAppSettingsInterface")),
ga4: dynamic(() => import("./ga4/components/EventTypeAppSettingsInterface")),
giphy: dynamic(() => import("./giphy/components/EventTypeAppSettingsInterface")),
@@ -4,6 +4,7 @@
**/
import { appKeysSchema as alby_zod_ts } from "./alby/zod";
import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod";
import { appKeysSchema as btcpayserver_zod_ts } from "./btcpayserver/zod";
import { appKeysSchema as closecom_zod_ts } from "./closecom/zod";
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
import { appKeysSchema as dub_zod_ts } from "./dub/zod";
@@ -54,6 +55,7 @@ import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appKeysSchemas = {
alby: alby_zod_ts,
basecamp3: basecamp3_zod_ts,
btcpayserver: btcpayserver_zod_ts,
closecom: closecom_zod_ts,
dailyvideo: dailyvideo_zod_ts,
dub: dub_zod_ts,
@@ -10,6 +10,7 @@ import autocheckin_config_json from "./autocheckin/config.json";
import baa_for_hipaa_config_json from "./baa-for-hipaa/config.json";
import basecamp3_config_json from "./basecamp3/config.json";
import bolna_config_json from "./bolna/config.json";
import btcpayserver_config_json from "./btcpayserver/config.json";
import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
import campfire_config_json from "./campfire/config.json";
import chatbase_config_json from "./chatbase/config.json";
@@ -117,6 +118,7 @@ export const appStoreMetadata = {
"baa-for-hipaa": baa_for_hipaa_config_json,
basecamp3: basecamp3_config_json,
bolna: bolna_config_json,
btcpayserver: btcpayserver_config_json,
caldavcalendar: caldavcalendar__metadata_ts,
campfire: campfire_config_json,
chatbase: chatbase_config_json,
@@ -4,6 +4,7 @@
**/
import { appDataSchema as alby_zod_ts } from "./alby/zod";
import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod";
import { appDataSchema as btcpayserver_zod_ts } from "./btcpayserver/zod";
import { appDataSchema as closecom_zod_ts } from "./closecom/zod";
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
import { appDataSchema as dub_zod_ts } from "./dub/zod";
@@ -54,6 +55,7 @@ import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appDataSchemas = {
alby: alby_zod_ts,
basecamp3: basecamp3_zod_ts,
btcpayserver: btcpayserver_zod_ts,
closecom: closecom_zod_ts,
dailyvideo: dailyvideo_zod_ts,
dub: dub_zod_ts,
@@ -11,6 +11,7 @@ export const apiHandlers = {
"baa-for-hipaa": import("./baa-for-hipaa/api"),
basecamp3: import("./basecamp3/api"),
bolna: import("./bolna/api"),
btcpayserver: import("./btcpayserver/api"),
caldavcalendar: import("./caldavcalendar/api"),
campfire: import("./campfire/api"),
chatbase: import("./chatbase/api"),
@@ -0,0 +1,8 @@
---
items:
- website.png
- integrations.png
- checkout.png
---
{DESCRIPTION}
@@ -0,0 +1,19 @@
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import { createDefaultInstallation } from "../../_utils/installation";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
redirect: {
url: "/apps/btcpayserver/setup",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};
export default handler;
@@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as webhook, config } from "./webhook";
@@ -0,0 +1,100 @@
import crypto from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import getRawBody from "raw-body";
import { z } from "zod";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
import { PrismaBookingPaymentRepository as BookingPaymentRepository } from "@calcom/lib/server/repository/PrismaBookingPaymentRepository";
import appConfig from "../config.json";
import { btcpayCredentialKeysSchema } from "../lib/btcpayCredentialKeysSchema";
export const config = { api: { bodyParser: false } };
function verifyBTCPaySignature(rawBody: Buffer, expectedSignature: string, webhookSecret: string): string {
const hmac = crypto.createHmac("sha256", webhookSecret);
hmac.update(rawBody);
const computedSignature = hmac.digest("hex");
const hexRegex = /^[0-9a-fA-F]+$/;
if (!hexRegex.test(computedSignature) || !hexRegex.test(expectedSignature)) {
throw new HttpCode({ statusCode: 400, message: "signature mismatch" });
}
return computedSignature;
}
const btcpayWebhookSchema = z.object({
deliveryId: z.string(),
webhookId: z.string(),
originalDeliveryId: z.string().optional(),
isRedelivery: z.boolean(),
type: z.string(),
timestamp: z.number(),
storeId: z.string(),
invoiceId: z.string(),
metadata: z.object({}).optional(),
manuallyMarked: z.boolean().optional(),
overPaid: z.boolean(),
});
const SUPPORTED_INVOICE_EVENTS = ["InvoiceSettled", "InvoiceProcessing"];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "POST") throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
const rawBody = await getRawBody(req);
const bodyAsString = rawBody.toString();
const signature = req.headers["btcpay-sig"] || req.headers["BTCPay-Sig"];
if (!signature || typeof signature !== "string" || !signature.startsWith("sha256="))
throw new HttpCode({ statusCode: 401, message: "Missing or invalid signature format" });
const webhookData = btcpayWebhookSchema.safeParse(JSON.parse(bodyAsString));
if (!webhookData.success) return res.status(400).json({ message: "Invalid webhook payload" });
const data = webhookData.data;
if (!SUPPORTED_INVOICE_EVENTS.includes(data.type))
return res.status(200).send({ message: "Webhook received but ignored" });
const bookingPaymentRepository = new BookingPaymentRepository();
const payment = await bookingPaymentRepository.findByExternalIdIncludeBookingUserCredentials(
data.invoiceId,
appConfig.type
);
if (!payment) throw new HttpCode({ statusCode: 404, message: "Cal.com: payment not found" });
if (payment.success) return res.status(200).send({ message: "Payment already registered" });
const key = payment.booking?.user?.credentials?.[0].key;
if (!key) throw new HttpCode({ statusCode: 404, message: "Cal.com: credentials not found" });
const parsedKey = btcpayCredentialKeysSchema.safeParse(key);
if (!parsedKey.success)
throw new HttpCode({ statusCode: 400, message: "Cal.com: Invalid BTCPay credentials" });
const { webhookSecret, storeId } = parsedKey.data;
if (storeId !== data.storeId)
throw new HttpCode({ statusCode: 400, message: "Cal.com: Store ID mismatch" });
const expectedSignature = signature.split("=")[1];
const computedSignature = verifyBTCPaySignature(rawBody, expectedSignature, webhookSecret);
if (computedSignature.length !== expectedSignature.length) {
throw new HttpCode({ statusCode: 400, message: "signature mismatch" });
}
const isValid = crypto.timingSafeEqual(
Buffer.from(computedSignature, "hex"),
Buffer.from(expectedSignature, "hex")
);
if (!isValid) throw new HttpCode({ statusCode: 400, message: "signature mismatch" });
await handlePaymentSuccess(payment.id, payment.bookingId);
return res.status(200).json({ success: true });
} catch (_err) {
const err = getErrorFromUnknown(_err);
const statusCode = err instanceof HttpCode ? err.statusCode : 500;
return res.status(statusCode).send({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.stack,
});
}
}
@@ -0,0 +1,150 @@
"use client";
import { useEffect, useState } from "react";
import z from "zod";
import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment";
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button } from "@calcom/ui/components/button";
import { Spinner } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
interface IPaymentComponentProps {
payment: {
// Will be parsed on render
data: unknown;
};
paymentPageProps: PaymentPageProps;
}
// Create zod schema for data
const PaymentBTCPayDataSchema = z.object({
invoice: z.object({ checkoutLink: z.string() }).required(),
});
export const BtcpayPaymentComponent = (props: IPaymentComponentProps) => {
const { payment } = props;
const { data } = payment;
const [iframeLoaded, setIframeLoaded] = useState(false);
const { copyToClipboard, isCopied } = useCopy();
const wrongUrl = (
<>
<p className="mt-3 text-center">Couldn&apos;t obtain payment URL</p>
</>
);
const parsedData = PaymentBTCPayDataSchema.safeParse(data);
if (!parsedData.success || !parsedData.data?.invoice?.checkoutLink) return wrongUrl;
const checkoutUrl = parsedData.data.invoice.checkoutLink;
const handleOpenInNewTab = () => {
window.open(checkoutUrl, "_blank", "noopener,noreferrer");
};
return (
<div className="mb-4 mt-8 flex h-full w-full flex-col items-center justify-center gap-4">
<PaymentChecker {...props.paymentPageProps} />
{!iframeLoaded && (
<div className="flex items-center justify-center">
<Spinner className="mr-2 h-5 w-5" />
<p>Loading payment page...</p>
</div>
)}
<div className={`w-full ${iframeLoaded ? "block" : "hidden"}`}>
<iframe
src={checkoutUrl}
title="BTCPay Payment"
className="h-[1000px] w-full rounded-md border-0"
onLoad={() => setIframeLoaded(true)}
/>
</div>
<div className="flex flex-row flex-wrap items-center justify-center gap-4">
<Button
size="sm"
color="secondary"
onClick={() => copyToClipboard(checkoutUrl)}
className="text-subtle rounded-md"
StartIcon={isCopied ? "clipboard-check" : "clipboard"}>
Copy Payment Link
</Button>
<Button onClick={handleOpenInNewTab}>Open in New Tab</Button>
</div>
</div>
);
};
type PaymentCheckerProps = PaymentPageProps;
function PaymentChecker(props: PaymentCheckerProps) {
// TODO: move booking success code to a common lib function
// TODO: subscribe rather than polling
const searchParams = useCompatSearchParams();
const bookingSuccessRedirect = useBookingSuccessRedirect();
const utils = trpc.useUtils();
const { t } = useLocale();
useEffect(() => {
if (searchParams === null) {
return;
}
// use closure to ensure non-nullability
const sp = searchParams;
const interval = setInterval(() => {
(async () => {
try {
if (props.booking.status === "ACCEPTED") {
return;
}
const { booking: bookingResult } = await utils.viewer.bookings.find.fetch({
bookingUid: props.booking.uid,
});
if (bookingResult?.paid) {
showToast("Payment successful", "success");
const params: {
uid: string;
email: string | null;
location: string;
} = {
uid: props.booking.uid,
email: sp.get("email"),
location: t("web_conferencing_details_to_follow"),
};
bookingSuccessRedirect({
successRedirectUrl: props.eventType.successRedirectUrl,
query: params,
booking: props.booking,
forwardParamsSuccessRedirect: props.eventType.forwardParamsSuccessRedirect,
});
}
} catch (e) {}
})();
}, 2000);
return () => clearInterval(interval);
}, [
bookingSuccessRedirect,
props.booking,
props.booking.id,
props.booking.status,
props.eventType.id,
props.eventType.successRedirectUrl,
props.eventType.forwardParamsSuccessRedirect,
props.payment.success,
searchParams,
t,
utils.viewer.bookings,
]);
return null;
}
@@ -0,0 +1,50 @@
import { usePathname } from "next/navigation";
import { useState } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import checkForMultiplePaymentApps from "../../_utils/payments/checkForMultiplePaymentApps";
import type { appDataSchema } from "../zod";
import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface";
type Option = { value: string; label: string };
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
eventType,
app,
eventTypeFormMetadata,
}) {
const { t } = useLocale();
const pathname = usePathname();
const { getAppData, setAppData, disabled } = useAppContextWithSchema<typeof appDataSchema>();
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
const otherPaymentAppEnabled = checkForMultiplePaymentApps(eventTypeFormMetadata);
const shouldDisableSwitch = !requirePayment && otherPaymentAppEnabled;
return (
<AppCard
returnTo={`${WEBAPP_URL}${pathname}?tabName=apps`}
app={app}
switchChecked={requirePayment}
switchOnClick={(e) => {
setRequirePayment(e);
}}
description={<>Add lightning payments to your events and booking</>}
disableSwitch={shouldDisableSwitch}
switchTooltip={shouldDisableSwitch ? t("other_payment_app_enabled") : undefined}>
<EventTypeAppSettingsInterface
eventType={eventType}
slug={app.slug}
disabled={disabled}
getAppData={getAppData}
setAppData={setAppData}
/>
</AppCard>
);
};
export default EventTypeAppCard;
@@ -0,0 +1,134 @@
import { useState, useEffect } from "react";
import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert } from "@calcom/ui/components/alert";
import { Select } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import {
currencyOptions,
convertToSmallestCurrencyUnit,
convertFromSmallestToPresentableCurrencyUnit,
} from "../lib/currencyOptions";
import { BTCPayPaymentOptions as paymentOptions } from "../zod";
type Option = { value: string; label: string };
const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
eventType,
getAppData,
setAppData,
}) => {
const { t } = useLocale();
const price = getAppData("price");
const currency = getAppData("currency") || (currencyOptions.length > 0 ? currencyOptions[0].value : "");
const [selectedCurrency, setSelectedCurrency] = useState(
currencyOptions.find((c) => c.value === currency) ||
(currencyOptions.length > 0
? {
label: currencyOptions[0].label,
value: currencyOptions[0].value,
}
: null)
);
const paymentOption = getAppData("paymentOption");
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
label: paymentOptions.length > 0 ? paymentOptions[0].label : "",
value: paymentOptions.length > 0 ? paymentOptions[0].value : "",
};
const seatsEnabled = !!eventType.seatsPerTimeSlot;
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
// make sure a currency is selected
useEffect(() => {
if (requirePayment && !getAppData("currency")) {
setAppData("currency", currencyOptions[0].value);
}
}, [requirePayment, getAppData, setAppData]);
const disableDecimalPlace = (value: number) => {
return Math.floor(value);
};
return (
<>
{recurringEventDefined ? (
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
) : (
requirePayment && (
<>
<div className="mt-4 block items-center justify-start sm:flex sm:space-x-2">
<TextField
label={t("price")}
className="text-black"
addOnClassname="h-[38px]"
min="1"
type="number"
required
placeholder="Price"
onChange={(e) => {
setAppData("price", convertToSmallestCurrencyUnit(Number(e.target.value), currency));
}}
value={
price && price > 0
? disableDecimalPlace(convertFromSmallestToPresentableCurrencyUnit(price, currency))
: undefined
}
/>
</div>
<div className="mt-5 w-60">
<label className="text-default block text-sm font-medium" htmlFor="currency">
{t("currency")}
</label>
<Select
variant="default"
options={currencyOptions}
value={selectedCurrency}
className="text-black"
defaultValue={selectedCurrency}
onChange={(e) => {
if (e) {
setSelectedCurrency(e);
setAppData("currency", e.value);
}
}}
/>
</div>
<div className="mt-2 w-60">
<label className="text-default block text-sm font-medium" htmlFor="paymentOption">
{t("payment_option")}
</label>
<Select<Option>
defaultValue={
paymentOptionSelectValue
? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
: paymentOptions.length > 0
? { ...paymentOptions[0], label: t(paymentOptions[0].label) }
: undefined
}
options={paymentOptions.map((option) => {
return { ...option, label: t(option.label) || option.label };
})}
onChange={(input) => {
if (input) setAppData("paymentOption", input.value);
}}
className="mb-1 h-[38px] w-full"
isDisabled={seatsEnabled}
/>
</div>
{seatsEnabled && paymentOption === "HOLD" && (
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
)}
</>
)
)}
</>
);
};
export default EventTypeAppSettingsInterface;
@@ -0,0 +1,186 @@
"use client";
import classNames from "classnames";
import type { FormEvent } from "react";
import React, { forwardRef, useState, useEffect, useId, useCallback } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label } from "@calcom/ui/components/form";
import { Input } from "@calcom/ui/components/form";
import type { InputFieldProps } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Skeleton } from "@calcom/ui/components/skeleton";
type AddonProps = {
children: React.ReactNode;
className?: string;
error?: boolean;
onClickAddon?: () => void;
};
const Addon = ({ children, className, error }: AddonProps) => (
<div
className={classNames(
"addon-wrapper border-default [input:hover_+_&]:border-emphasis [input:hover_+_&]:border-l-default [&:has(+_input:hover)]:border-emphasis [&:has(+_input:hover)]:border-r-default h-9 border px-3 transition disabled:hover:cursor-not-allowed",
className
)}>
<div
className={classNames(
"min-h-9 flex flex-col justify-center text-sm leading-7",
error ? "text-error" : "text-default"
)}>
<span
className="flex max-w-2xl overflow-y-auto whitespace-nowrap"
style={{
WebkitOverflowScrolling: "touch",
scrollbarWidth: "none",
overflow: "-ms-scroll-chaining",
msOverflowStyle: "-ms-autohiding-scrollbar",
}}>
{children}
</span>
</div>
</div>
);
export const KeyField: React.FC<InputFieldProps & { defaultValue: string }> = forwardRef<
HTMLInputElement,
InputFieldProps & { defaultValue: string }
>(function KeyField(props, ref) {
const id = useId();
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [currentValue, setCurrentValue] = useState<string>("");
const toggleIsPasswordVisible = useCallback(
() => setIsPasswordVisible(!isPasswordVisible),
[isPasswordVisible, setIsPasswordVisible]
);
const { t: _t, isLocaleReady, i18n } = useLocale();
const t = props.t || _t;
const name = props.name || "";
const {
label = t(name),
labelProps,
labelClassName,
LockedIcon,
placeholder = isLocaleReady && i18n.exists(`${name}_placeholder`) ? t(`${name}_placeholder`) : "",
className,
addOnLeading,
addOnClassname,
inputIsFullWidth,
labelSrOnly,
noLabel,
containerClassName,
readOnly,
showAsteriskIndicator,
defaultValue,
...passThrough
} = props;
useEffect(() => {
if (currentValue.trim().length === 0) {
setIsPasswordVisible(true);
}
}, [currentValue]);
useEffect(() => {
setCurrentValue(defaultValue);
if (defaultValue.length > 0) {
setIsPasswordVisible(false);
}
}, [defaultValue]);
const getHiddenKey = (): string => {
let hiddenKey = currentValue;
const length = currentValue.length;
if (length > 6) {
const start = currentValue.slice(0, 3);
const end = currentValue.slice(length - 3);
hiddenKey = `${start}${"*".repeat(length - 6)}${end}`;
}
return hiddenKey;
};
const onInput = (event: FormEvent<HTMLInputElement>) => {
const target = event.target as HTMLInputElement;
const fullValue = target.value;
setCurrentValue(fullValue);
target.value = fullValue;
};
return (
<div className={classNames(containerClassName)}>
{!!label && !noLabel && (
<Skeleton
as={Label}
htmlFor={id}
loadingClassName="w-16"
{...labelProps}
className={classNames(labelClassName, labelSrOnly && "sr-only", props.error && "text-error")}>
{label}
{showAsteriskIndicator && !readOnly && passThrough.required ? (
<span className="text-default ml-1 font-medium">*</span>
) : null}
{LockedIcon}
</Skeleton>
)}
<div
dir="ltr"
className="focus-within:ring-brand-default group relative mb-1 flex items-center rounded-md transition focus-within:outline-none focus-within:ring-2">
{addOnLeading && (
<Addon className={classNames("ltr:rounded-l-md rtl:rounded-r-md", addOnClassname)}>
{addOnLeading}
</Addon>
)}
<Input
id={id}
type="text"
placeholder={placeholder}
isFullWidth={inputIsFullWidth}
className={classNames(
className,
"disabled:bg-subtle disabled:hover:border-subtle mb-0 rounded-r-none border-r-0 disabled:cursor-not-allowed",
addOnLeading && "rounded-l-none border-l-0",
isPasswordVisible && "inline-block",
!isPasswordVisible && "hidden",
"!my-0 !ring-0"
)}
{...passThrough}
ref={ref}
onInput={onInput}
/>
<Input
type="text"
isFullWidth={inputIsFullWidth}
className={classNames(
className,
"disabled:bg-subtle disabled:hover:border-subtle mb-0 rounded-r-none border-r-0 disabled:cursor-not-allowed",
addOnLeading && "rounded-l-none border-l-0",
!isPasswordVisible && "inline-block",
isPasswordVisible && "hidden",
"!my-0 !ring-0"
)}
disabled
value={getHiddenKey()}
/>
<Addon className={classNames("ltr:rounded-r-md rtl:rounded-l-md", addOnClassname)}>
<button
className="text-emphasis h-9"
tabIndex={-1}
type="button"
onClick={() => toggleIsPasswordVisible()}>
{isPasswordVisible ? (
<Icon name="eye-off" className="h-4 w-4 stroke-[2.5px]" />
) : (
<Icon name="eye" className="h-4 w-4 stroke-[2.5px]" />
)}
</button>
</Addon>
</div>
</div>
);
});
export default KeyField;
@@ -0,0 +1,16 @@
{
"name": "BTCPayServer",
"slug": "btcpayserver",
"type": "btcpayserver_payment",
"logo": "icon.svg",
"url": "https://btcpayserver.org",
"variant": "payment",
"categories": ["payment"],
"publisher": "BTCPay Server Team",
"email": "chat.btcpayserver.org",
"description": "BTCPay Server is a self-hosted open source Bitcoin payment processor. Start receiving bitcoin payments for your events and bookings.",
"extendsFeature": "EventType",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "event-type-app-card"
}

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