Compare commits

...
Author SHA1 Message Date
github-actions[bot] 8b3fbb672b chore: version packages (#22950)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-14 09:18:13 +00:00
Alex van AndelandHariom Balhara 2d5a86774e fix: Type error in inviteMember.handler test (#23070)
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-08-14 01:48:09 +00:00
emrysal 0865104a77 chore: release v5.5.17
Post release / release (push) Canceled after 0s
2025-08-14 00:13:46 +00:00
1914b37ee4 feat: add PATCH endpoint for updating Google Calendar events (#22339)
* feat: add PATCH endpoint for updating Google Calendar events

- Create UpdateUnifiedCalendarEventInput DTO with optional fields for partial updates
- Add PATCH /:calendar/event/:eventUid endpoint to CalUnifiedCalendarsController
- Extend GoogleCalendarService with updateEventDetails method
- Support updating title, description, start/end times for Google Calendar events
- Follow existing patterns for validation, error handling, and response transformation
- Initially support Google Calendar only with extensibility for other providers

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

* feat: expand PATCH endpoint to support all calendar event fields

- Add comprehensive field support to UpdateUnifiedCalendarEventInput DTO
- Include locations, attendees, status, and hosts fields with proper validation
- Expand GoogleCalendarService.updateEventDetails to handle complex field transformations
- Add helper methods for response status and event status mapping
- Preserve existing transformation logic from GoogleCalendarEventOutputPipe
- Support conferenceData updates with conferenceDataVersion parameter

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

* fix: add support for simple location field in Google Calendar events

- Add handling for geographic location field in addition to conferenceData
- Filter non-video locations for the simple location string field
- Preserve existing conferenceData support for video meetings
- Verified against Google Calendar API documentation

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

* refactor: extract transformation logic into GoogleCalendarEventInputPipe

- Create GoogleCalendarEventInputPipe following existing patterns
- Extract transformation logic from updateEventDetails method
- Move mapping methods to the new pipe for better organization
- Improve code reusability and maintainability

Addresses PR comment requesting transformation logic extraction.

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

* refactor: remove locations field and simplify calendar event input handling

* update docs

* refactor: remove hosts field from the update endpoint

* fix: exclude organizer from attendees list in Google Calendar event transformation

* feat: implement organizer preservation logic in PATCH endpoint

- Fetch existing Google Calendar event data before transformation
- Extract organizer attendees from existing event and merge with user-provided attendees
- Prevent accidental removal of organizers when users update attendees
- Add custom interface for GoogleCalendarEventInputPipe to support optional existingEvent parameter
- Preserve organizers unless user explicitly provides hosts field in update request

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

* feat: add hosts array handling to PATCH endpoint

- Add hosts field back to UpdateUnifiedCalendarEventInput DTO
- Implement transformAttendeesWithHostsHandling method in GoogleCalendarEventInputPipe
- Convert hosts to attendees with organizer: true for Google Calendar API
- When hosts are provided, they replace existing organizers (not merge)
- Maintain backward compatibility with existing organizer preservation logic

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

* feat: implement sophisticated attendee update logic with preservation and deletion support

- Add UpdateCalendarEventAttendee class with action: 'delete' support
- Implement attendee preservation logic that starts with existing Google Calendar attendees
- Support explicit attendee deletion using action: 'delete' field
- Update existing attendees when provided without action field
- Add new attendees that don't exist in current event
- Preserve organizer status when updating existing attendees
- Fetch existing event data when attendees OR hosts are being updated
- Maintain hosts array handling that replaces existing organizers

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

* feat: add host management and attendee deletion to calendar event API

* refactor: improve readability of transformAttendeesWithHostsHandling and restrict hosts to responseStatus updates only

- Break down complex transformAttendeesWithHostsHandling method into smaller, more readable helper functions:
  - preserveExistingAttendees: handles existing attendee preservation
  - processAttendeeDeletions: handles attendee deletion logic
  - processAttendeeUpdatesAndAdditions: handles attendee updates and additions
  - replaceHostsWithUpdatedOnes: handles host replacement with restrictions
- Create UpdateCalendarEventHost class that only allows responseStatus updates
- Restrict hosts array to prevent changes to name, email, and other fields
- Preserve existing host displayName from Google Calendar when updating responseStatus

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

* feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data

- Remove email field from UpdateCalendarEventHost class
- Update replaceHostsWithUpdatedOnes to preserve all host data except responseStatus
- Apply responseStatus updates to all existing organizers from Google Calendar
- Prevent users from updating host email or name fields

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

* Revert "feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data"

This reverts commit 22d07ce0cb13e71661522b2fd8a237d8b6e0809c.

* refactor: replace optional flag with organizer property in calendar event attendees

* refactor: remove organizer field from calendar event attendee input

* feat: add comprehensive test suites for calendar transformation pipes

- Create google-calendar-event-input.pipe.spec.ts with 67 test cases
- Create get-calendar-event-details-output-pipe.spec.ts with 35 test cases
- Follow event-types transformer test patterns with separate describe blocks
- Cover all transformation functions including edge cases and null handling
- Test attendee preservation, deletion, host management, and status transformations
- Ensure comprehensive coverage for sophisticated attendee update logic

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

* fix: remove invalid optional properties from calendar pipe tests

- Remove optional: false from line 85 in 'should transform attendees without existing event' test case
- Remove optional: true from line 388 in 'should update existing attendee' test case
- Remove optional: false from line 421 in 'should add new attendee' test case
- Update expected outputs to match actual pipe implementation behavior
- All 30 tests now pass locally

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

* fix: add platform library path mappings to Jest configuration

- Resolves module resolution errors for @calcom/platform-libraries/* imports in Jest
- Ensures test suite can run without 'Cannot find module' errors
- Adds comprehensive path mappings matching TypeScript configuration

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

* fix: add @calcom/dayjs path mapping to Jest configuration

- Add missing moduleNameMapper entry for @calcom/dayjs to resolve TypeScript path mappings in Jest
- Fixes TypeError: Cannot read properties of undefined (reading 'extend') in calendar pipe tests
- Calendar transformation pipe tests now pass successfully

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

* fix: configure Jest to use dayjs mock system for consistent test environment

- Remove @calcom/dayjs path mapping from Jest config
- Add setupFilesAfterEnv to automatically import dayjs mock
- Update dayjs mock with comprehensive jest.fn() implementation
- Add @calcom/platform-libraries/repositories and @calcom/prisma/client mappings
- Resolves dayjs TypeError in slots.service.spec.ts and other test files

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

* fix: complete shared Google event data with required properties for test suites

- Add missing required properties to sharedGoogleEvent in both test files
- Fix TypeScript compilation errors in calendar transformation pipe tests
- Correct test assertion for attendees filtering logic in output pipe
- All 11 test suites now pass with 199 tests total

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

* undo extra changes

* better tests

* fix: update calendar API endpoints to use plural events in path

* update documentation

* fix: Restrict updates to only the responseStatus field for hosts

* feat: add PATCH endpoint for updating Google Calendar events

- Create UpdateUnifiedCalendarEventInput DTO with optional fields for partial updates
- Add PATCH /:calendar/event/:eventUid endpoint to CalUnifiedCalendarsController
- Extend GoogleCalendarService with updateEventDetails method
- Support updating title, description, start/end times for Google Calendar events
- Follow existing patterns for validation, error handling, and response transformation
- Initially support Google Calendar only with extensibility for other providers

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

* feat: expand PATCH endpoint to support all calendar event fields

- Add comprehensive field support to UpdateUnifiedCalendarEventInput DTO
- Include locations, attendees, status, and hosts fields with proper validation
- Expand GoogleCalendarService.updateEventDetails to handle complex field transformations
- Add helper methods for response status and event status mapping
- Preserve existing transformation logic from GoogleCalendarEventOutputPipe
- Support conferenceData updates with conferenceDataVersion parameter

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

* fix: add support for simple location field in Google Calendar events

- Add handling for geographic location field in addition to conferenceData
- Filter non-video locations for the simple location string field
- Preserve existing conferenceData support for video meetings
- Verified against Google Calendar API documentation

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

* refactor: extract transformation logic into GoogleCalendarEventInputPipe

- Create GoogleCalendarEventInputPipe following existing patterns
- Extract transformation logic from updateEventDetails method
- Move mapping methods to the new pipe for better organization
- Improve code reusability and maintainability

Addresses PR comment requesting transformation logic extraction.

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

* refactor: remove locations field and simplify calendar event input handling

* update docs

* refactor: remove hosts field from the update endpoint

* fix: exclude organizer from attendees list in Google Calendar event transformation

* feat: implement organizer preservation logic in PATCH endpoint

- Fetch existing Google Calendar event data before transformation
- Extract organizer attendees from existing event and merge with user-provided attendees
- Prevent accidental removal of organizers when users update attendees
- Add custom interface for GoogleCalendarEventInputPipe to support optional existingEvent parameter
- Preserve organizers unless user explicitly provides hosts field in update request

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

* feat: add hosts array handling to PATCH endpoint

- Add hosts field back to UpdateUnifiedCalendarEventInput DTO
- Implement transformAttendeesWithHostsHandling method in GoogleCalendarEventInputPipe
- Convert hosts to attendees with organizer: true for Google Calendar API
- When hosts are provided, they replace existing organizers (not merge)
- Maintain backward compatibility with existing organizer preservation logic

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

* feat: implement sophisticated attendee update logic with preservation and deletion support

- Add UpdateCalendarEventAttendee class with action: 'delete' support
- Implement attendee preservation logic that starts with existing Google Calendar attendees
- Support explicit attendee deletion using action: 'delete' field
- Update existing attendees when provided without action field
- Add new attendees that don't exist in current event
- Preserve organizer status when updating existing attendees
- Fetch existing event data when attendees OR hosts are being updated
- Maintain hosts array handling that replaces existing organizers

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

* feat: add host management and attendee deletion to calendar event API

* refactor: improve readability of transformAttendeesWithHostsHandling and restrict hosts to responseStatus updates only

- Break down complex transformAttendeesWithHostsHandling method into smaller, more readable helper functions:
  - preserveExistingAttendees: handles existing attendee preservation
  - processAttendeeDeletions: handles attendee deletion logic
  - processAttendeeUpdatesAndAdditions: handles attendee updates and additions
  - replaceHostsWithUpdatedOnes: handles host replacement with restrictions
- Create UpdateCalendarEventHost class that only allows responseStatus updates
- Restrict hosts array to prevent changes to name, email, and other fields
- Preserve existing host displayName from Google Calendar when updating responseStatus

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

* feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data

- Remove email field from UpdateCalendarEventHost class
- Update replaceHostsWithUpdatedOnes to preserve all host data except responseStatus
- Apply responseStatus updates to all existing organizers from Google Calendar
- Prevent users from updating host email or name fields

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

* Revert "feat: restrict host updates to responseStatus only, preserve email and name from Google Calendar data"

This reverts commit 22d07ce0cb13e71661522b2fd8a237d8b6e0809c.

* feat: add comprehensive test suites for calendar transformation pipes

- Create google-calendar-event-input.pipe.spec.ts with 67 test cases
- Create get-calendar-event-details-output-pipe.spec.ts with 35 test cases
- Follow event-types transformer test patterns with separate describe blocks
- Cover all transformation functions including edge cases and null handling
- Test attendee preservation, deletion, host management, and status transformations
- Ensure comprehensive coverage for sophisticated attendee update logic

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

* refactor: replace optional flag with organizer property in calendar event attendees

* refactor: remove organizer field from calendar event attendee input

* fix: remove invalid optional properties from calendar pipe tests

- Remove optional: false from line 85 in 'should transform attendees without existing event' test case
- Remove optional: true from line 388 in 'should update existing attendee' test case
- Remove optional: false from line 421 in 'should add new attendee' test case
- Update expected outputs to match actual pipe implementation behavior
- All 30 tests now pass locally

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

* fix: complete shared Google event data with required properties for test suites

- Add missing required properties to sharedGoogleEvent in both test files
- Fix TypeScript compilation errors in calendar transformation pipe tests
- Correct test assertion for attendees filtering logic in output pipe
- All 11 test suites now pass with 199 tests total

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

* fix: resolve TypeScript compilation errors in calendar transformation pipe tests

- Add comprehensive Jest mocks for @calcom/prisma/client, @calcom/dayjs, CalendarManager, and delegationCredential
- Fix RedisService AbortSignal.timeout() usage
- Update UserAvailabilityService import path to use @calcom/lib/getUserAvailability
- Configure Jest moduleNameMapper to handle ES module imports and package.json mocking
- All 11 test suites now pass with 199 tests total

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

* fix: use proper CacheService dependency injection in AvailableSlotsService

- Replace direct CacheService instantiation with dependency injection
- Update CacheService import path to use correct calendar-cache module
- Fix repository and service dependencies for proper type safety
- Resolves TypeScript compilation errors in slots service tests

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

* fix: remove duplicate test file causing TypeScript compilation error

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

* undo extra changes

* undo extra changes

* fix: remove the action: "delete" key from attendees

* fix: simplify tests

* update docs

* undo: remove hosts

* Update pre-commit

* feat: add alternate route for calendar event retrieval and simplify test fixtures

* remove userId -- unused

* undo

---------

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>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-08-13 17:24:19 +01:00
Lauris Skraucis 58ef0abb99 Revert "fix: managed orgs get bookings (#23063)" (#23066)
This reverts commit 13ba680a73.
2025-08-13 15:54:49 +00:00
Lauris Skraucis 13ba680a73 fix: managed orgs get bookings (#23063)
* fix: bookings handler scope to provided orgId

* fix: private link input body

* tests

* chore: bump platform libs

* fix: cleanup test org

* refactor get handler

* refactor test
2025-08-13 14:58:10 +00:00
Benny JooandAlex van Andel f40c6da93b fix: use server fetched user for all sub pages in /getting-started (#23064)
* use server fetched user for all sub pages

* Wrap setAnyInstalledVideoApps call in useEffect

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-13 14:35:30 +00:00
Somay Chauhan fc2e81e89a feat: add toggle for no availability dialog (#23052)
* feat: add toggle for no availability dialog

* Create great-hands-lick.md

* fix: correct spelling from showNoAvailabilityDailog to showNoAvailabilityDialog

* remove comment and redundant
2025-08-13 13:21:56 +00:00
Somay Chauhan 293dd1a140 Fix: lock timezone on booking page breaks atoms (#23060)
* fix: eventTypeSettings atom crashed on enabling `Lock timezone on booking page`

* add changelog
2025-08-13 13:03:35 +00:00
Hariom Balhara b7fda31678 fix: Issues observed when inviting members to organization either through teams migration during onboarding or explicit user invite (#22901)
* fix: make team migrations sequential to avoid race conditions

Instead of processing team migrations in parallel using Promise.all,
process them sequentially to avoid race conditions when creating
profiles and memberships for team members.

This simplifies the implementation and avoids the need for complex
retry logic or database-level concurrency controls.

* Promise.all accepts an array of promises and not an array with array in it as the first item. This fixes the issue where these promises were unhandled

* Fix the issue where an existing pending membership with team stays pending even though theorganization membership gets created as accepted
2025-08-13 10:48:17 +00:00
Benny Joo 94a04f0dc2 remove network level caching for i18n service (#23058) 2025-08-13 09:39:00 +00:00
RomitandDevanshu Sharma 2338f85dcf fix: fixed team event form ui (#22005)
* fixed team event form ui

* edge cases for some mobiles

* revert with removed padding

* chore

---------

Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-08-13 05:51:22 +00:00
KartikandKartik Saini fbaccc8e5f fix: prevent dropdown arrow from centering when Select height increases (#22877)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-13 04:38:48 +00:00
Alex van Andel 750db38d38 fix: Crash of onboarding connected-video page (#23047) 2025-08-13 02:10:22 +00:00
emrysal 8829e1ca3f chore: release v5.5.16
Post release / release (push) Canceled after 0s
2025-08-12 23:44:06 +00:00
Joe Au-Yeung ab703a4346 Add and to (#23020) 2025-08-12 16:44:02 +01:00
Lauris Skraucis 8d5a7b4e10 fix: atoms e2e env variables to turbo.json (#23039) 2025-08-12 15:27:40 +00:00
Benny Joo 6ba3049314 fix: unauthorized trpc error in /getting-started page (#23040) 2025-08-12 15:58:07 +01:00
Lauris SkraucisandSomay Chauhan abe92cd693 feat: more atoms styles (#22976)
* feat: availability override classnames

* feat: availability time picker styles

* feat: calendar settings headers styles

* feat: destination and connected calendars

* feat: date override title,description,button

* feat: selected calendars list

* example styles

* docs

* chanegest

* refactor: change import path to relative

* fix: make all classnames optional

* docs: time picker docs

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2025-08-12 21:35:06 +09:00
Hariom BalharaandUdit Takkar 6c6cf0433a fix: handle TempOrgRedirect when NEXT_PUBLIC_SINGLE_ORG_SLUG forces org context (#23009)
* fix: rename getTemporaryOrgRedirect to handleOrgRedirect and prevent duplicate orgRedirection query parameter

* refactor: improve handleOrgRedirect test robustness with scenario-based mocking

- Replace brittle mockResolvedValue with mockImplementation that derives behavior from input
- Create createRedirectScenario function that sets up mocks based on actual query parameters
- Add expectRedirectUsesData helper to verify redirects use the correct data
- Add comprehensive mock verification tests to ensure different inputs return different outputs
- Ensure tests verify the actual usage of Prisma results, not just that Prisma was called

This prevents false positives where tests pass even if the code doesn't use the database results correctly.

* Fix bug with Private links redirecting an exposing users booking page URL

* Narrow the type to what is being used

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-08-12 08:47:26 -03:00
211b958c58 refactor: move isRestrictionScheduleEnabled into AvailableSlotsService with DI (#23015)
* refactor: move isRestrictionScheduleEnabled into AvailableSlotsService with DI

- Remove external isRestrictionScheduleEnabled function call from AvailableSlotsService
- Add IFeaturesRepository to IAvailableSlotsService interface dependencies
- Implement checkRestrictionScheduleEnabled private method within AvailableSlotsService
- Update DI module to inject featuresRepo dependency
- Update API v2 service to pass featuresRepository to base service
- Resolves TODO comment about implementing DI for isRestrictionScheduleEnabled

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

* chore: bump platform libs

* fix: api v2 di for qualifiedHostsService

* chore: bump platform libs

* fix unit 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-08-12 13:18:21 +03:00
01a0d43943 feat: add conditional todesktop class to html element (#22985)
- Add applyToDesktopClass function to detect window.todesktop
- Conditionally add 'todesktop' class to html element when ToDesktop environment is detected
- Follow existing pattern used by applyTheme function for client-side script injection
- Enable desktop-specific CSS styling that already exists in globals.css

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-08-11 19:41:57 +01:00
Benny Joo 3133eb5aeb fix: false 500s from /api/book/event (#23001)
* fix: false 500 from api/book/event

* fix another
2025-08-11 18:06:26 +01:00
76b68bebd7 fix: ensure seated event reschedules send proper reschedule emails (#22849)
* fix: ensure seated event reschedules send proper reschedule emails

- Fix attendee reschedule bypass when no existing booking at new time slot
- Remove restrictive isConfirmedByDefault conditions from owner reschedule functions
- Add seatedRescheduleEmailSent flag to coordinate email sending between handleSeats and handleNewBooking
- Prevent main flow from overriding seated reschedule emails with scheduled emails

Fixes issue where both organizer and attendee receive schedule emails instead of reschedule emails during seated event reschedules.

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

* refine: only apply seatedRescheduleEmailSent check for seated bookings

- Add eventType.seatsPerTimeSlot condition to ensure flag only affects seated events
- Prevents interference with regular booking email flow

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

* fix: replace unreliable flag mechanism with direct seated reschedule detection

- Remove seatedRescheduleEmailSent flag from HandleSeatsResultBooking type
- Remove flag assignments from seated reschedule functions
- Use direct detection (eventType.seatsPerTimeSlot && rescheduleUid) in handleNewBooking
- Maintain email sending fixes for attendee reschedules and owner reschedule conditions
- Resolve TypeScript type mismatch issues with type-safe approach

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

* Update attendeeRescheduleSeatedBooking.ts

* revert

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-11 12:43:56 -03:00
c20e723e34 fix: resolve credential mismatch in round robin reschedule causing 404 errors (#22993)
* fix: flaky e2e

* fix: resolve credential mismatch

* test: add comprehensive credential mismatch test for round robin reschedule

- Verify original host credentials used for deletion operations
- Verify new host credentials used for creation operations
- Test dual EventManager pattern implementation
- Fix timeFormat property access issue

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

* Update handleNewBooking.ts

* Update booking.ts

* Update handleNewBooking.ts

* update

* Update booking.ts

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-11 21:08:40 +05:30
Lauris Skraucis 7b7478c0a9 feat: atoms e2e tests (#22926)
* feat: atoms example app e2e

* chore: build atoms when e2e examples app

* docs: readme

* fix: add env variable

* fix: env var

* chore: playwright at monorepo version

* fix: workflow

* chore: increase timeout to have time to build atoms

* increase timeout for e2e

* bump e2e instance

* go back to 4cpu
2025-08-11 20:53:26 +05:30
+3 ebeb008f9b refactor: convert findQualifiedHostsWithDelegationCredentials to service class with DI (#22974)
* refactor: convert findQualifiedHostsWithDelegationCredentials to service class with DI

- Create QualifiedHostsService class following UserAvailabilityService pattern
- Add IQualifiedHostsService interface with prisma and bookingRepo dependencies
- Create DI module and container for qualified hosts service
- Update filterHostsBySameRoundRobinHost to accept prisma as parameter
- Update all usage sites to use the new service:
  - loadAndValidateUsers.ts
  - slots/util.ts
  - test mocks in _post.test.ts
- Maintain backward compatibility with original function export
- Fix type issues in team properties (rrResetInterval, rrTimestampBasis)

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

* fix: update filterHostsBySameRoundRobinHost test to include prisma parameter

- Add missing prisma parameter to all test function calls
- Resolves unit test failure caused by function signature change

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

* fix: resolve type issues in FilterHostsService

- Import PrismaClient type instead of using unknown
- Fix type compatibility for BookingRepository constructor
- Update test mocks to use proper BookingRepository type
- Ensure all DI dependencies are properly typed

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

* refactor: rename DI files to CamelCase and update imports

- Rename all files in packages/lib/di from kebab-case to CamelCase
- Update 22 external files with import statements to use new file names
- Update internal DI module files with corrected imports
- Maintain consistency with TypeScript naming conventions

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

* chore: bump platform libs

* chore: bump platform libs

* fix: remove obsolete vitest mock after service class refactoring

- Remove obsolete mock for old function module
- Keep correct mock for new DI container
- Resolves CI unit test failures

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

* fix: correct import path for calAIPhone zod-utils module

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

* fix: Booker active booking limit can't be switched off (#23005)

* refactor: Get rid of `getServerSideProps` for /getting-started pages (#23003)

* refactor

* fix type check

* fix: Remove Reporting page within Routing Forms (#22990)

* fix error in handleNewBooking (#23011)

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

* Documentation edits made through Mintlify web editor (#23007)

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

* fix: Contact support button position changed from absolute to fixed (#23002)

* feat: Add private links to API (#22943)

* --init

* address change requests

* adding further changes

* address feedback

* further changes

* further clean-up

* clean up

* fix module import and others

* add guards

* remove unnecessary comments

* remove unnecessary comments

* cleanup

* sort coderabbig suggestions

* improve check

* chore: bump platform libraries

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>

* chore: release v5.5.15

* chore: bump platform libs

* 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: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Sahitya Chandra <sahityajb@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
Co-authored-by: Ayush Kumar <kumarayushkumar@protonmail.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: emrysal <me@alexvanandel.com>
2025-08-11 12:24:15 +00:00
157 changed files with 5241 additions and 4706 deletions
-9
View File
@@ -1,9 +0,0 @@
---
"@calcom/atoms": minor
---
Added new callback functions to the handleFormSubmit method in the EventTypeSettings and AvailabilitySettings atoms. The handleFormSubmit method now accepts an optional callbacks object with the following properties:
- **onSuccess**: Called when the form submission is successful, allowing additional logic to be executed after the update.
- **onError**: Called when an error occurs during form submission, providing details about the error to handle specific cases or display custom messages.
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": minor
---
booker atom: allow toggling org and team info when booking round robin
+57
View File
@@ -0,0 +1,57 @@
name: Atoms E2E Tests
on:
pull_request:
branches:
- main
paths:
- 'packages/platform/atoms/**'
- 'packages/platform/examples/base/**'
- 'apps/api/v2/**'
- '.github/workflows/atoms-e2e.yml'
permissions:
actions: write
contents: read
env:
NODE_OPTIONS: --max-old-space-size=8096
ATOMS_E2E_OAUTH_CLIENT_ID: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID }}
ATOMS_E2E_OAUTH_CLIENT_SECRET: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_SECRET }}
ATOMS_E2E_API_URL: ${{ secrets.ATOMS_E2E_API_URL }}
ATOMS_E2E_ORG_ID: ${{ secrets.ATOMS_E2E_ORG_ID }}
ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED: ${{ secrets.ATOMS_E2E_OAUTH_CLIENT_ID_BOOKER_EMBED }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
jobs:
atoms-e2e:
timeout-minutes: 15
name: Atoms E2E Tests
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
- name: Run Atoms E2E Tests
working-directory: packages/platform/examples/base
run: yarn test:e2e
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: always()
with:
name: atoms-e2e-test-results
path: test-results
retention-days: 7
- name: Upload Playwright Report
uses: actions/upload-artifact@v4
if: always()
with:
name: atoms-e2e-playwright-report
path: playwright-report
retention-days: 7
+7
View File
@@ -0,0 +1,7 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
View File
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { z } from "zod";
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
import { getUserAvailabilityService } from "@calcom/lib/di/containers/GetUserAvailability";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
+1 -1
View File
@@ -4,7 +4,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { createContext } from "@calcom/trpc/server/createContext";
+7 -5
View File
@@ -76,11 +76,13 @@ vi.mock("@calcom/features/watchlist/operations/check-if-users-are-blocked.contro
checkIfUsersAreBlocked: vi.fn().mockResolvedValue(false),
}));
vi.mock("@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials", () => ({
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
qualifiedRRHosts: [],
allFallbackRRHosts: [],
fixedHosts: [],
vi.mock("@calcom/lib/di/containers/QualifiedHosts", () => ({
getQualifiedHostsService: vi.fn().mockReturnValue({
findQualifiedHostsWithDelegationCredentials: vi.fn().mockResolvedValue({
qualifiedRRHosts: [],
allFallbackRRHosts: [],
fixedHosts: [],
}),
}),
}));
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.294",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.300",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -67,10 +67,9 @@ export class EventTypesPrivateLinksController {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Get all private links for an event type" })
async getPrivateLinks(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@GetUser("id") userId: number
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<GetPrivateLinksOutput> {
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId, userId);
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId);
return {
status: SUCCESS_STATUS,
@@ -1,15 +1,15 @@
import { Module } from "@nestjs/common";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { Module } from "@nestjs/common";
import { EventTypesPrivateLinksController } from "./controllers/event-types-private-links.controller";
import { PrivateLinksRepository } from "./private-links.repository";
import { PrivateLinksInputService } from "./services/private-links-input.service";
import { PrivateLinksOutputService } from "./services/private-links-output.service";
import { PrivateLinksService } from "./services/private-links.service";
import { PrivateLinksRepository } from "./private-links.repository";
@Module({
imports: [TokensModule, OAuthClientModule, PrismaModule, EventTypesModule_2024_06_14],
@@ -23,5 +23,3 @@ import { PrivateLinksRepository } from "./private-links.repository";
],
})
export class EventTypesPrivateLinksModule {}
@@ -25,7 +25,10 @@ export class PrivateLinksRepository {
});
}
async create(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
async create(
eventTypeId: number,
link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }
) {
return this.dbWrite.prisma.hashedLink.create({
data: {
eventTypeId,
@@ -36,7 +39,10 @@ export class PrivateLinksRepository {
});
}
async update(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
async update(
eventTypeId: number,
link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }
) {
return this.dbWrite.prisma.hashedLink.updateMany({
where: { eventTypeId, link: link.link },
data: {
@@ -50,5 +56,3 @@ export class PrivateLinksRepository {
return this.dbWrite.prisma.hashedLink.deleteMany({ where: { eventTypeId, link: linkId } });
}
}
@@ -1,6 +1,12 @@
import { Injectable } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import {
PrivateLinkOutput,
TimeBasedPrivateLinkOutput,
UsageBasedPrivateLinkOutput,
} from "@calcom/platform-types";
export type PrivateLinkData = {
id: string;
eventTypeId: number;
@@ -10,7 +16,6 @@ export type PrivateLinkData = {
maxUsageCount?: number | null;
usageCount?: number;
};
import { PrivateLinkOutput, TimeBasedPrivateLinkOutput, UsageBasedPrivateLinkOutput } from "@calcom/platform-types";
@Injectable()
export class PrivateLinksOutputService {
@@ -39,5 +44,3 @@ export class PrivateLinksOutputService {
return data.map((item) => this.transformToOutput(item));
}
}
@@ -1,14 +1,13 @@
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
import { PrivateLinksInputService } from "@/ee/event-types-private-links/services/private-links-input.service";
import {
PrivateLinksOutputService,
type PrivateLinkData,
} from "@/ee/event-types-private-links/services/private-links-output.service";
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
@Injectable()
export class PrivateLinksService {
@@ -48,7 +47,7 @@ export class PrivateLinksService {
}
}
async getPrivateLinks(eventTypeId: number, userId: number): Promise<PrivateLinkOutput[]> {
async getPrivateLinks(eventTypeId: number): Promise<PrivateLinkOutput[]> {
try {
const links = await this.repo.listByEventTypeId(eventTypeId);
const mapped: PrivateLinkData[] = links.map((l) => ({
@@ -124,5 +123,3 @@ export class PrivateLinksService {
}
}
}
@@ -6,7 +6,6 @@ import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
@@ -42,7 +41,6 @@ import {
GetEventTypesQuery_2024_06_14,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
} from "@calcom/platform-types";
@Controller({
@@ -1,6 +1,7 @@
import { BookingsModule_2024_04_15 } from "@/ee/bookings/2024-04-15/bookings.module";
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
import { CalendarsModule } from "@/ee/calendars/calendars.module";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { GcalModule } from "@/ee/gcal/gcal.module";
@@ -14,7 +15,6 @@ import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.m
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -11,6 +11,8 @@ import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisService } from "@/modules/redis/redis.service";
@@ -34,6 +36,8 @@ import { Module } from "@nestjs/common";
AvailableSlotsService,
UserAvailabilityService,
BusyTimesService,
FilterHostsService,
QualifiedHostsService,
],
exports: [AvailableSlotsService],
})
@@ -10,6 +10,7 @@ import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"
import { BusyTimesService } from "@/lib/services/busy-times.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
@@ -29,7 +30,12 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
eventTypeRepository: PrismaEventTypeRepository,
userRepository: PrismaUserRepository,
redisService: RedisService,
featuresRepository: PrismaFeaturesRepository
featuresRepository: PrismaFeaturesRepository,
qualifiedHostsService: QualifiedHostsService,
checkBookingLimitsService: CheckBookingLimitsService,
cacheService: CacheService,
userAvailabilityService: UserAvailabilityService,
busyTimesService: BusyTimesService
) {
super({
oooRepo: oooRepoDependency,
@@ -41,15 +47,12 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
eventTypeRepo: eventTypeRepository,
userRepo: userRepository,
redisClient: redisService,
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository),
cacheService: new CacheService(featuresRepository),
userAvailabilityService: new UserAvailabilityService(
oooRepoDependency,
bookingRepository,
eventTypeRepository,
redisService
),
busyTimesService: new BusyTimesService(bookingRepository),
checkBookingLimitsService,
cacheService,
userAvailabilityService,
busyTimesService,
qualifiedHostsService,
featuresRepo: featuresRepository,
});
}
}
@@ -0,0 +1,13 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { Injectable } from "@nestjs/common";
import { FilterHostsService as BaseFilterHostsService } from "@calcom/platform-libraries/slots";
@Injectable()
export class FilterHostsService extends BaseFilterHostsService {
constructor(bookingRepository: PrismaBookingRepository) {
super({
bookingRepo: bookingRepository,
});
}
}
@@ -0,0 +1,15 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { FilterHostsService } from "@/lib/services/filter-hosts.service";
import { Injectable } from "@nestjs/common";
import { QualifiedHostsService as BaseQualifiedHostsService } from "@calcom/platform-libraries/slots";
@Injectable()
export class QualifiedHostsService extends BaseQualifiedHostsService {
constructor(bookingRepository: PrismaBookingRepository, filterHostsService: FilterHostsService) {
super({
bookingRepo: bookingRepository,
filterHostsService,
});
}
}
@@ -2,10 +2,21 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
import { UpdateUnifiedCalendarEventInput } from "@/modules/cal-unified-calendars/inputs/update-unified-calendar-event.input";
import { GetUnifiedCalendarEventOutput } from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
import { GoogleCalendarEventOutputPipe } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/google-calendar.service";
import { Controller, Get, Param, UseGuards, HttpCode, HttpStatus, BadRequestException } from "@nestjs/common";
import {
Controller,
Get,
Param,
UseGuards,
HttpCode,
HttpStatus,
BadRequestException,
Patch,
Body,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiParam, ApiHeader, ApiOperation } from "@nestjs/swagger";
import { GOOGLE_CALENDAR, SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -30,6 +41,7 @@ export class CalUnifiedCalendarsController {
type: String,
})
@Get("/:calendar/event/:eventUid")
@Get("/:calendar/events/:eventUid")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@@ -54,4 +66,42 @@ export class CalUnifiedCalendarsController {
data: transformedEvent,
};
}
@ApiParam({
name: "calendar",
enum: [GOOGLE_CALENDAR],
type: String,
})
@ApiParam({
name: "eventUid",
description:
"The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
type: String,
})
@Patch("/:calendar/events/:eventUid")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Update meeting details in calendar",
description: "Updates event information in the specified calendar provider",
})
async updateCalendarEvent(
@Param("calendar") calendar: string,
@Param("eventUid") eventUid: string,
@Body() updateData: UpdateUnifiedCalendarEventInput
): Promise<GetUnifiedCalendarEventOutput> {
if (calendar !== GOOGLE_CALENDAR) {
throw new BadRequestException("Event updates are currently only available for Google Calendar");
}
const updatedEvent = await this.googleCalendarService.updateEventDetails(eventUid, updateData);
const transformedEvent = new GoogleCalendarEventOutputPipe().transform(updatedEvent);
return {
status: SUCCESS_STATUS,
data: transformedEvent,
};
}
}
@@ -0,0 +1,134 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsISO8601, IsOptional, IsString, ValidateNested, IsEnum, IsArray } from "class-validator";
import { CalendarEventStatus, CalendarEventResponseStatus } from "../outputs/get-unified-calendar-event.output";
export class UpdateCalendarEventAttendee {
@IsString()
@ApiPropertyOptional({
type: String,
description: "Email address of the attendee",
})
email!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
type: String,
description: "Display name of the attendee",
})
name?: string;
@IsEnum(CalendarEventResponseStatus)
@IsOptional()
@ApiPropertyOptional({
enum: CalendarEventResponseStatus,
enumName: "CalendarEventResponseStatus",
nullable: true,
description: "Response status of the attendee",
})
responseStatus?: CalendarEventResponseStatus | null;
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Indicates if this attendee is the current user",
})
self?: boolean;
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Indicates if this attendee's attendance is optional",
})
optional?: boolean;
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Indicates if this attendee is the host",
})
host?: boolean;
}
export class UpdateDateTimeWithZone {
@IsISO8601()
@IsOptional()
@ApiPropertyOptional({ type: "string", format: "date-time" })
time?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
timeZone?: string;
}
export class UpdateUnifiedCalendarEventInput {
@ValidateNested()
@Type(() => UpdateDateTimeWithZone)
@IsOptional()
@ApiPropertyOptional({
type: "object",
properties: {
time: { type: "string", format: "date-time" },
timeZone: { type: "string" },
},
description: "Start date and time of the calendar event with timezone information",
})
start?: UpdateDateTimeWithZone;
@ValidateNested()
@Type(() => UpdateDateTimeWithZone)
@IsOptional()
@ApiPropertyOptional({
type: "object",
properties: {
time: { type: "string", format: "date-time" },
timeZone: { type: "string" },
},
description: "End date and time of the calendar event with timezone information",
})
end?: UpdateDateTimeWithZone;
@IsString()
@IsOptional()
@ApiPropertyOptional({
type: String,
description: "Title of the calendar event",
})
title?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
type: String,
nullable: true,
description: "Detailed description of the calendar event",
})
description?: string | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UpdateCalendarEventAttendee)
@ApiPropertyOptional({
type: [UpdateCalendarEventAttendee],
nullable: true,
description:
"List of attendees. CAUTION: You must pass the entire array with all updated values. Any attendees not included in this array will be removed from the event.",
})
attendees?: UpdateCalendarEventAttendee[];
@IsEnum(CalendarEventStatus)
@IsOptional()
@ApiPropertyOptional({
enum: CalendarEventStatus,
enumName: "CalendarEventStatus",
nullable: true,
description: "Status of the event (accepted, pending, declined, cancelled)",
example: CalendarEventStatus.ACCEPTED,
})
status?: CalendarEventStatus | null;
}
@@ -225,6 +225,22 @@ export class CalendarEventHost {
responseStatus!: CalendarEventResponseStatus | null;
}
export class calendarEventOwner {
@IsString()
@ApiProperty({
description: "Email address of the event host",
})
email!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Display name of the event host",
})
name?: string;
}
export class CalendarEventAttendee {
@IsString()
@ApiProperty({
@@ -264,6 +280,14 @@ export class CalendarEventAttendee {
description: "Indicates if this attendee's attendance is optional",
})
optional?: boolean;
@IsOptional()
@ApiPropertyOptional({
nullable: true,
description: "Indicates if this attendee is the host",
})
host?: boolean;
}
export class DateTimeWithZone {
@@ -384,6 +408,16 @@ export class UnifiedCalendarEventOutput {
})
hosts?: CalendarEventHost[];
@IsOptional()
@ValidateNested({ each: true })
@Type(() => calendarEventOwner)
@ApiPropertyOptional({
type: calendarEventOwner,
nullable: true,
description: "The calendar account that owns this event. This is the primary calendar where the event is stored and cannot be modified without appropriate permissions. Changing this would require moving the event to a different calendar",
})
calendarEventOwner?: calendarEventOwner;
@IsEnum(CALENDARS)
@ApiProperty({
enum: CALENDARS,
@@ -0,0 +1,103 @@
import { GoogleCalendarEventResponse } from "../get-calendar-event-details-output-pipe";
export const createGoogleCalendarEventFixture = (
overrides: Partial<GoogleCalendarEventResponse> = {}
): GoogleCalendarEventResponse => {
const baseEvent: GoogleCalendarEventResponse = {
kind: "calendar#event",
etag: "test-etag",
id: "test-event-id",
status: "confirmed",
htmlLink: "https://calendar.google.com/event",
created: "2024-01-01T00:00:00Z",
updated: "2024-01-01T00:00:00Z",
summary: "Test Meeting",
description: "Test description",
creator: {
email: "creator@example.com",
displayName: "Creator Name",
},
organizer: {
email: "organizer@example.com",
displayName: "Organizer Name",
},
start: {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
},
end: {
dateTime: "2024-01-15T11:00:00Z",
timeZone: "America/New_York",
},
iCalUID: "test-ical-uid",
sequence: 0,
attendees: [
{
email: "attendee@example.com",
displayName: "Attendee Name",
responseStatus: "accepted",
organizer: false,
},
{
email: "organizer@example.com",
displayName: "Organizer Name",
responseStatus: "accepted",
organizer: true,
},
],
conferenceData: {
conferenceId: "abc-def-ghi",
entryPoints: [
{
entryPointType: "video",
uri: "https://meet.google.com/abc-def-ghi",
label: "meet.google.com/abc-def-ghi",
},
],
conferenceSolution: {
key: {
type: "hangoutsMeet",
},
name: "Google Meet",
iconUri:
"https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v6/web-512dp/logo_meet_2020q4_color_2x_web_512dp.png",
},
},
hangoutLink: "https://meet.google.com/abc-def-ghi",
};
return { ...baseEvent, ...overrides };
};
export const googleEventWithConferenceData = createGoogleCalendarEventFixture({
conferenceData: {
conferenceId: "test-conference-id",
entryPoints: [
{
entryPointType: "video",
uri: "https://meet.google.com/abc-def-ghi",
label: "Google Meet",
pin: "123456",
regionCode: "US",
},
{
entryPointType: "phone",
uri: "tel:+1-555-123-4567",
label: "Phone",
pin: "789012",
},
],
},
});
export const googleEventWithLocationOnly = createGoogleCalendarEventFixture({
location: "123 Main St, City, State",
conferenceData: undefined,
hangoutLink: undefined,
});
export const googleEventWithHangoutLink = createGoogleCalendarEventFixture({
hangoutLink: "https://hangouts.google.com/call/abc123",
conferenceData: undefined,
location: undefined,
});
@@ -0,0 +1,201 @@
import {
CalendarEventStatus,
CalendarEventResponseStatus,
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
import {
createGoogleCalendarEventFixture,
googleEventWithConferenceData,
googleEventWithLocationOnly,
googleEventWithHangoutLink,
} from "./__fixtures__/google-calendar-event.fixture";
import {
GoogleCalendarEventOutputPipe,
GoogleCalendarEventResponse,
} from "./get-calendar-event-details-output-pipe";
describe("GoogleCalendarEventOutputPipe", () => {
let pipe: GoogleCalendarEventOutputPipe;
let sharedGoogleEvent: GoogleCalendarEventResponse;
beforeAll(() => {
sharedGoogleEvent = createGoogleCalendarEventFixture();
});
beforeEach(() => {
pipe = new GoogleCalendarEventOutputPipe();
});
describe("transform", () => {
it("should transform Google Calendar event to unified format", () => {
const result = pipe.transform(sharedGoogleEvent);
expect(result.id).toBe("test-event-id");
expect(result.title).toBe("Test Meeting");
expect(result.description).toBe("Test description");
expect(result.source).toBe("google");
expect(result.status).toBe(CalendarEventStatus.ACCEPTED);
expect(result.start.time).toBe("2024-01-15T10:00:00Z");
expect(result.start.timeZone).toBe("America/New_York");
expect(result.end.time).toBe("2024-01-15T11:00:00Z");
expect(result.end.timeZone).toBe("America/New_York");
expect(result.attendees).toHaveLength(2);
});
});
describe("transformDateTimeWithZone", () => {
it("should transform Google date time to unified format", () => {
const googleDateTime = {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
};
const result = pipe["transformDateTimeWithZone"](googleDateTime);
expect(result.time).toBe("2024-01-15T10:00:00Z");
expect(result.timeZone).toBe("America/New_York");
});
});
describe("transformAttendeeResponseStatus", () => {
it("should transform accepted status", () => {
const result = pipe["transformAttendeeResponseStatus"]("accepted");
expect(result).toBe(CalendarEventResponseStatus.ACCEPTED);
});
it("should transform tentative status", () => {
const result = pipe["transformAttendeeResponseStatus"]("tentative");
expect(result).toBe(CalendarEventResponseStatus.PENDING);
});
it("should transform declined status", () => {
const result = pipe["transformAttendeeResponseStatus"]("declined");
expect(result).toBe(CalendarEventResponseStatus.DECLINED);
});
it("should transform needsaction status", () => {
const result = pipe["transformAttendeeResponseStatus"]("needsaction");
expect(result).toBe(CalendarEventResponseStatus.NEEDS_ACTION);
});
it("should handle case insensitive status", () => {
const result = pipe["transformAttendeeResponseStatus"]("ACCEPTED");
expect(result).toBe(CalendarEventResponseStatus.ACCEPTED);
});
it("should handle unknown status", () => {
const result = pipe["transformAttendeeResponseStatus"]("unknown");
expect(result).toBeNull();
});
it("should handle null status", () => {
const result = pipe["transformAttendeeResponseStatus"](undefined);
expect(result).toBeNull();
});
});
describe("transformEventStatus", () => {
it("should transform confirmed status", () => {
const result = pipe["transformEventStatus"]("confirmed");
expect(result).toBe(CalendarEventStatus.ACCEPTED);
});
it("should transform tentative status", () => {
const result = pipe["transformEventStatus"]("tentative");
expect(result).toBe(CalendarEventStatus.PENDING);
});
it("should transform cancelled status", () => {
const result = pipe["transformEventStatus"]("cancelled");
expect(result).toBe(CalendarEventStatus.CANCELLED);
});
it("should handle case insensitive status", () => {
const result = pipe["transformEventStatus"]("CONFIRMED");
expect(result).toBe(CalendarEventStatus.ACCEPTED);
});
it("should handle unknown status", () => {
const result = pipe["transformEventStatus"]("unknown");
expect(result).toBeNull();
});
it("should handle null status", () => {
const result = pipe["transformEventStatus"](undefined);
expect(result).toBeNull();
});
});
describe("transformOrganizer", () => {
it("should return calendarEventOwner organizer exist", () => {
const eventWithoutOrganizerAttendees = createGoogleCalendarEventFixture({
organizer: { email: "organizer@example.com" },
attendees: [
{
email: "attendee@example.com",
displayName: "Regular Attendee",
responseStatus: "accepted",
organizer: false,
},
],
});
const result = pipe.transform(eventWithoutOrganizerAttendees);
expect(result?.calendarEventOwner?.email).toBe("organizer@example.com");
});
});
describe("transformLocations", () => {
it("should transform conference data entry points", () => {
const result = pipe["transformLocations"](googleEventWithConferenceData);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
type: "video",
url: "https://meet.google.com/abc-def-ghi",
label: "Google Meet",
pin: "123456",
regionCode: "US",
});
expect(result[1]).toEqual({
type: "phone",
url: "tel:+1-555-123-4567",
label: "Phone",
pin: "789012",
regionCode: undefined,
});
});
it("should fallback to location field", () => {
const result = pipe["transformLocations"](googleEventWithLocationOnly);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: "video",
url: "123 Main St, City, State",
});
});
it("should fallback to hangout link", () => {
const result = pipe["transformLocations"](googleEventWithHangoutLink);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: "video",
url: "https://hangouts.google.com/call/abc123",
});
});
it("should return empty array when no location data", () => {
const eventWithoutLocation = createGoogleCalendarEventFixture({
location: undefined,
conferenceData: undefined,
hangoutLink: undefined,
});
const result = pipe["transformLocations"](eventWithoutLocation);
expect(result).toEqual([]);
});
});
});
@@ -3,7 +3,7 @@ import {
CalendarEventStatus,
DateTimeWithZone,
UnifiedCalendarEventOutput,
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event";
} from "@/modules/cal-unified-calendars/outputs/get-unified-calendar-event.output";
import { PipeTransform, Injectable } from "@nestjs/common";
export interface GoogleCalendarEventResponse {
@@ -94,14 +94,12 @@ export class GoogleCalendarEventOutputPipe
calendarEvent.locations = this.transformLocations(googleEvent);
if (googleEvent.attendees && googleEvent.attendees.length > 0) {
calendarEvent.attendees = googleEvent.attendees
.filter((attendee) => !attendee.organizer)
.map((attendee) => {
calendarEvent.attendees = googleEvent.attendees.map((attendee) => {
return {
email: attendee.email,
name: attendee.displayName,
responseStatus: this.transformAttendeeResponseStatus(attendee.responseStatus),
organizer: attendee.organizer,
host: attendee.organizer,
self: attendee.self,
optional: attendee.optional,
};
@@ -110,7 +108,8 @@ export class GoogleCalendarEventOutputPipe
calendarEvent.status = this.transformEventStatus(googleEvent.status);
calendarEvent.hosts = this.transformHosts(googleEvent);
calendarEvent.calendarEventOwner = googleEvent.organizer
calendarEvent.hosts = this.transformHosts(googleEvent)
calendarEvent.source = "google";
@@ -196,6 +195,8 @@ export class GoogleCalendarEventOutputPipe
regionCode: entryPoint.regionCode,
};
});
} else if (googleEvent.location) {
return [{ type: "video", url: googleEvent.location }];
} else if (googleEvent.hangoutLink) {
return [{ type: "video", url: googleEvent.hangoutLink }];
}
@@ -0,0 +1,121 @@
import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
import { Injectable } from "@nestjs/common";
import {
UpdateUnifiedCalendarEventInput,
UpdateDateTimeWithZone,
} from "../inputs/update-unified-calendar-event.input";
import { CalendarEventResponseStatus, CalendarEventStatus } from "../outputs/get-unified-calendar-event.output";
// Common interfaces for Google Calendar types
interface GoogleCalendarDateTime {
dateTime: string;
timeZone: string;
}
interface GoogleCalendarAttendee {
email: string;
displayName?: string;
responseStatus: string;
organizer?: boolean;
}
interface GoogleCalendarEventInputTransform {
transform(
updateData: UpdateUnifiedCalendarEventInput,
fetchedEvent?: GoogleCalendarEventResponse | null
): any;
}
@Injectable()
export class GoogleCalendarEventInputPipe implements GoogleCalendarEventInputTransform {
transform(
updateData: UpdateUnifiedCalendarEventInput,
): any {
const updatePayload: any = {};
if (updateData.title !== undefined) {
updatePayload.summary = updateData.title;
}
if (updateData.description !== undefined) {
updatePayload.description = updateData.description;
}
if (updateData.start) {
updatePayload.start = this.transformDateTimeWithZone(updateData.start);
}
if (updateData.end) {
updatePayload.end = this.transformDateTimeWithZone(updateData.end);
}
if (updateData.attendees !== undefined) {
updatePayload.attendees = this.transformAttendees(
updateData.attendees,
);
}
if (updateData.status !== undefined) {
updatePayload.status = this.transformEventStatus(updateData.status);
}
return updatePayload;
}
private transformDateTimeWithZone(dateTime: UpdateDateTimeWithZone): GoogleCalendarDateTime {
return {
dateTime: dateTime.time || "",
timeZone: dateTime.timeZone || "",
};
}
private transformAttendees(
inputAttendees: NonNullable<UpdateUnifiedCalendarEventInput["attendees"]>,
): GoogleCalendarAttendee[] {
return inputAttendees.map((attendee) => {
return {
email: attendee.email,
displayName: attendee.name,
responseStatus: this.transformResponseStatus(attendee.responseStatus),
organizer: attendee.host,
self: attendee.self,
optional: attendee.optional,
};
});
}
private transformResponseStatus(responseStatus?: CalendarEventResponseStatus | null): string {
if (!responseStatus) return "needsAction";
switch (responseStatus) {
case CalendarEventResponseStatus.ACCEPTED:
return "accepted";
case CalendarEventResponseStatus.PENDING:
return "tentative";
case CalendarEventResponseStatus.DECLINED:
return "declined";
case CalendarEventResponseStatus.NEEDS_ACTION:
return "needsAction";
default:
return "needsAction";
}
}
private transformEventStatus(status?: CalendarEventStatus | null): string {
if (!status) return "confirmed";
switch (status) {
case CalendarEventStatus.ACCEPTED:
return "confirmed";
case CalendarEventStatus.PENDING:
return "tentative";
case CalendarEventStatus.CANCELLED:
return "cancelled";
case CalendarEventStatus.DECLINED:
return "cancelled";
default:
return "confirmed";
}
}
}
@@ -0,0 +1,222 @@
import {
UpdateUnifiedCalendarEventInput,
UpdateDateTimeWithZone,
} from "../inputs/update-unified-calendar-event.input";
import { CalendarEventResponseStatus, CalendarEventStatus } from "../outputs/get-unified-calendar-event.output";
import { GoogleCalendarEventResponse } from "./get-calendar-event-details-output-pipe";
import { GoogleCalendarEventInputPipe } from "./google-calendar-event-input-pipe";
describe("GoogleCalendarEventInputPipe", () => {
let pipe: GoogleCalendarEventInputPipe;
let sharedGoogleEvent: GoogleCalendarEventResponse;
beforeAll(() => {
sharedGoogleEvent = {
kind: "calendar#event",
etag: "test-etag",
id: "test-event-id",
status: "confirmed",
htmlLink: "https://calendar.google.com/event",
created: "2024-01-01T00:00:00Z",
updated: "2024-01-01T00:00:00Z",
summary: "Test Meeting",
description: "Test description",
start: {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
},
end: {
dateTime: "2024-01-15T11:00:00Z",
timeZone: "America/New_York",
},
iCalUID: "test-ical-uid",
sequence: 0,
attendees: [
{
email: "attendee1@example.com",
displayName: "Attendee One",
responseStatus: "accepted",
organizer: false,
},
{
email: "attendee2@example.com",
displayName: "Attendee Two",
responseStatus: "needsAction",
organizer: false,
},
{
email: "organizer@example.com",
displayName: "Organizer",
responseStatus: "accepted",
organizer: true,
},
],
creator: {
email: "organizer@example.com",
displayName: "Organizer",
},
organizer: {
email: "organizer@example.com",
displayName: "Organizer",
},
};
});
beforeEach(() => {
pipe = new GoogleCalendarEventInputPipe();
});
describe("transform", () => {
it("should transform basic event fields", () => {
const input: UpdateUnifiedCalendarEventInput = {
title: "Updated Meeting",
description: "Updated description",
start: {
time: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
},
end: {
time: "2024-01-15T11:00:00Z",
timeZone: "America/New_York",
},
status: CalendarEventStatus.ACCEPTED,
};
const expectedOutput = {
summary: "Updated Meeting",
description: "Updated description",
start: {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
},
end: {
dateTime: "2024-01-15T11:00:00Z",
timeZone: "America/New_York",
},
status: "confirmed",
};
const result = pipe.transform(input);
expect(result).toEqual(expectedOutput);
});
it("should handle partial updates", () => {
const input: UpdateUnifiedCalendarEventInput = {
title: "Only Title Update",
};
const expectedOutput = {
summary: "Only Title Update",
};
const result = pipe.transform(input);
expect(result).toEqual(expectedOutput);
});
it("should handle null description", () => {
const input: UpdateUnifiedCalendarEventInput = {
description: null,
};
const expectedOutput = {
description: null,
};
const result = pipe.transform(input);
expect(result).toEqual(expectedOutput);
});
});
describe("transformDateTimeWithZone", () => {
it("should transform date time with timezone", () => {
const input: UpdateDateTimeWithZone = {
time: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
};
const expectedOutput = {
dateTime: "2024-01-15T10:00:00Z",
timeZone: "America/New_York",
};
const result = pipe["transformDateTimeWithZone"](input);
expect(result).toEqual(expectedOutput);
});
it("should handle empty time and timezone", () => {
const input: UpdateDateTimeWithZone = {};
const expectedOutput = {
dateTime: "",
timeZone: "",
};
const result = pipe["transformDateTimeWithZone"](input);
expect(result).toEqual(expectedOutput);
});
});
describe("transformResponseStatus", () => {
it("should transform ACCEPTED status", () => {
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.ACCEPTED);
expect(result).toBe("accepted");
});
it("should transform PENDING status", () => {
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.PENDING);
expect(result).toBe("tentative");
});
it("should transform DECLINED status", () => {
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.DECLINED);
expect(result).toBe("declined");
});
it("should transform NEEDS_ACTION status", () => {
const result = pipe["transformResponseStatus"](CalendarEventResponseStatus.NEEDS_ACTION);
expect(result).toBe("needsAction");
});
it("should handle null status", () => {
const result = pipe["transformResponseStatus"](null);
expect(result).toBe("needsAction");
});
it("should handle undefined status", () => {
const result = pipe["transformResponseStatus"](undefined);
expect(result).toBe("needsAction");
});
});
describe("transformEventStatus", () => {
it("should transform ACCEPTED status", () => {
const result = pipe["transformEventStatus"](CalendarEventStatus.ACCEPTED);
expect(result).toBe("confirmed");
});
it("should transform PENDING status", () => {
const result = pipe["transformEventStatus"](CalendarEventStatus.PENDING);
expect(result).toBe("tentative");
});
it("should transform CANCELLED status", () => {
const result = pipe["transformEventStatus"](CalendarEventStatus.CANCELLED);
expect(result).toBe("cancelled");
});
it("should transform DECLINED status", () => {
const result = pipe["transformEventStatus"](CalendarEventStatus.DECLINED);
expect(result).toBe("cancelled");
});
it("should handle null status", () => {
const result = pipe["transformEventStatus"](null);
expect(result).toBe("confirmed");
});
it("should handle undefined status", () => {
const result = pipe["transformEventStatus"](undefined);
expect(result).toBe("confirmed");
});
});
});
@@ -10,7 +10,8 @@ import { JWT } from "googleapis-common";
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
import { UnifiedCalendarEventOutput } from "../outputs/get-unified-calendar-event";
import { UpdateUnifiedCalendarEventInput } from "../inputs/update-unified-calendar-event.input";
import { GoogleCalendarEventInputPipe } from "../pipes/google-calendar-event-input-pipe";
@Injectable()
export class GoogleCalendarService {
@@ -53,6 +54,44 @@ export class GoogleCalendarService {
}
}
async updateEventDetails(
eventUid: string,
updateData: UpdateUnifiedCalendarEventInput
): Promise<GoogleCalendarEventResponse> {
const bookingReference =
await this.bookingReferencesRepository.getBookingReferencesIncludeSensitiveCredentials(eventUid);
if (!bookingReference) {
throw new NotFoundException("Booking reference not found");
}
const ownerUserEmail = bookingReference?.booking?.user?.email;
const calendar = await this.getAuthorizedCalendarInstance(
ownerUserEmail,
bookingReference.credential?.key,
bookingReference.delegationCredential
);
const updatePayload = new GoogleCalendarEventInputPipe().transform(updateData);
try {
const event = await calendar.events.patch({
calendarId: bookingReference?.externalCalendarId ?? "primary",
eventId: bookingReference?.uid,
requestBody: updatePayload,
});
if (!event.data) {
throw new NotFoundException("Failed to update meeting");
}
return event.data as GoogleCalendarEventResponse;
} catch (error) {
throw new NotFoundException("Failed to update meeting details");
}
}
/**
* Gets an authorized Google Calendar instance
* Tries delegation credentials first, falls back to direct OAuth
@@ -40,5 +40,3 @@ export class EventTypeOwnershipGuard implements CanActivate {
return true;
}
}
+180 -1
View File
@@ -8563,6 +8563,69 @@
]
}
},
"/v2/calendars/{calendar}/events/{eventUid}": {
"patch": {
"operationId": "CalUnifiedCalendarsController_updateCalendarEvent",
"summary": "Update meeting details in calendar",
"description": "Updates event information in the specified calendar provider",
"parameters": [
{
"name": "calendar",
"required": true,
"in": "path",
"schema": {
"enum": [
"google"
],
"type": "string"
}
},
{
"name": "eventUid",
"required": true,
"in": "path",
"description": "The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUnifiedCalendarEventInput"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetUnifiedCalendarEventOutput"
}
}
}
}
},
"tags": [
"Cal Unified Calendars"
]
}
},
"/v2/calendars/ics-feed/save": {
"post": {
"operationId": "CalendarsController_createIcsFeed",
@@ -28782,7 +28845,7 @@
},
"CalendarEventResponseStatus": {
"type": "string",
"description": "Host's response to the invitation",
"description": "Response status of the attendee",
"enum": [
"accepted",
"pending",
@@ -28815,6 +28878,11 @@
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee's attendance is optional"
},
"host": {
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee is the host"
}
},
"required": [
@@ -28853,6 +28921,23 @@
"email"
]
},
"calendarEventOwner": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "Email address of the event host"
},
"name": {
"type": "string",
"nullable": true,
"description": "Display name of the event host"
}
},
"required": [
"email"
]
},
"CalendarSource": {
"type": "string",
"description": "Calendar integration source (e.g., Google Calendar, Office 365, Apple Calendar). Currently only Google Calendar is supported.",
@@ -28949,6 +29034,15 @@
"$ref": "#/components/schemas/CalendarEventHost"
}
},
"calendarEventOwner": {
"nullable": true,
"description": "The calendar account that owns this event. This is the primary calendar where the event is stored and cannot be modified without appropriate permissions. Changing this would require moving the event to a different calendar",
"allOf": [
{
"$ref": "#/components/schemas/calendarEventOwner"
}
]
},
"source": {
"example": "google",
"$ref": "#/components/schemas/CalendarSource"
@@ -28982,6 +29076,91 @@
"data"
]
},
"UpdateCalendarEventAttendee": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "Email address of the attendee"
},
"name": {
"type": "string",
"description": "Display name of the attendee"
},
"responseStatus": {
"nullable": true,
"$ref": "#/components/schemas/CalendarEventResponseStatus"
},
"self": {
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee is the current user"
},
"optional": {
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee's attendance is optional"
},
"host": {
"type": "boolean",
"nullable": true,
"description": "Indicates if this attendee is the host"
}
}
},
"UpdateUnifiedCalendarEventInput": {
"type": "object",
"properties": {
"start": {
"type": "object",
"properties": {
"time": {
"type": "string",
"format": "date-time"
},
"timeZone": {
"type": "string"
}
},
"description": "Start date and time of the calendar event with timezone information"
},
"end": {
"type": "object",
"properties": {
"time": {
"type": "string",
"format": "date-time"
},
"timeZone": {
"type": "string"
}
},
"description": "End date and time of the calendar event with timezone information"
},
"title": {
"type": "string",
"description": "Title of the calendar event"
},
"description": {
"type": "string",
"nullable": true,
"description": "Detailed description of the calendar event"
},
"attendees": {
"nullable": true,
"description": "List of attendees. CAUTION: You must pass the entire array with all updated values. Any attendees not included in this array will be removed from the event.",
"type": "array",
"items": {
"$ref": "#/components/schemas/UpdateCalendarEventAttendee"
}
},
"status": {
"nullable": true,
"example": "accepted",
"$ref": "#/components/schemas/CalendarEventStatus"
}
}
},
"RequestEmailVerificationInput": {
"type": "object",
"properties": {
@@ -16,7 +16,7 @@ import slugify from "@calcom/lib/slugify";
import { BookingStatus, RedirectType } from "@calcom/prisma/enums";
import { buildLegacyCtx, buildLegacyRequest } from "@lib/buildLegacyCtx";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
import CachedClientView, { type TeamBookingPageProps } from "~/team/type-view-cached";
@@ -124,19 +124,17 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const CachedTeamBooker = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
// Handle org redirects for non-org contexts
if (!isOrgContext) {
const redirectResult = await getTemporaryOrgRedirect({
slugs: teamSlug,
redirectType: RedirectType.Team,
eventTypeSlug: meetingSlug,
currentQuery: legacyCtx.query,
});
if (redirectResult) return redirect(redirectResult.redirect.destination);
}
// Handle org redirects
const redirectResult = await handleOrgRedirect({
slugs: [teamSlug],
redirectType: RedirectType.Team,
eventTypeSlug: meetingSlug,
context: legacyCtx,
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
});
if (redirectResult) return redirect(redirectResult.redirect.destination);
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
@@ -1,3 +1,4 @@
import { createRouterCaller } from "app/_trpc/context";
import type { PageProps as ServerPageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
@@ -7,6 +8,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -32,15 +34,20 @@ const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
}
const userRepo = new UserRepository(prisma);
const user = await userRepo.findUserTeams({
id: session.user.id,
});
const meCaller = await createRouterCaller(meRouter);
if (!user) {
const [userTeams, user] = await Promise.all([
userRepo.findUserTeams({
id: session.user.id,
}),
meCaller.get(),
]);
if (!userTeams || !user) {
return redirect("/auth/login");
}
return <Page hasPendingInvites={!!user.teams.find((team) => team.accepted === false)} />;
return <Page user={user} hasPendingInvites={!!userTeams.teams.find((team) => team.accepted === false)} />;
};
export default ServerPage;
@@ -1,7 +1,11 @@
import { type TFunction } from "i18next";
import { useEffect, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { RouterOutputs } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { List } from "@calcom/ui/components/list";
@@ -12,10 +16,18 @@ import { StepConnectionLoader } from "../components/StepConnectionLoader";
interface ConnectedAppStepProps {
nextStep: () => void;
isPageLoading: boolean;
user: RouterOutputs["viewer"]["me"]["get"];
}
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
const { nextStep, isPageLoading } = props;
const ConnectedVideoStepInner = ({
t,
setAnyInstalledVideoApps,
user,
}: {
t: TFunction;
setAnyInstalledVideoApps: Dispatch<SetStateAction<boolean>>;
user: RouterOutputs["viewer"]["me"]["get"];
}) => {
const { data: queryConnectedVideoApps, isPending } = trpc.viewer.apps.integrations.useQuery({
variant: "conferencing",
onlyInstalled: false,
@@ -30,47 +42,59 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
sortByMostPopular: true,
sortByInstalledFirst: true,
});
const { data } = useMeQuery();
const { t } = useLocale();
const metadata = userMetadata.parse(data?.metadata);
const hasAnyInstalledVideoApps = queryConnectedVideoApps?.items.some(
(item) => item.userCredentialIds.length > 0
);
useEffect(() => {
setAnyInstalledVideoApps(Boolean(hasAnyInstalledVideoApps));
}, [hasAnyInstalledVideoApps, setAnyInstalledVideoApps]);
if (isPending) {
return <StepConnectionLoader />;
}
const result = userMetadata.safeParse(user.metadata);
if (!result.success) {
return <StepConnectionLoader />;
}
const { data: metadata } = result;
const defaultConferencingApp = metadata?.defaultConferencingApp?.appSlug;
return (
<>
{!isPending && (
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
{queryConnectedVideoApps?.items &&
queryConnectedVideoApps?.items.map((item) => {
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
return (
<li key={item.name}>
{item.name && item.logo && (
<AppConnectionItem
type={item.type}
title={item.name}
isDefault={item.slug === defaultConferencingApp}
description={item.description}
dependencyData={item.dependencyData}
logo={item.logo}
slug={item.slug}
installed={item.userCredentialIds.length > 0}
defaultInstall={
!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"
}
/>
)}
</li>
);
})}
</List>
)}
<List className="bg-default border-subtle divide-subtle scroll-bar mx-1 max-h-[45vh] divide-y !overflow-y-scroll rounded-md border p-0 sm:mx-0">
{queryConnectedVideoApps?.items &&
queryConnectedVideoApps?.items.map((item) => {
if (item.slug === "daily-video") return null; // we dont want to show daily here as it is installed by default
return (
<li key={item.name}>
{item.name && item.logo && (
<AppConnectionItem
type={item.type}
title={item.name}
isDefault={item.slug === defaultConferencingApp}
description={item.description}
dependencyData={item.dependencyData}
logo={item.logo}
slug={item.slug}
installed={item.userCredentialIds.length > 0}
defaultInstall={!defaultConferencingApp && item.appData?.location?.linkType === "dynamic"}
/>
)}
</li>
);
})}
</List>
);
};
{isPending && <StepConnectionLoader />}
const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
const { nextStep, isPageLoading, user } = props;
const { t } = useLocale();
const [hasAnyInstalledVideoApps, setAnyInstalledVideoApps] = useState(false);
return (
<>
<ConnectedVideoStepInner setAnyInstalledVideoApps={setAnyInstalledVideoApps} t={t} user={user} />
<Button
EndIcon="arrow-right"
data-testid="save-video-button"
@@ -10,6 +10,7 @@ import { md } from "@calcom/lib/markdownIt";
import { telemetryEventTypes } from "@calcom/lib/telemetry";
import turndown from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { UserAvatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Editor } from "@calcom/ui/components/editor";
@@ -21,8 +22,11 @@ type FormData = {
bio: string;
};
const UserProfile = () => {
const [user] = trpc.viewer.me.get.useSuspenseQuery();
interface UserProfileProps {
user: RouterOutputs["viewer"]["me"]["get"];
}
const UserProfile = ({ user }: UserProfileProps) => {
const { t } = useLocale();
const avatarRef = useRef<HTMLInputElement>(null);
const { setValue, handleSubmit, getValues } = useForm<FormData>({
@@ -13,6 +13,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
import { telemetryEventTypes } from "@calcom/lib/telemetry";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Input } from "@calcom/ui/components/form";
@@ -21,11 +22,11 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability";
interface IUserSettingsProps {
nextStep: () => void;
hideUsername?: boolean;
user: RouterOutputs["viewer"]["me"]["get"];
}
const UserSettings = (props: IUserSettingsProps) => {
const { nextStep } = props;
const [user] = trpc.viewer.me.get.useSuspenseQuery();
const { nextStep, user } = props;
const { t } = useLocale();
const { setTimezone: setSelectedTimeZone, timezone: selectedTimeZone } = useTimePreferences();
const telemetry = useTelemetry();
@@ -15,7 +15,7 @@ import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/enums";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import { getRedirectWithOriginAndSearchString } from "@lib/handleOrgRedirect";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
@@ -64,17 +64,26 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
return notFound;
}
if (!org) {
const redirect = await getTemporaryOrgRedirect({
slugs: [username],
redirectType: RedirectType.User,
eventTypeSlug: slug,
currentQuery: context.query,
});
// Get just the origin and searchString from the redirect to ensure that we don't redirect to a URL that exposes the real path to book the user for any other events \
// This is important for a private booking link
// e.g. http://app.cal.com/d/sgdthj8mu4nsLNTYi3fW2p/demo -> should redirect to -> http://acme.cal.com/d/sgdthj8mu4nsLNTYi3fW2p/demo and not to http://acme.cal.com/john/demo(which exposes the real path to book the user for any other events)
const redirectWithOriginAndSearchString = await getRedirectWithOriginAndSearchString({
slugs: [username],
redirectType: RedirectType.User,
context,
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
});
if (redirect) {
return redirect;
}
if (redirectWithOriginAndSearchString) {
return {
redirect: {
permanent: false,
// App Router doesn't have access to the current path directly, so we build it manually
destination: `${redirectWithOriginAndSearchString.origin ?? ""}/d/${link}/${slug}${
redirectWithOriginAndSearchString.searchString
}`,
},
};
}
name = profileUsername || username;
@@ -1,333 +0,0 @@
import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
import { describe, it, expect, beforeEach } from "vitest";
import { RedirectType } from "@calcom/prisma/client";
import { getTemporaryOrgRedirect } from "./getTemporaryOrgRedirect";
const mockData = {
redirects: [] as {
toUrl: string;
from: string;
redirectType: RedirectType;
}[],
};
function mockARedirectInDB({
toUrl,
slug,
redirectType,
}: {
toUrl: string;
slug: string;
redirectType: RedirectType;
}) {
mockData.redirects.push({
toUrl,
from: slug,
redirectType,
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
prismaMock.tempOrgRedirect.findMany.mockImplementation(({ where }) => {
return new Promise((resolve) => {
const tempOrgRedirects: typeof mockData.redirects = [];
where.from.in.forEach((whereSlug: string) => {
const matchingRedirect = mockData.redirects.find((redirect) => {
return where.type === redirect.redirectType && whereSlug === redirect.from && where.fromOrgId === 0;
});
if (matchingRedirect) {
tempOrgRedirects.push(matchingRedirect);
}
});
resolve(tempOrgRedirects);
});
});
}
beforeEach(() => {
mockData.redirects = [];
});
describe("getTemporaryOrgRedirect", () => {
it("should generate event-type URL without existing query params", async () => {
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
const redirect = await getTemporaryOrgRedirect({
slugs: "slug",
redirectType: RedirectType.User,
eventTypeSlug: "30min",
currentQuery: {},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/30min?orgRedirection=true",
},
});
});
it("should generate event-type URL with existing query params", async () => {
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
const redirect = await getTemporaryOrgRedirect({
slugs: "slug",
redirectType: RedirectType.User,
eventTypeSlug: "30min",
currentQuery: {
abc: "1",
},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/30min?abc=1&orgRedirection=true",
},
});
});
it("should generate User URL with existing query params", async () => {
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
const redirect = await getTemporaryOrgRedirect({
slugs: "slug",
redirectType: RedirectType.User,
eventTypeSlug: null,
currentQuery: {
abc: "1",
},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com?abc=1&orgRedirection=true",
},
});
});
it("should generate Team Profile URL with existing query params", async () => {
mockARedirectInDB({
slug: "seeded-team",
toUrl: "https://calcom.cal.com",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slugs: "seeded-team",
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: {
abc: "1",
},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com?abc=1&orgRedirection=true",
},
});
});
it("should generate Team Event URL with existing query params", async () => {
mockARedirectInDB({
slug: "seeded-team",
toUrl: "https://calcom.cal.com",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slugs: "seeded-team",
redirectType: RedirectType.Team,
eventTypeSlug: "30min",
currentQuery: {
abc: "1",
},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/30min?abc=1&orgRedirection=true",
},
});
});
it("should generate Team Event URL without query params", async () => {
mockARedirectInDB({
slug: "seeded-team",
toUrl: "https://calcom.cal.com",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slugs: "seeded-team",
redirectType: RedirectType.Team,
eventTypeSlug: "30min",
currentQuery: {},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/30min?orgRedirection=true",
},
});
});
it("should generate Dynamic Group Booking Profile Url", async () => {
mockARedirectInDB({
slug: "first",
toUrl: "https://calcom.cal.com/first-in-org1",
redirectType: RedirectType.Team,
});
mockARedirectInDB({
slug: "second",
toUrl: "https://calcom.cal.com/second-in-org1",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slugs: ["first", "second"],
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: {},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/first-in-org1+second-in-org1?orgRedirection=true",
},
});
});
it("should generate Dynamic Group Booking Profile Url - same order", async () => {
mockARedirectInDB({
slug: "second",
toUrl: "https://calcom.cal.com/second-in-org1",
redirectType: RedirectType.Team,
});
mockARedirectInDB({
slug: "first",
toUrl: "https://calcom.cal.com/first-in-org1",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slugs: ["first", "second"],
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: {},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/first-in-org1+second-in-org1?orgRedirection=true",
},
});
const redirect1 = await getTemporaryOrgRedirect({
slugs: ["second", "first"],
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: {},
});
expect(redirect1).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/second-in-org1+first-in-org1?orgRedirection=true",
},
});
});
it("should generate Dynamic Group Booking Profile Url with query params", async () => {
mockARedirectInDB({
slug: "first",
toUrl: "https://calcom.cal.com/first-in-org1",
redirectType: RedirectType.Team,
});
mockARedirectInDB({
slug: "second",
toUrl: "https://calcom.cal.com/second-in-org1",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slugs: ["first", "second"],
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: {
abc: "1",
},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/first-in-org1+second-in-org1?abc=1&orgRedirection=true",
},
});
});
it("should generate Dynamic Group Booking EventType Url", async () => {
mockARedirectInDB({
slug: "first",
toUrl: "https://calcom.cal.com/first-in-org1",
redirectType: RedirectType.Team,
});
mockARedirectInDB({
slug: "second",
toUrl: "https://calcom.cal.com/second-in-org1",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slugs: ["first", "second"],
redirectType: RedirectType.Team,
eventTypeSlug: "30min",
currentQuery: {},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/first-in-org1+second-in-org1/30min?orgRedirection=true",
},
});
});
it("should generate Dynamic Group Booking EventType Url with query params", async () => {
mockARedirectInDB({
slug: "first",
toUrl: "https://calcom.cal.com/first-in-org1",
redirectType: RedirectType.Team,
});
mockARedirectInDB({
slug: "second",
toUrl: "https://calcom.cal.com/second-in-org1",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slugs: ["first", "second"],
redirectType: RedirectType.Team,
eventTypeSlug: "30min",
currentQuery: {
abc: "1",
},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/first-in-org1+second-in-org1/30min?abc=1&orgRedirection=true",
},
});
});
});
-71
View File
@@ -1,71 +0,0 @@
import type { ParsedUrlQuery } from "querystring";
import { stringify } from "querystring";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { RedirectType } from "@calcom/prisma/client";
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
export const getTemporaryOrgRedirect = async ({
slugs,
redirectType,
eventTypeSlug,
currentQuery,
}: {
slugs: string[] | string;
redirectType: RedirectType;
eventTypeSlug: string | null;
currentQuery: ParsedUrlQuery;
}) => {
const prisma = (await import("@calcom/prisma")).default;
slugs = slugs instanceof Array ? slugs : [slugs];
log.debug(
`Looking for redirect for`,
safeStringify({
slugs,
redirectType,
eventTypeSlug,
})
);
const redirects = await prisma.tempOrgRedirect.findMany({
where: {
type: redirectType,
from: {
in: slugs,
},
fromOrgId: 0,
},
});
const currentQueryString = stringify(currentQuery);
if (!redirects.length) {
return null;
}
// Use the first redirect origin as the new origin as we aren't supposed to handle different org usernames in a group
const newOrigin = new URL(redirects[0].toUrl).origin;
const query = currentQueryString ? `?${currentQueryString}&orgRedirection=true` : "?orgRedirection=true";
// Use the same order as in input slugs - It is important from Dynamic Group perspective as the first user's settings are used for various things
const newSlugs = slugs.map((slug) => {
const redirect = redirects.find((redirect) => redirect.from === slug);
if (!redirect) {
return slug;
}
const newSlug = new URL(redirect.toUrl).pathname.slice(1);
return newSlug;
});
const newSlug = newSlugs.join("+");
const newPath = newSlug ? `/${newSlug}` : "";
const newDestination = `${newOrigin}${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}`;
log.debug(`Suggesting redirect from ${slugs} to ${newDestination}`);
return {
redirect: {
permanent: false,
destination: newDestination,
},
} as const;
};
+704
View File
@@ -0,0 +1,704 @@
import type { GetServerSidePropsContext } from "next";
import type { ParsedUrlQuery } from "querystring";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import * as constants from "@calcom/lib/constants";
import { RedirectType } from "@calcom/prisma/client";
import { handleOrgRedirect, getRedirectWithOriginAndSearchString } from "./handleOrgRedirect";
// Mock prisma for all tests
const prismaMock = vi.hoisted(() => ({
tempOrgRedirect: {
findMany: vi.fn(),
},
}));
vi.mock("@calcom/prisma", () => ({
default: prismaMock,
}));
const createTestContext = (overrides?: {
host?: string;
query?: ParsedUrlQuery;
}): GetServerSidePropsContext =>
({
req: {
headers: {
host: overrides?.host || "cal.local:3000",
},
},
query: overrides?.query || {},
} as unknown as GetServerSidePropsContext);
const createTestRedirectParams = (overrides?: {
slugs?: string[];
redirectType?: RedirectType;
eventTypeSlug?: string | null;
context?: GetServerSidePropsContext;
currentOrgDomain?: string | null;
}) => ({
slugs: overrides?.slugs || ["pro"],
redirectType: overrides?.redirectType || RedirectType.User,
eventTypeSlug: overrides?.eventTypeSlug ?? null,
context: overrides?.context || createTestContext(),
currentOrgDomain: overrides?.currentOrgDomain ?? null,
});
interface RedirectScenario {
redirects: Array<{
from: string;
toUrl: string;
type?: RedirectType;
}>;
}
const createRedirectScenario = (scenario: RedirectScenario) => {
// Create a map for quick lookup based on input parameters
const redirectMap = new Map<string, (typeof scenario.redirects)[0]>();
scenario.redirects.forEach((redirect) => {
const key = `${redirect.type || RedirectType.User}-${redirect.from}`;
redirectMap.set(key, redirect);
});
// Return a mock implementation that returns data based on the actual query
prismaMock.tempOrgRedirect.findMany.mockImplementation(async (params) => {
const { type, from } = params?.where || {};
const slugsToFind = from?.in || [];
// Return redirects that match the requested type and slugs
const matchingRedirects = slugsToFind
.map((slug: string) => {
const key = `${type}-${slug}`;
const redirect = redirectMap.get(key);
if (redirect) {
return {
from: redirect.from,
toUrl: redirect.toUrl,
type: redirect.type || type,
fromOrgId: 0,
};
}
return null;
})
.filter(Boolean);
return matchingRedirects;
});
};
const expectRedirectTo = (result: any, expectedDestination: string) => {
expect(result).toEqual({
redirect: {
permanent: false,
destination: expectedDestination,
},
});
};
const expectNoRedirect = (result: any) => {
expect(result).toBeNull();
};
const expectPrismaCalledWith = (expectedParams: { type: RedirectType; slugs: string[] }) => {
expect(prismaMock.tempOrgRedirect.findMany).toHaveBeenCalledWith({
where: {
type: expectedParams.type,
from: {
in: expectedParams.slugs,
},
fromOrgId: 0,
},
});
};
const expectPrismaNotCalled = () => {
expect(prismaMock.tempOrgRedirect.findMany).not.toHaveBeenCalled();
};
// Verify that the redirect was actually built from the data returned by Prisma
const expectRedirectUsesData = (
result: any,
expectedSlug: string,
expectedOrigin = "https://acme.cal.local:3000"
) => {
if (!result || !result.redirect) {
throw new Error("Expected a redirect result but got null/undefined");
}
const url = new URL(result.redirect.destination);
expect(url.origin).toBe(expectedOrigin);
expect(url.pathname).toContain(expectedSlug);
};
describe("handleOrgRedirect", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default to no redirects - tests will override with specific scenarios
createRedirectScenario({ redirects: [] });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("when not in organization context", () => {
it("should redirect to organization URL when redirect exists", async () => {
// Setup scenario with unique test data to verify correct usage
createRedirectScenario({
redirects: [
{
from: "pro",
toUrl: "https://acme.cal.local:3000/pro-example",
type: RedirectType.User,
},
// Add a decoy redirect to ensure the code picks the right one
{
from: "other-user",
toUrl: "https://acme.cal.local:3000/wrong-redirect",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams({
slugs: ["pro"],
});
const result = await handleOrgRedirect(params);
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["pro"] });
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?orgRedirection=true");
expectRedirectUsesData(result, "pro-example");
});
it("should return null when no redirect exists", async () => {
// Explicitly set up no redirects scenario
createRedirectScenario({ redirects: [] });
const params = createTestRedirectParams({
slugs: ["pro"],
});
const result = await handleOrgRedirect(params);
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["pro"] });
expectNoRedirect(result);
});
it("should handle Team redirect type", async () => {
createRedirectScenario({
redirects: [
{
from: "sales-team",
toUrl: "https://acme.cal.local:3000/sales",
type: RedirectType.Team,
},
// Add a User redirect with same slug to verify type filtering
{
from: "sales-team",
toUrl: "https://acme.cal.local:3000/wrong-user-redirect",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams({
slugs: ["sales-team"],
redirectType: RedirectType.Team,
});
const result = await handleOrgRedirect(params);
expectPrismaCalledWith({ type: RedirectType.Team, slugs: ["sales-team"] });
expectRedirectTo(result, "https://acme.cal.local:3000/sales?orgRedirection=true");
expectRedirectUsesData(result, "sales");
});
});
describe("when SINGLE_ORG_SLUG mode is enabled", () => {
beforeEach(() => {
vi.spyOn(constants, "SINGLE_ORG_SLUG", "get").mockReturnValue("acme");
});
it("should redirect using relative path when accessing from base domain", async () => {
createRedirectScenario({
redirects: [
{
from: "pro",
toUrl: "https://acme.cal.local:3000/pro-example",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams({
context: createTestContext({ host: "cal.local:3000" }),
currentOrgDomain: "acme",
});
const result = await handleOrgRedirect(params);
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["pro"] });
// Should use relative path to stay on same domain
expectRedirectTo(result, "/pro-example?orgRedirection=true");
});
it("should redirect john87 to john using relative path in single org mode", async () => {
createRedirectScenario({
redirects: [
{
from: "john87",
toUrl: "https://acme.cal.local:3000/john",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams({
slugs: ["john87"],
context: createTestContext({
host: "my-instance.com",
}),
currentOrgDomain: "acme",
});
const result = await handleOrgRedirect(params);
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["john87"] });
// Should redirect to relative path /john to stay on my-instance.com
expectRedirectTo(result, "/john?orgRedirection=true");
});
describe("when orgRedirection parameter is present", () => {
it("should not redirect to prevent loops", async () => {
const params = createTestRedirectParams({
context: createTestContext({ query: { orgRedirection: "true" } }),
currentOrgDomain: "acme",
});
const result = await handleOrgRedirect(params);
expectPrismaNotCalled();
expectNoRedirect(result);
});
});
});
describe("when event type slug is provided", () => {
it("should include event type slug in redirect destination", async () => {
createRedirectScenario({
redirects: [
{
from: "pro",
toUrl: "https://acme.cal.local:3000/pro-example",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams({
eventTypeSlug: "30min",
});
const result = await handleOrgRedirect(params);
expectPrismaCalledWith({ type: RedirectType.User, slugs: ["pro"] });
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example/30min?orgRedirection=true");
// Verify the event type slug was properly appended
const url = new URL(result?.redirect.destination || "");
expect(url.pathname).toBe("/pro-example/30min");
});
it("should handle empty event type slug", async () => {
createRedirectScenario({
redirects: [
{
from: "pro",
toUrl: "https://acme.cal.local:3000/pro-example",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams({
eventTypeSlug: null,
});
const result = await handleOrgRedirect(params);
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?orgRedirection=true");
expectRedirectUsesData(result, "pro-example");
});
});
describe("when handling multiple slugs (group bookings)", () => {
it("should preserve order of slugs in redirect", async () => {
createRedirectScenario({
redirects: [
{
from: "user1",
toUrl: "https://acme.cal.local:3000/org-user1",
type: RedirectType.User,
},
{
from: "user2",
toUrl: "https://acme.cal.local:3000/org-user2",
type: RedirectType.User,
},
{
from: "user3",
toUrl: "https://acme.cal.local:3000/org-user3",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams({
slugs: ["user1", "user2", "user3"],
});
const result = await handleOrgRedirect(params);
expectRedirectTo(
result,
"https://acme.cal.local:3000/org-user1+org-user2+org-user3?orgRedirection=true"
);
// Verify order is preserved from input slugs
const url = new URL(result?.redirect.destination || "");
expect(url.pathname).toBe("/org-user1+org-user2+org-user3");
});
it("should handle partial redirects in group bookings", async () => {
createRedirectScenario({
redirects: [
{
from: "user1",
toUrl: "https://acme.cal.local:3000/org-user1",
type: RedirectType.User,
},
// user2 has no redirect - it should stay as-is
{
from: "user3",
toUrl: "https://acme.cal.local:3000/org-user3",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams({
slugs: ["user1", "user2", "user3"],
});
const result = await handleOrgRedirect(params);
expectRedirectTo(result, "https://acme.cal.local:3000/org-user1+user2+org-user3?orgRedirection=true");
// Verify that user2 remains unchanged while others are redirected
const url = new URL(result?.redirect.destination || "");
expect(url.pathname).toBe("/org-user1+user2+org-user3");
});
it("should handle different origins in redirects", async () => {
createRedirectScenario({
redirects: [
{
from: "user1",
toUrl: "https://org1.cal.com/john",
type: RedirectType.User,
},
{
from: "user2",
toUrl: "https://org1.cal.com/jane",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams({
slugs: ["user1", "user2"],
});
const result = await handleOrgRedirect(params);
// Should use the first redirect's origin
expectRedirectTo(result, "https://org1.cal.com/john+jane?orgRedirection=true");
expectRedirectUsesData(result, "john+jane", "https://org1.cal.com");
});
});
describe("when handling query parameters", () => {
it("should add orgRedirection=true to empty query", async () => {
createRedirectScenario({
redirects: [
{
from: "pro",
toUrl: "https://acme.cal.local:3000/pro-example",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams({
context: createTestContext({ query: {} }),
});
const result = await handleOrgRedirect(params);
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?orgRedirection=true");
expectRedirectUsesData(result, "pro-example");
});
it("should preserve existing query parameters", async () => {
createRedirectScenario({
redirects: [
{
from: "pro",
toUrl: "https://acme.cal.local:3000/pro-example",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams({
context: createTestContext({
query: { abc: "1", xyz: "test" },
}),
});
const result = await handleOrgRedirect(params);
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?abc=1&xyz=test&orgRedirection=true");
// Verify query parameters are preserved
const url = new URL(result?.redirect.destination || "");
expect(url.searchParams.get("abc")).toBe("1");
expect(url.searchParams.get("xyz")).toBe("test");
});
it("should not duplicate orgRedirection parameter", async () => {
createRedirectScenario({
redirects: [
{
from: "pro",
toUrl: "https://acme.cal.local:3000/pro-example",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams({
context: createTestContext({
query: { orgRedirection: "true", abc: "1" },
}),
});
const result = await handleOrgRedirect(params);
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example?abc=1&orgRedirection=true");
// Verify only one orgRedirection parameter exists
const url = new URL(result?.redirect.destination || "");
const orgRedirectionParams = url.searchParams.getAll("orgRedirection");
expect(orgRedirectionParams).toHaveLength(1);
expect(orgRedirectionParams[0]).toBe("true");
});
it("should handle array query parameters", async () => {
createRedirectScenario({
redirects: [
{
from: "pro",
toUrl: "https://acme.cal.local:3000/pro-example",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams({
context: createTestContext({
query: { tags: ["tag1", "tag2"], filter: "active" },
}),
});
const result = await handleOrgRedirect(params);
const url = new URL(result?.redirect.destination || "");
expect(url.searchParams.getAll("tags")).toEqual(["tag1", "tag2"]);
expect(url.searchParams.get("filter")).toBe("active");
expect(url.searchParams.get("orgRedirection")).toBe("true");
});
});
describe("edge cases and error scenarios", () => {
it("should handle empty slugs array", async () => {
createRedirectScenario({ redirects: [] });
const params = createTestRedirectParams({
slugs: [],
});
const result = await handleOrgRedirect(params);
expectPrismaCalledWith({ type: RedirectType.User, slugs: [] });
expectNoRedirect(result);
});
it("should handle redirects with trailing slashes", async () => {
createRedirectScenario({
redirects: [
{
from: "pro",
toUrl: "https://acme.cal.local:3000/pro-example/",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams();
const result = await handleOrgRedirect(params);
expectRedirectTo(result, "https://acme.cal.local:3000/pro-example/?orgRedirection=true");
// Verify trailing slash is preserved
const url = new URL(result?.redirect.destination || "");
expect(url.pathname).toBe("/pro-example/");
});
it("should handle redirects to root path", async () => {
createRedirectScenario({
redirects: [
{
from: "pro",
toUrl: "https://acme.cal.local:3000",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams();
const result = await handleOrgRedirect(params);
expectRedirectTo(result, "https://acme.cal.local:3000?orgRedirection=true");
// Verify root path redirect
const url = new URL(result?.redirect.destination || "");
expect(url.pathname).toBe("/");
});
it("should handle special characters in slugs", async () => {
createRedirectScenario({
redirects: [
{
from: "user-name.test",
toUrl: "https://acme.cal.local:3000/user_name_test",
type: RedirectType.User,
},
],
});
const params = createTestRedirectParams({
slugs: ["user-name.test"],
});
const result = await handleOrgRedirect(params);
expectRedirectTo(result, "https://acme.cal.local:3000/user_name_test?orgRedirection=true");
expectRedirectUsesData(result, "user_name_test");
});
});
describe("when SINGLE_ORG_SLUG is not set", () => {
beforeEach(() => {
vi.spyOn(constants, "SINGLE_ORG_SLUG", "get").mockReturnValue(undefined);
});
it("should not check for redirects when in org context", async () => {
const params = createTestRedirectParams({
currentOrgDomain: "acme",
});
const result = await handleOrgRedirect(params);
expectPrismaNotCalled();
expectNoRedirect(result);
});
});
});
describe("getRedirectWithOriginAndSearchString", () => {
beforeEach(() => {
vi.spyOn(constants, "SINGLE_ORG_SLUG", "get").mockReturnValue(undefined);
});
afterEach(() => {
prismaMock.tempOrgRedirect.findMany.mockReset();
});
it("should return origin and search string for absolute URL redirects", async () => {
createRedirectScenario({
redirects: [
{
from: "john",
toUrl: "https://acme.cal.local:3000/john-org",
type: RedirectType.User,
},
],
});
const params = {
slugs: ["john"],
redirectType: RedirectType.User,
context: createTestContext(),
currentOrgDomain: null,
};
const result = await getRedirectWithOriginAndSearchString(params);
expect(result).not.toBeNull();
expect(result?.origin).toBe("https://acme.cal.local:3000");
expect(result?.searchString).toBe("?orgRedirection=true");
});
it("should return null origin for relative path redirects", async () => {
// Enable SINGLE_ORG_SLUG for this test to get relative paths
vi.spyOn(constants, "SINGLE_ORG_SLUG", "get").mockReturnValue("acme");
createRedirectScenario({
redirects: [
{
from: "john",
toUrl: "https://acme.cal.local:3000/john-org",
type: RedirectType.User,
},
],
});
const params = {
slugs: ["john"],
redirectType: RedirectType.User,
context: createTestContext({ host: "my-instance.com" }),
currentOrgDomain: "acme",
};
const result = await getRedirectWithOriginAndSearchString(params);
expect(result).not.toBeNull();
expect(result?.origin).toBeNull(); // Relative path has no origin
expect(result?.searchString).toBe("?orgRedirection=true");
});
it("should preserve existing query parameters in search string", async () => {
createRedirectScenario({
redirects: [
{
from: "john",
toUrl: "https://acme.cal.local:3000/john-org",
type: RedirectType.User,
},
],
});
const params = {
slugs: ["john"],
redirectType: RedirectType.User,
context: createTestContext({ query: { foo: "bar", baz: "qux" } }),
currentOrgDomain: null,
};
const result = await getRedirectWithOriginAndSearchString(params);
expect(result).not.toBeNull();
expect(result?.origin).toBe("https://acme.cal.local:3000");
expect(result?.searchString).toBe("?foo=bar&baz=qux&orgRedirection=true");
});
it("should return null when no redirect exists", async () => {
createRedirectScenario({ redirects: [] });
const params = {
slugs: ["john"],
redirectType: RedirectType.User,
context: createTestContext(),
currentOrgDomain: null,
};
const result = await getRedirectWithOriginAndSearchString(params);
expect(result).toBeNull();
});
});
+203
View File
@@ -0,0 +1,203 @@
import type { ParsedUrlQuery } from "querystring";
import { stringify } from "querystring";
import { SINGLE_ORG_SLUG } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { RedirectType } from "@calcom/prisma/client";
const log = logger.getSubLogger({ prefix: ["lib", "handleOrgRedirect"] });
type NextJsRedirect = {
redirect: {
permanent: false;
/**
* It could be a full URL or a relative path
*/
destination: string;
};
};
function getSearchString(relativeOrAbsoluteUrl: string) {
const url = new URL(relativeOrAbsoluteUrl, "http://localhost");
return url.search;
}
const getTemporaryOrgRedirect = async ({
slugs,
redirectType,
eventTypeSlug,
currentQuery,
useRelativePath = false,
}: {
slugs: string[];
redirectType: RedirectType;
eventTypeSlug: string | null;
currentQuery: ParsedUrlQuery;
useRelativePath?: boolean;
}): Promise<NextJsRedirect | null> => {
const prisma = (await import("@calcom/prisma")).default;
log.debug(
`Looking for redirect for`,
safeStringify({
slugs,
redirectType,
eventTypeSlug,
})
);
const redirects = await prisma.tempOrgRedirect.findMany({
where: {
type: redirectType,
from: {
in: slugs,
},
fromOrgId: 0,
},
});
if (!redirects.length) {
return null;
}
// Use the first redirect origin as the new origin as we aren't supposed to handle different org usernames in a group
const newOrigin = new URL(redirects[0].toUrl).origin;
// Filter out any existing orgRedirection parameter to avoid duplicates
// querystring.stringify transforms undefined values to empty strings, so we need to filter those out as well initially
const filteredQuery = Object.entries(currentQuery).reduce((acc, [key, value]) => {
if (key !== "orgRedirection" && value !== undefined) {
acc[key] = value;
}
return acc;
}, {} as ParsedUrlQuery);
const currentQueryString = stringify(filteredQuery);
const query = currentQueryString ? `?${currentQueryString}&orgRedirection=true` : "?orgRedirection=true";
// Use the same order as in input slugs - It is important from Dynamic Group perspective as the first user's settings are used for various things
const newSlugs = slugs.map((slug) => {
const redirect = redirects.find((redirect) => redirect.from === slug);
if (!redirect) {
return slug;
}
const newSlug = new URL(redirect.toUrl).pathname.slice(1);
return newSlug;
});
const newSlug = newSlugs.join("+");
const newPath = newSlug ? `/${newSlug}` : "";
// When in single org mode and not on actual org subdomain, use relative path to stay on same domain
const newDestination = useRelativePath
? `${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}`
: `${newOrigin}${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}`;
log.debug(`Suggesting redirect from ${slugs} to ${newDestination}`);
return {
redirect: {
permanent: false,
destination: newDestination,
},
} as const;
};
interface HandleOrgRedirectParams {
slugs: string[];
redirectType: RedirectType;
eventTypeSlug: string | null;
context: {
query: ParsedUrlQuery;
};
currentOrgDomain: string | null;
}
/**
* Handles organization redirects for both regular org context and SINGLE_ORG_SLUG mode
* The redirect is required for all existing user links and team links to keep working when a user/team is moved to an organization
* Example:
* - User "john87" is added to organization "acme" and his username in the organization is "john". So, cal.com/john87 is redirected to cal.com/john
* - Team "acme-sales" is added to organization "acme" and its slug in the organization is "sales". So, cal.com/acme-sales is redirected to cal.com/sales
*
* Returns a redirect object if a redirect is needed, null otherwise
*/
export async function handleOrgRedirect({
slugs,
redirectType,
eventTypeSlug,
context,
currentOrgDomain,
}: HandleOrgRedirectParams) {
const isOrgContext = !!currentOrgDomain;
const isARedirectFromNonOrgLink = context.query.orgRedirection === "true";
// If we're not in an org context, the request could clearly be eligible for a redirect
if (!isOrgContext) {
const nextJsRedirect = await getTemporaryOrgRedirect({
slugs,
redirectType,
eventTypeSlug,
currentQuery: context.query,
});
if (nextJsRedirect) {
return nextJsRedirect;
}
}
const isSingleOrgMode = SINGLE_ORG_SLUG && isOrgContext;
// When SINGLE_ORG_SLUG is set(which is possible in Self-Hosted instances), isOrgContext could be true even when the current domain is not an org subdomain
// Example: my-instance.com could technically mean acme.my-instance.com where acme is the org slug
// In such a case, the existing links which were on my-instance.com e.g. my-instance.com/john87 should be redirected to my-instance.com/john where john is the new username in Organization
// This is why we need to follow redirect even when in Org Context
if (!isSingleOrgMode) {
return null;
}
// If already redirected from a non-org link, we shouldn't redirect again. Protects against infinite redirects
if (isARedirectFromNonOrgLink) {
return null;
}
// We're in single org mode but not on an actual org subdomain
// Check if there's a redirect for this username
// Use relative path since we want to stay on the same domain (my-instance.com)
const nextJsRedirect = await getTemporaryOrgRedirect({
slugs,
redirectType,
eventTypeSlug,
currentQuery: context.query,
useRelativePath: true,
});
return nextJsRedirect;
}
export async function getRedirectWithOriginAndSearchString({
slugs,
redirectType,
context,
currentOrgDomain,
}: Omit<HandleOrgRedirectParams, "eventTypeSlug">) {
const nextJsRedirect = await handleOrgRedirect({
slugs,
redirectType,
eventTypeSlug: null,
context,
currentOrgDomain,
});
if (!nextJsRedirect) {
return null;
}
const newDestination = nextJsRedirect.redirect.destination;
// If there is an origin use it, otherwise mark no new Origin and it remains the same domain
const newOrigin =
newDestination.startsWith("http://") || newDestination.startsWith("https://")
? new URL(newDestination).origin
: null;
return {
origin: newOrigin,
searchString: getSearchString(newDestination),
};
}
@@ -23,15 +23,18 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
if (team) {
return GSSTeamPage({
...ctx,
query: { slug: ctx.query.user, orgRedirection: ctx.query.orgRedirection },
query: {
slug: ctx.query.user,
...(ctx.query.orgRedirection !== undefined && { orgRedirection: ctx.query.orgRedirection }),
},
});
}
return GSSUserPage({
...ctx,
query: {
user: ctx.query.user,
redirect: ctx.query.redirect,
orgRedirection: ctx.query.orgRedirection,
...(ctx.query.redirect !== undefined && { redirect: ctx.query.redirect }),
...(ctx.query.orgRedirection !== undefined && { orgRedirection: ctx.query.orgRedirection }),
},
});
};
@@ -15,7 +15,7 @@ import type { User } from "@calcom/prisma/client";
import { BookingStatus, RedirectType } from "@calcom/prisma/client";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
const paramsSchema = z.object({
type: z.string().transform((s) => slugify(s)),
@@ -33,19 +33,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slugs: teamSlug,
redirectType: RedirectType.Team,
eventTypeSlug: meetingSlug,
currentQuery: context.query,
});
const redirect = await handleOrgRedirect({
slugs: [teamSlug],
redirectType: RedirectType.Team,
eventTypeSlug: meetingSlug,
context,
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
});
if (redirect) {
return redirect;
}
if (redirect) {
return redirect;
}
const team = await getTeamWithEventsData(teamSlug, meetingSlug, isValidOrgDomain, currentOrgDomain);
@@ -19,7 +19,7 @@ import type { Team, OrganizationSettings } from "@calcom/prisma/client";
import { RedirectType } from "@calcom/prisma/client";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
const log = logger.getSubLogger({ prefix: ["team/[slug]"] });
@@ -67,7 +67,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
context.req,
context.params?.orgSlug ?? context.query?.orgSlug
);
const isOrgContext = isValidOrgDomain && currentOrgDomain;
// Provided by Rewrite from next.config.js
const isOrgProfile = context.query?.isOrgProfile === "1";
@@ -89,12 +88,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
isOrgView: isValidOrgDomain && isOrgProfile,
});
if (!isOrgContext && slug) {
const redirect = await getTemporaryOrgRedirect({
slugs: slug,
if (slug) {
const redirect = await handleOrgRedirect({
slugs: [slug],
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: context.query,
context,
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
});
if (redirect) {
@@ -10,8 +10,7 @@ import { z } from "zod";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import { trpc } from "@calcom/trpc";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import type { RouterOutputs } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { StepCard } from "@calcom/ui/components/card";
@@ -85,13 +84,14 @@ const stepRouteSchema = z.object({
type PageProps = {
hasPendingInvites: boolean;
user: RouterOutputs["viewer"]["me"]["get"];
};
const OnboardingPage = (props: PageProps) => {
const pathname = usePathname();
const params = useParamsWithFallback();
const user = props.user;
const router = useRouter();
const [user] = trpc.viewer.me.get.useSuspenseQuery();
const { t } = useLocale();
const [isNextStepLoading, startTransition] = useTransition();
@@ -162,20 +162,20 @@ const OnboardingPage = (props: PageProps) => {
<StepCard>
<Suspense fallback={<Icon name="loader" />}>
{currentStep === "user-settings" && (
<UserSettings nextStep={goToNextStep} hideUsername={from === "signup"} />
<UserSettings nextStep={goToNextStep} hideUsername={from === "signup"} user={user} />
)}
{currentStep === "connected-calendar" && (
<ConnectedCalendars nextStep={goToNextStep} isPageLoading={isNextStepLoading} />
)}
{currentStep === "connected-video" && (
<ConnectedVideoStep nextStep={goToNextStep} isPageLoading={isNextStepLoading} />
<ConnectedVideoStep nextStep={goToNextStep} isPageLoading={isNextStepLoading} user={user} />
)}
{currentStep === "setup-availability" && (
<SetupAvailability nextStep={goToNextStep} defaultScheduleId={user.defaultScheduleId} />
)}
{currentStep === "user-profile" && <UserProfile />}
{currentStep === "user-profile" && <UserProfile user={user} />}
</Suspense>
</StepCard>
+2
View File
@@ -179,6 +179,8 @@ const nextConfig = {
"superagent-proxy", // Dependencies of @tryvital/vital-node
"superagent", // Dependencies of akismet
"formidable", // Dependencies of akismet
"@boxyhq/saml-jackson",
"jose", // Dependency of @boxyhq/saml-jackson
],
experimental: {
// externalize server-side node_modules with size > 1mb, to improve dev mode performance/RAM usage
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.5.15",
"version": "5.5.17",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+19
View File
@@ -57,3 +57,22 @@ export const applyTheme = function () {
console.error("Error applying theme:", e);
}
};
// ToDesktop class application function - will be stringified and injected. So, it must not use anything from the closure
export const applyToDesktopClass = function () {
try {
const onReady = () => {
if (typeof window !== "undefined" && window.todesktop && document.documentElement) {
document.documentElement.classList.add("todesktop");
} else if (document.documentElement) {
return;
} else {
requestAnimationFrame(onReady);
}
};
requestAnimationFrame(onReady);
} catch (e) {
console.error("Error applying todesktop class:", e);
}
};
+2 -1
View File
@@ -5,7 +5,7 @@ import Document, { Head, Html, Main, NextScript } from "next/document";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { applyTheme } from "./_applyThemeForDocument";
import { applyTheme, applyToDesktopClass } from "./_applyThemeForDocument";
type Props = Record<string, unknown> & DocumentProps & { newLocale: string };
@@ -50,6 +50,7 @@ class MyDocument extends Document<Props> {
__html: `
window.calNewLocale = "${newLocale}";
(${applyTheme.toString()})();
(${applyToDesktopClass.toString()})();
`,
}}
/>
@@ -5,7 +5,7 @@ import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { ZGetRetellLLMSchema } from "@calcom/features/calAIPhone/zod-utils";
import type { TGetRetellLLMSchema } from "@calcom/features/calAIPhone/zod-utils";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
import { fetcher } from "@calcom/lib/retellAIFetcher";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import prisma from "@calcom/prisma";
@@ -15,7 +15,7 @@ import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { BookingStatus, RedirectType } from "@calcom/prisma/client";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
import { getUsersInOrgContext } from "@server/lib/[user]/getServerSideProps";
@@ -122,17 +122,17 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const allowRescheduleForCancelledBooking = context.query.allowRescheduleForCancelledBooking === "true";
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const org = isValidOrgDomain ? currentOrgDomain : null;
if (!org) {
const redirect = await getTemporaryOrgRedirect({
slugs: usernames,
redirectType: RedirectType.User,
eventTypeSlug: slug,
currentQuery: context.query,
});
if (redirect) {
return redirect;
}
const redirect = await handleOrgRedirect({
slugs: usernames,
redirectType: RedirectType.User,
eventTypeSlug: slug,
context,
currentOrgDomain: org,
});
if (redirect) {
return redirect;
}
const userRepo = new UserRepository(prisma);
@@ -220,18 +220,16 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const allowRescheduleForCancelledBooking = context.query.allowRescheduleForCancelledBooking === "true";
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slugs: usernames,
redirectType: RedirectType.User,
eventTypeSlug: slug,
currentQuery: context.query,
});
const redirect = await handleOrgRedirect({
slugs: usernames,
redirectType: RedirectType.User,
eventTypeSlug: slug,
context,
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
});
if (redirect) {
return redirect;
}
if (redirect) {
return redirect;
}
const [user] = await getUsersInOrgContext([username], isValidOrgDomain ? currentOrgDomain : null);
@@ -18,7 +18,7 @@ import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { UserProfile } from "@calcom/types/UserProfile";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import { handleOrgRedirect } from "@lib/handleOrgRedirect";
const log = logger.getSubLogger({ prefix: ["[[pages/[user]]]"] });
type UserPageProps = {
@@ -73,25 +73,20 @@ type UserPageProps = {
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const usernameList = getUsernameList(context.query.user as string);
const isARedirectFromNonOrgLink = context.query.orgRedirection === "true";
const isOrgContext = isValidOrgDomain && !!currentOrgDomain;
const dataFetchStart = Date.now();
if (!isOrgContext) {
// If there is no org context, see if some redirect is setup due to org migration
const redirect = await getTemporaryOrgRedirect({
slugs: usernameList,
redirectType: RedirectType.User,
eventTypeSlug: null,
currentQuery: context.query,
});
const redirect = await handleOrgRedirect({
slugs: usernameList,
redirectType: RedirectType.User,
eventTypeSlug: null,
context,
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
});
if (redirect) {
return redirect;
}
if (redirect) {
return redirect;
}
const usersInOrgContext = await getUsersInOrgContext(
+1 -1
View File
@@ -3,7 +3,7 @@ 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 { getCheckBookingLimitsService } from "@calcom/lib/di/containers/BookingLimits";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
+1 -1
View File
@@ -17,7 +17,7 @@ import {
import { describe, vi, test } from "vitest";
import dayjs from "@calcom/dayjs";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
import { SchedulingType, type BookingStatus } from "@calcom/prisma/enums";
import { expect, expectedSlotsForSchedule } from "./getSchedule/expects";
@@ -9,7 +9,7 @@ import {
import { describe, test } from "vitest";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
import { expect, expectedSlotsForSchedule } from "./expects";
import { setupAndTeardown } from "./setupAndTeardown";
@@ -12,7 +12,7 @@ import { expectNoAttemptToGetAvailability } from "../../utils/bookingScenario/ex
import { describe, test } from "vitest";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
import { MembershipRole } from "@calcom/prisma/enums";
import { expect, expectedSlotsForSchedule } from "./expects";
@@ -8,7 +8,7 @@ import type { ScenarioData } from "../../utils/bookingScenario/bookingScenario";
import { describe, expect, vi, test } from "vitest";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
import { PeriodType } from "@calcom/prisma/enums";
import { expectedSlotsForSchedule } from "./expects";
@@ -10,7 +10,7 @@ import {
import { describe, test, vi } from "vitest";
import type { z } from "zod";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
import type { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
import { expect } from "./expects";
@@ -10,7 +10,7 @@ import type { IncomingMessage } from "http";
import { describe, test, beforeEach, vi } from "vitest";
import type { z } from "zod";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
import { prisma } from "@calcom/prisma";
import type { getScheduleSchema, GetScheduleOptions } from "@calcom/trpc/server/routers/viewer/slots/types";
File diff suppressed because it is too large Load Diff
+39 -6
View File
@@ -125,12 +125,45 @@ Along with the props, Availability settings atom accepts custom styles via the *
| formClassName | Form which contains the days and toggles |
| timezoneSelectClassName | Adds styling to the timezone select component |
| subtitlesClassName | Styles the subtitle |
| scheduleContainer | Styles the entire schedule component |
| scheduleDay | Adds styling to just the day of a particular schedule |
| dayRanges | Adds styling to day ranges |
| timeRanges | Time ranges in the availability settings can be customized |
| labelAndSwitchContainer | Adds styling to label and switches |
| overridesModalClassNames | Adds styling to the date overrides modal |
| scheduleClassNames | An object for granular styling of schedule components (see detailed table below) |
| dateOverrideClassNames | An object for granular styling of date override components (see detailed table below) |
### scheduleClassNames Object Structure
The `scheduleClassNames` prop accepts an object with the following structure for granular styling of schedule components:
| Property Path | Description |
|:--------------|:------------|
| `schedule` | Styles the entire schedule component |
| `scheduleContainer` | Styles the schedule container |
| `scheduleDay` | Adds styling to just the day of a particular schedule |
| `dayRanges` | Adds styling to day ranges |
| `timeRangeField` | Styles the time range input fields |
| `labelAndSwitchContainer` | Adds styling to label and switches |
| `timePicker` | An object for granular styling of time picker dropdown components (see nested table below) |
#### timePicker Object Structure (nested within scheduleClassNames)
The `timePicker` prop accepts an object with the following structure for granular styling of time picker dropdown components. This applies to the time selection dropdowns (e.g., [ 9:00 ]) that users can click to open a dropdown with available times or click to manually enter a time:
| Property Path | Description |
|:--------------|:------------|
| `container` | Styles the main time picker container |
| `valueContainer` | Styles the container that holds the selected value |
| `value` | Styles the displayed selected time value |
| `input` | Styles the input field for manual time entry |
| `dropdown` | Styles the dropdown menu with time options |
### dateOverrideClassNames Object Structure
The `dateOverrideClassNames` prop accepts an object with the following structure for granular styling of date override components:
| Property Path | Description |
|:--------------|:------------|
| `container` | Styles the main container |
| `title` | Styles the title|
| `description` | Styles the description |
| `button` | Styles the button to add date override |
<Info>Please ensure all custom classnames are valid [Tailwind CSS](https://tailwindcss.com/) classnames.</Info>
+1
View File
@@ -127,6 +127,7 @@ Below is a list of props that can be passed to the booker atom.
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
## Styling
+31
View File
@@ -47,6 +47,37 @@ Along with the props, calendar settings atom accepts custom styles via the **cla
| calendarSettingsCustomClassnames | Adds styling to the entire calendar settings atom |
| destinationCalendarSettingsCustomClassnames | Adds styling only to the destination calendar container |
| selectedCalendarSettingsCustomClassnames | Adds styling only to the selected calendar container |
| selectedCalendarSettingsClassNames | An object for granular styling of selected calendars component (see detailed table below) |
| destinationCalendarSettingsClassNames | An object for granular styling of destination calendar component (see detailed table below) |
### selectedCalendarSettingsClassNames Object Structure
The `selectedCalendarSettingsClassNames` prop accepts an object with the following nested structure for granular styling of the selected calendars component:
| Property Path | Description |
|:--------------|:------------|
| `container` | Styles the main container of the selected calendars section |
| `header.container` | Styles the header container |
| `header.title` | Styles the header title |
| `header.description` | Styles the header description |
| `selectedCalendarsListClassNames.container` | Styles the container that holds all selected calendar items |
| `selectedCalendarsListClassNames.selectedCalendar.container` | Styles each individual calendar item container |
| `selectedCalendarsListClassNames.selectedCalendar.header.container` | Styles the header section of each calendar item |
| `selectedCalendarsListClassNames.selectedCalendar.header.title` | Styles the title of each calendar item |
| `selectedCalendarsListClassNames.selectedCalendar.header.description` | Styles the description of each calendar item |
| `selectedCalendarsListClassNames.selectedCalendar.body.container` | Styles the body section of each calendar item |
| `selectedCalendarsListClassNames.selectedCalendar.body.description` | Styles the body description of each calendar item |
| `noSelectedCalendarsMessage` | Styles the message shown when no calendars are connected |
### destinationCalendarSettingsClassNames Object Structure
The `destinationCalendarSettingsClassNames` prop accepts an object with the following nested structure for granular styling of the destination calendar component:
| Property Path | Description |
|:--------------|:------------|
| `container` | Styles the main container of the destination calendar section |
| `header.title` | Styles the header title text |
| `header.description` | Styles the header description text |
<p></p>
Additionally, if you wish to select either the Destination Calendar or the Selected Calendar, we also provide atoms specifically designed for this purpose.
+144 -71
View File
@@ -26,11 +26,14 @@ describe("CalendarEventBuilder", () => {
})
.build();
expect(event.bookerUrl).toBe("https://cal.com/user/test-slug");
expect(event.title).toBe("Test Event");
expect(event.startTime).toBe(mockStartTime);
expect(event.endTime).toBe(mockEndTime);
expect(event.additionalNotes).toBe("Some notes");
expect(event).not.toBeNull();
if (event) {
expect(event.bookerUrl).toBe("https://cal.com/user/test-slug");
expect(event.title).toBe("Test Event");
expect(event.startTime).toBe(mockStartTime);
expect(event.endTime).toBe(mockEndTime);
expect(event.additionalNotes).toBe("Some notes");
}
});
it("should create an event with event type details", () => {
@@ -50,11 +53,14 @@ describe("CalendarEventBuilder", () => {
})
.build();
expect(event.type).toBe("test-slug");
expect(event.description).toBe("Test description");
expect(event.eventTypeId).toBe(123);
expect(event.hideCalendarNotes).toBe(true);
expect(event.hideCalendarEventDetails).toBe(false);
expect(event).not.toBeNull();
if (event) {
expect(event.type).toBe("test-slug");
expect(event.description).toBe("Test description");
expect(event.eventTypeId).toBe(123);
expect(event.hideCalendarNotes).toBe(true);
expect(event.hideCalendarEventDetails).toBe(false);
}
});
it("should create an event with organizer details", () => {
@@ -82,17 +88,20 @@ describe("CalendarEventBuilder", () => {
})
.build();
expect(event.organizer).toEqual({
id: 456,
name: "John Doe",
email: "john@example.com",
username: "johndoe",
timeZone: "America/New_York",
language: {
translate: mockTranslate,
locale: "en",
},
});
expect(event).not.toBeNull();
if (event) {
expect(event.organizer).toEqual({
id: 456,
name: "John Doe",
email: "john@example.com",
username: "johndoe",
timeZone: "America/New_York",
language: {
translate: mockTranslate,
locale: "en",
},
});
}
});
it("should handle nameless organizer", () => {
@@ -119,7 +128,10 @@ describe("CalendarEventBuilder", () => {
})
.build();
expect(event.organizer.name).toBe("Nameless");
expect(event).not.toBeNull();
if (event) {
expect(event.organizer.name).toBe("Nameless");
}
});
it("should create an event with attendees", () => {
@@ -158,7 +170,10 @@ describe("CalendarEventBuilder", () => {
.withAttendees(attendees)
.build();
expect(event.attendees).toEqual(attendees);
expect(event).not.toBeNull();
if (event) {
expect(event.attendees).toEqual(attendees);
}
});
it("should create an event with metadata and responses", () => {
@@ -192,10 +207,13 @@ describe("CalendarEventBuilder", () => {
})
.build();
expect(event.additionalNotes).toBe("Some notes");
expect(event.customInputs).toEqual(customInputs);
expect(event.responses).toEqual(responses);
expect(event.userFieldsResponses).toEqual(userFieldsResponses);
expect(event).not.toBeNull();
if (event) {
expect(event.additionalNotes).toBe("Some notes");
expect(event.customInputs).toEqual(customInputs);
expect(event.responses).toEqual(responses);
expect(event.userFieldsResponses).toEqual(userFieldsResponses);
}
});
it("should create an event with location", () => {
@@ -216,8 +234,11 @@ describe("CalendarEventBuilder", () => {
})
.build();
expect(event.location).toBe("Conference Room A");
expect(event.conferenceCredentialId).toBe(789);
expect(event).not.toBeNull();
if (event) {
expect(event.location).toBe("Conference Room A");
expect(event.conferenceCredentialId).toBe(789);
}
});
it("should create an event with destination calendar", () => {
@@ -229,6 +250,8 @@ describe("CalendarEventBuilder", () => {
userId: null,
eventTypeId: null,
credentialId: null,
createdAt: null,
updatedAt: null,
delegationCredentialId: null,
domainWideDelegationCredentialId: null,
};
@@ -247,7 +270,10 @@ describe("CalendarEventBuilder", () => {
.withDestinationCalendar([destinationCalendar])
.build();
expect(event.destinationCalendar).toEqual([destinationCalendar]);
expect(event).not.toBeNull();
if (event) {
expect(event.destinationCalendar).toEqual([destinationCalendar]);
}
});
it("should create an event with identifiers", () => {
@@ -268,8 +294,11 @@ describe("CalendarEventBuilder", () => {
})
.build();
expect(event.iCalUID).toBe("ical-123");
expect(event.iCalSequence).toBe(2);
expect(event).not.toBeNull();
if (event) {
expect(event.iCalUID).toBe("ical-123");
expect(event.iCalSequence).toBe(2);
}
});
it("should create an event with confirmation settings", () => {
@@ -290,8 +319,11 @@ describe("CalendarEventBuilder", () => {
})
.build();
expect(event.requiresConfirmation).toBe(true);
expect(event.oneTimePassword).toBeUndefined();
expect(event).not.toBeNull();
if (event) {
expect(event.requiresConfirmation).toBe(true);
expect(event.oneTimePassword).toBeUndefined();
}
});
it("should set oneTimePassword to null when isConfirmedByDefault is true", () => {
@@ -312,8 +344,11 @@ describe("CalendarEventBuilder", () => {
})
.build();
expect(event.requiresConfirmation).toBe(true);
expect(event.oneTimePassword).toBeNull();
expect(event).not.toBeNull();
if (event) {
expect(event.requiresConfirmation).toBe(true);
expect(event.oneTimePassword).toBeNull();
}
});
it("should create an event with platform variables", () => {
@@ -336,10 +371,13 @@ describe("CalendarEventBuilder", () => {
})
.build();
expect(event.platformClientId).toBe("client-123");
expect(event.platformRescheduleUrl).toBe("https://platform.com/reschedule");
expect(event.platformCancelUrl).toBe("https://platform.com/cancel");
expect(event.platformBookingUrl).toBe("https://platform.com/booking");
expect(event).not.toBeNull();
if (event) {
expect(event.platformClientId).toBe("client-123");
expect(event.platformRescheduleUrl).toBe("https://platform.com/reschedule");
expect(event.platformCancelUrl).toBe("https://platform.com/cancel");
expect(event.platformBookingUrl).toBe("https://platform.com/booking");
}
});
it("should create an event with apps status", () => {
@@ -376,7 +414,10 @@ describe("CalendarEventBuilder", () => {
.withAppsStatus(appsStatus)
.build();
expect(event.appsStatus).toEqual(appsStatus);
expect(event).not.toBeNull();
if (event) {
expect(event.appsStatus).toEqual(appsStatus);
}
});
it("should create an event with video call data", () => {
@@ -401,7 +442,10 @@ describe("CalendarEventBuilder", () => {
.withVideoCallData(videoCallData)
.build();
expect(event.videoCallData).toEqual(videoCallData);
expect(event).not.toBeNull();
if (event) {
expect(event.videoCallData).toEqual(videoCallData);
}
});
it("should create an event with team information", () => {
@@ -435,7 +479,10 @@ describe("CalendarEventBuilder", () => {
.withTeam(team)
.build();
expect(event.team).toEqual(team);
expect(event).not.toBeNull();
if (event) {
expect(event.team).toEqual(team);
}
});
it("should create an event with recurring event information", () => {
@@ -459,7 +506,10 @@ describe("CalendarEventBuilder", () => {
.withRecurring(recurringEvent)
.build();
expect(event.recurringEvent).toEqual(recurringEvent);
expect(event).not.toBeNull();
if (event) {
expect(event.recurringEvent).toEqual(recurringEvent);
}
});
it("should create an event with attendee seat ID", () => {
@@ -477,7 +527,10 @@ describe("CalendarEventBuilder", () => {
.withAttendeeSeatId("seat-123")
.build();
expect(event.attendeeSeatId).toBe("seat-123");
expect(event).not.toBeNull();
if (event) {
expect(event.attendeeSeatId).toBe("seat-123");
}
});
it("should create an event with UID", () => {
@@ -495,7 +548,10 @@ describe("CalendarEventBuilder", () => {
.withUid("booking-uid-123")
.build();
expect(event.uid).toBe("booking-uid-123");
expect(event).not.toBeNull();
if (event) {
expect(event.uid).toBe("booking-uid-123");
}
});
it("should create an event with one-time password", () => {
@@ -513,7 +569,10 @@ describe("CalendarEventBuilder", () => {
.withOneTimePassword("otp123")
.build();
expect(event.oneTimePassword).toBe("otp123");
expect(event).not.toBeNull();
if (event) {
expect(event.oneTimePassword).toBe("otp123");
}
});
it("should create an event with recurring event ID", () => {
@@ -531,9 +590,12 @@ describe("CalendarEventBuilder", () => {
.withRecurringEventId("recurring-123")
.build();
expect(event.existingRecurringEvent).toEqual({
recurringEventId: "recurring-123",
});
expect(event).not.toBeNull();
if (event) {
expect(event.existingRecurringEvent).toEqual({
recurringEventId: "recurring-123",
});
}
});
it("should create a complete calendar event with all properties", () => {
@@ -597,6 +659,8 @@ describe("CalendarEventBuilder", () => {
userId: 1,
eventTypeId: 123,
credentialId: 1,
createdAt: null,
updatedAt: null,
delegationCredentialId: null,
domainWideDelegationCredentialId: null,
},
@@ -644,26 +708,29 @@ describe("CalendarEventBuilder", () => {
.build();
// Test that all properties are set correctly
expect(event.title).toBe("Complete Test Event");
expect(event.type).toBe("complete-test");
expect(event.organizer.name).toBe("John Doe");
expect(event.attendees).toHaveLength(1);
expect(event.location).toBe("Conference Room A");
expect(event.iCalUID).toBe("ical-123");
expect(event.requiresConfirmation).toBe(true);
expect(event.platformClientId).toBe("client-123");
expect(event.appsStatus).toHaveLength(1);
expect(event.videoCallData?.url).toBe("https://meet.example.com/123");
expect(event.team?.name).toBe("Engineering Team");
expect(event.recurringEvent?.count).toBe(5);
expect(event.attendeeSeatId).toBe("seat-123");
expect(event.uid).toBe("booking-uid-123");
expect(event.oneTimePassword).toBe("otp123");
expect(event).not.toBeNull();
if (event) {
expect(event.title).toBe("Complete Test Event");
expect(event.type).toBe("complete-test");
expect(event.organizer.name).toBe("John Doe");
expect(event.attendees).toHaveLength(1);
expect(event.location).toBe("Conference Room A");
expect(event.iCalUID).toBe("ical-123");
expect(event.requiresConfirmation).toBe(true);
expect(event.platformClientId).toBe("client-123");
expect(event.appsStatus).toHaveLength(1);
expect(event.videoCallData?.url).toBe("https://meet.example.com/123");
expect(event.team?.name).toBe("Engineering Team");
expect(event.recurringEvent?.count).toBe(5);
expect(event.attendeeSeatId).toBe("seat-123");
expect(event.uid).toBe("booking-uid-123");
expect(event.oneTimePassword).toBe("otp123");
}
});
it("should throw an error when building without required fields", () => {
it("should return null when building without required fields", () => {
const builder = new CalendarEventBuilder();
expect(() => builder.build()).toThrow("Missing required fields for calendar event");
expect(builder.build()).toBeNull();
});
it("should create an event from an existing event", () => {
@@ -684,8 +751,11 @@ describe("CalendarEventBuilder", () => {
})
.build();
expect(event.title).toBe("Updated Event");
expect(event.type).toBe("existing-type");
expect(event).not.toBeNull();
if (event) {
expect(event.title).toBe("Updated Event");
expect(event.type).toBe("existing-type");
}
});
it("should propagate disableCancelling and disableRescheduling", () => {
@@ -706,7 +776,10 @@ describe("CalendarEventBuilder", () => {
})
.build();
expect(event.disableCancelling).toBe(true);
expect(event.disableRescheduling).toBe(true);
expect(event).not.toBeNull();
if (event) {
expect(event.disableCancelling).toBe(true);
expect(event.disableRescheduling).toBe(true);
}
});
});
+2 -2
View File
@@ -266,7 +266,7 @@ export class CalendarEventBuilder {
return this;
}
build(): CalendarEvent {
build(): CalendarEvent | null {
// Validate required fields
if (
!this.event.startTime ||
@@ -275,7 +275,7 @@ export class CalendarEventBuilder {
!this.event.bookerUrl ||
!this.event.title
) {
throw new Error("Missing required fields for calendar event");
return null;
}
return this.event as CalendarEvent;
@@ -81,6 +81,7 @@ const BookerComponent = ({
timeZones,
eventMetaChildren,
roundRobinHideOrgAndTeam,
showNoAvailabilityDialog,
}: BookerProps & WrappedBookerProps) => {
const searchParams = useCompatSearchParams();
const isPlatformBookerEmbed = useIsPlatformBookerEmbed();
@@ -417,6 +418,7 @@ const BookerComponent = ({
slots={schedule?.data?.slots}
isLoading={schedule.isPending}
scrollToTimeSlots={scrollToTimeSlots}
showNoAvailabilityDialog={showNoAvailabilityDialog}
/>
</div>
)}
@@ -445,6 +447,7 @@ const BookerComponent = ({
slots={schedule?.data?.slots}
isLoading={schedule.isPending}
scrollToTimeSlots={scrollToTimeSlots}
showNoAvailabilityDialog={showNoAvailabilityDialog}
/>
</BookerSection>
@@ -57,6 +57,7 @@ export const DatePicker = ({
isLoading,
classNames,
scrollToTimeSlots,
showNoAvailabilityDialog,
}: {
event: {
data?: {
@@ -72,6 +73,7 @@ export const DatePicker = ({
isLoading?: boolean;
classNames?: DatePickerClassNames;
scrollToTimeSlots?: () => void;
showNoAvailabilityDialog?: boolean;
}) => {
const { i18n } = useLocale();
const [month, selectedDate, layout] = useBookerStore(
@@ -148,6 +150,7 @@ export const DatePicker = ({
scrollToTimeSlots={scrollToTimeSlots}
periodData={periodData}
isCompact={isCompact}
showNoAvailabilityDialog={showNoAvailabilityDialog}
/>
);
};
@@ -98,6 +98,7 @@ export interface BookerProps {
hashedLink?: string | null;
isInstantMeeting?: boolean;
teamMemberEmail?: string | null;
showNoAvailabilityDialog?: boolean;
crmOwnerRecordType?: string | null;
crmAppSlug?: string | null;
crmRecordId?: string | null;
@@ -5,6 +5,7 @@ import { SystemField } from "@calcom/features/bookings/lib/SystemField";
import type { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { contructEmailFromPhoneNumber } from "@calcom/lib/contructEmailFromPhoneNumber";
import { getBookingWithResponses } from "@calcom/lib/getBooking";
import { HttpError } from "@calcom/lib/http-error";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -42,7 +43,10 @@ export const getCalEventResponses = ({
// To set placeholder email for the booking
if (!!!backwardCompatibleResponses.email) {
if (typeof backwardCompatibleResponses["attendeePhoneNumber"] !== "string")
throw new Error("Both Phone and Email are missing");
throw new HttpError({
statusCode: 400,
message: "Both Phone and Email are missing",
});
backwardCompatibleResponses.email = contructEmailFromPhoneNumber(
backwardCompatibleResponses["attendeePhoneNumber"]
@@ -52,8 +52,8 @@ import {
enrichHostsWithDelegationCredentials,
getFirstDelegationConferencingCredentialAppLocation,
} from "@calcom/lib/delegationCredential/server";
import { getCheckBookingAndDurationLimitsService } from "@calcom/lib/di/containers/booking-limits";
import { getCacheService } from "@calcom/lib/di/containers/cache";
import { getCheckBookingAndDurationLimitsService } from "@calcom/lib/di/containers/BookingLimits";
import { getCacheService } from "@calcom/lib/di/containers/Cache";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { getEventName, updateHostInEventName } from "@calcom/lib/event";
@@ -83,7 +83,13 @@ import {
} from "@calcom/prisma/zod-utils";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util";
import type { AdditionalInformation, AppsStatus, CalendarEvent, Person } from "@calcom/types/Calendar";
import type {
AdditionalInformation,
AppsStatus,
CalendarEvent,
CalEventResponses,
Person,
} from "@calcom/types/Calendar";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
@@ -314,17 +320,35 @@ export const buildEventForTeamEventType = async ({
const teamMembers = await Promise.all(teamMemberPromises);
evt = CalendarEventBuilder.fromEvent(evt)
.withDestinationCalendar([...(evt.destinationCalendar ?? []), ...teamDestinationCalendars])
const updatedEvt = CalendarEventBuilder.fromEvent(evt)
?.withDestinationCalendar([...(evt.destinationCalendar ?? []), ...teamDestinationCalendars])
.build();
return CalendarEventBuilder.fromEvent(evt)
.withTeam({
if (!updatedEvt) {
throw new HttpError({
statusCode: 400,
message: "Failed to build event with destination calendar due to missing required fields",
});
}
evt = updatedEvt;
const teamEvt = CalendarEventBuilder.fromEvent(evt)
?.withTeam({
members: teamMembers,
name: team?.name || "Nameless",
id: team?.id ?? 0,
})
.build();
if (!teamEvt) {
throw new HttpError({
statusCode: 400,
message: "Failed to build team event due to missing required fields",
});
}
return teamEvt;
};
function buildTroubleshooterData({
@@ -1162,7 +1186,7 @@ async function handler(
const eventName = getEventName(eventNameObject);
let evt: CalendarEvent = new CalendarEventBuilder()
const builtEvt = new CalendarEventBuilder()
.withBasicDetails({
bookerUrl,
title: eventName,
@@ -1220,20 +1244,44 @@ async function handler(
})
.build();
if (!builtEvt) {
throw new HttpError({
statusCode: 400,
message: "Failed to build calendar event due to missing required fields",
});
}
let evt: CalendarEvent = builtEvt;
if (input.bookingData.thirdPartyRecurringEventId) {
evt = CalendarEventBuilder.fromEvent(evt)
.withRecurringEventId(input.bookingData.thirdPartyRecurringEventId)
const updatedEvt = CalendarEventBuilder.fromEvent(evt)
?.withRecurringEventId(input.bookingData.thirdPartyRecurringEventId)
.build();
if (!updatedEvt) {
throw new HttpError({
statusCode: 400,
message: "Failed to build event with recurring event ID due to missing required fields",
});
}
evt = updatedEvt;
}
if (isTeamEventType) {
evt = await buildEventForTeamEventType({
const teamEvt = await buildEventForTeamEventType({
existingEvent: evt,
schedulingType: eventType.schedulingType,
users,
team: eventType.team,
organizerUser,
});
if (!teamEvt) {
throw new HttpError({ statusCode: 400, message: "Failed to build team event" });
}
evt = teamEvt;
}
// data needed for triggering webhooks
@@ -1351,13 +1399,22 @@ async function handler(
// Rescheduling logic for the original seated event was handled in handleSeats
// We want to use new booking logic for the new time slot
originalRescheduledBooking = null;
evt = CalendarEventBuilder.fromEvent(evt)
.withIdentifiers({
const updatedEvt = CalendarEventBuilder.fromEvent(evt)
?.withIdentifiers({
iCalUID: getICalUID({
attendeeId: bookingSeat?.attendeeId,
}),
})
.build();
if (!updatedEvt) {
throw new HttpError({
statusCode: 400,
message: "Failed to build event with new identifiers due to missing required fields",
});
}
evt = updatedEvt;
}
}
@@ -1478,14 +1535,32 @@ async function handler(
}
}
evt = CalendarEventBuilder.fromEvent(evt)
.withUid(booking.uid ?? null)
const updatedEvtWithUid = CalendarEventBuilder.fromEvent(evt)
?.withUid(booking.uid ?? null)
.build();
evt = CalendarEventBuilder.fromEvent(evt)
.withOneTimePassword(booking.oneTimePassword ?? null)
if (!updatedEvtWithUid) {
throw new HttpError({
statusCode: 400,
message: "Failed to build event with UID due to missing required fields",
});
}
evt = updatedEvtWithUid;
const updatedEvtWithPassword = CalendarEventBuilder.fromEvent(evt)
?.withOneTimePassword(booking.oneTimePassword ?? null)
.build();
if (!updatedEvtWithPassword) {
throw new HttpError({
statusCode: 400,
message: "Failed to build event with one-time password due to missing required fields",
});
}
evt = updatedEvtWithPassword;
if (booking && booking.id && eventType.seatsPerTimeSlot) {
const currentAttendee = booking.attendees.find(
(attendee) =>
@@ -1580,6 +1655,47 @@ async function handler(
evt.iCalUID = undefined;
}
if (changedOrganizer && originalRescheduledBooking?.user) {
const originalHostCredentials = await getAllCredentialsIncludeServiceAccountKey(
originalRescheduledBooking.user,
eventType
);
const refreshedOriginalHostCredentials = await refreshCredentials(originalHostCredentials);
// Create EventManager with original host's credentials for deletion operations
const originalHostEventManager = new EventManager(
{ ...originalRescheduledBooking.user, credentials: refreshedOriginalHostCredentials },
apps
);
log.debug("RescheduleOrganizerChanged: Deleting Event and Meeting for previous booking");
// Create deletion event with original host's organizer info and original booking properties
const deletionEvent = {
...evt,
organizer: {
id: originalRescheduledBooking.user.id,
name: originalRescheduledBooking.user.name || "",
email: originalRescheduledBooking.user.email,
username: originalRescheduledBooking.user.username || undefined,
timeZone: originalRescheduledBooking.user.timeZone,
language: { translate: tOrganizer, locale: originalRescheduledBooking.user.locale ?? "en" },
timeFormat: getTimeFormatStringFromUserTimeFormat(originalRescheduledBooking.user.timeFormat),
},
destinationCalendar: previousHostDestinationCalendar,
// Override with original booking properties used by deletion operations
startTime: originalRescheduledBooking.startTime.toISOString(),
endTime: originalRescheduledBooking.endTime.toISOString(),
uid: originalRescheduledBooking.uid,
location: originalRescheduledBooking.location,
responses: originalRescheduledBooking.responses
? (originalRescheduledBooking.responses as CalEventResponses)
: evt.responses,
};
await originalHostEventManager.deleteEventsAndMeetings({
event: deletionEvent,
bookingReferences: originalRescheduledBooking.references,
});
}
const updateManager = await eventManager.reschedule(
evt,
originalRescheduledBooking.uid,
@@ -1737,6 +1853,8 @@ async function handler(
originalBookingMemberEmails.push({
...originalRescheduledBooking.user,
username: originalRescheduledBooking.user.username ?? undefined,
timeFormat: getTimeFormatStringFromUserTimeFormat(originalRescheduledBooking.user.timeFormat),
name: originalRescheduledBooking.user.name || "",
language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" },
});
@@ -1941,7 +2059,7 @@ async function handler(
})
);
if (!isDryRun) {
if (!isDryRun && !(eventType.seatsPerTimeSlot && rescheduleUid)) {
await sendScheduledEmailsAndSMS(
{
...evt,
@@ -4,8 +4,8 @@ import dayjs from "@calcom/dayjs";
import type { Dayjs } from "@calcom/dayjs";
import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts";
import { buildDateRanges } from "@calcom/lib/date-ranges";
import { getBusyTimesService } from "@calcom/lib/di/containers/busy-times";
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
import { getBusyTimesService } from "@calcom/lib/di/containers/BusyTimes";
import { getUserAvailabilityService } from "@calcom/lib/di/containers/GetUserAvailability";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
@@ -2,8 +2,8 @@ import type { Prisma } from "@prisma/client";
import type { Logger } from "tslog";
import { checkIfUsersAreBlocked } from "@calcom/features/watchlist/operations/check-if-users-are-blocked.controller";
import { findQualifiedHostsWithDelegationCredentials } from "@calcom/lib/bookings/findQualifiedHostsWithDelegationCredentials";
import { enrichUsersWithDelegationCredentials } from "@calcom/lib/delegationCredential/server";
import { getQualifiedHostsService } from "@calcom/lib/di/containers/QualifiedHosts";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { HttpError } from "@calcom/lib/http-error";
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
@@ -142,8 +142,9 @@ const _loadAndValidateUsers = async ({
? false
: user.isFixed || eventType.schedulingType !== SchedulingType.ROUND_ROBIN,
}));
const qualifiedHostsService = getQualifiedHostsService();
const { qualifiedRRHosts, allFallbackRRHosts, fixedHosts } =
await findQualifiedHostsWithDelegationCredentials({
await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: routedTeamMemberIds || [],
rescheduleUid,
@@ -159,7 +160,7 @@ const _loadAndValidateUsers = async ({
},
{} as {
[key: number]: Awaited<
ReturnType<typeof findQualifiedHostsWithDelegationCredentials>
ReturnType<ReturnType<typeof getQualifiedHostsService>["findQualifiedHostsWithDelegationCredentials"]>
>["qualifiedRRHosts"][number];
}
);
@@ -2827,5 +2827,133 @@ describe("handleNewBooking", () => {
timeout
);
});
test(
"should use correct credentials when round robin reschedule changes host - original host credentials for deletion, new host for creation",
async ({ emails }) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const originalHost = getOrganizer({
name: "Original Host",
email: "originalhost@example.com",
id: 101,
schedules: [TestData.schedules.IstMorningShift],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const newHost = getOrganizer({
name: "New Host",
email: "newhost@example.com",
id: 102,
schedules: [TestData.schedules.IstEveningShift],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const uidOfBookingToBeRescheduled = "credential-test-booking-uid";
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 15,
length: 15,
users: [{ id: 101 }, { id: 102 }],
schedulingType: SchedulingType.ROUND_ROBIN,
},
],
bookings: [
{
uid: uidOfBookingToBeRescheduled,
eventTypeId: 1,
userId: 101,
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
credentialId: null,
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
credentialId: undefined,
},
],
},
],
organizer: originalHost,
usersApartFromOrganizer: [newHost],
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
const videoMock = mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
});
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: { uid: "NEW_EVENT_ID" },
update: { uid: "UPDATED_EVENT_ID" },
});
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
rescheduleUid: uidOfBookingToBeRescheduled,
start: `${plus1DateString}T14:00:00.000Z`,
end: `${plus1DateString}T14:15:00.000Z`,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
expectSuccessfulCalendarEventDeletionInCalendar(calendarMock, {
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
calEvent: {
organizer: expect.objectContaining({
email: originalHost.email,
}),
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
uid: uidOfBookingToBeRescheduled,
},
uid: "MOCK_ID",
});
// Verify that creation occurred with new host credentials
expect(calendarMock.createEventCalls.length).toBe(1);
const createCall = calendarMock.createEventCalls[0];
expect(createCall.args.calEvent.organizer.email).toBe(newHost.email);
expect(createdBooking.userId).toBe(newHost.id);
expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T14:00:00.000Z`);
expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T14:15:00.000Z`);
},
timeout
);
});
});
@@ -51,6 +51,8 @@ const attendeeRescheduleSeatedBooking = async (
// We don't want to trigger rescheduling logic of the original booking
originalRescheduledBooking = null;
await sendRescheduledSeatEmailAndSMS(evt, seatAttendee as Person, eventType.metadata);
return null;
}
+17 -8
View File
@@ -59,6 +59,8 @@ export type DatePickerProps = {
periodData?: PeriodData;
// Whether this is a compact sidebar view or main monthly view
isCompact?: boolean;
// Whether to show the no availability dialog
showNoAvailabilityDialog?: boolean;
};
const Day = ({
@@ -164,6 +166,7 @@ const Days = ({
isBookingInPast,
periodData,
isCompact,
showNoAvailabilityDialog = true,
...props
}: Omit<DatePickerProps, "locale" | "className" | "weekStart"> & {
DayComponent?: React.FC<React.ComponentProps<typeof Day>>;
@@ -345,14 +348,18 @@ const Days = ({
)}
</div>
))}
{!props.isLoading && !isBookingInPast && includedDates && includedDates?.length === 0 && (
<NoAvailabilityDialog
month={month}
nextMonthButton={nextMonthButton}
browsingDate={browsingDate}
periodData={periodData}
/>
)}
{!props.isLoading &&
!isBookingInPast &&
includedDates &&
includedDates?.length === 0 &&
showNoAvailabilityDialog && (
<NoAvailabilityDialog
month={month}
nextMonthButton={nextMonthButton}
browsingDate={browsingDate}
periodData={periodData}
/>
)}
</>
);
};
@@ -374,6 +381,7 @@ const DatePicker = ({
periodType: "UNLIMITED",
},
isCompact,
showNoAvailabilityDialog,
...passThroughProps
}: DatePickerProps &
Partial<React.ComponentProps<typeof Days>> & {
@@ -484,6 +492,7 @@ const DatePicker = ({
isBookingInPast={isBookingInPast}
periodData={periodData}
isCompact={isCompact}
showNoAvailabilityDialog={showNoAvailabilityDialog}
/>
</div>
</div>
@@ -43,7 +43,7 @@ export const TeamEventTypeForm = ({
return (
<Form form={form} handleSubmit={handleSubmit}>
<div className="mt-3 space-y-6 pb-11">
<div className="mt-1 space-y-6">
<TextField
type="hidden"
labelProps={{ style: { display: "none" } }}
@@ -1,4 +1,4 @@
import { useState, Suspense } from "react";
import { useState, Suspense, useMemo } from "react";
import type { Dispatch, SetStateAction } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { z } from "zod";
@@ -10,9 +10,10 @@ import {
SelectedCalendarSettingsScope,
SelectedCalendarsSettingsWebWrapperSkeleton,
} from "@calcom/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper";
import { Timezone as PlatformTimzoneSelect } from "@calcom/atoms/timezone";
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
import { TimezoneSelect } from "@calcom/features/components/timezone-select";
import { TimezoneSelect as WebTimezoneSelect } from "@calcom/features/components/timezone-select";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import {
allowDisablingAttendeeConfirmationEmails,
@@ -565,6 +566,10 @@ export const EventAdvancedTab = ({
userEmail = removePlatformClientIdFromEmail(userEmail, platformContext.clientId);
}
const TimezoneSelect = useMemo(() => {
return isPlatform ? PlatformTimzoneSelect : WebTimezoneSelect;
}, [isPlatform]);
return (
<div className="flex flex-col space-y-4">
<calendarComponents.CalendarSettings
@@ -9,8 +9,8 @@ import {
routingRepositoryBaseInputSchema,
bookingRepositoryBaseInputSchema,
} from "@calcom/features/insights/server/raw-data.schema";
import { getInsightsBookingService } from "@calcom/lib/di/containers/insights-booking";
import { getInsightsRoutingService } from "@calcom/lib/di/containers/insights-routing";
import { getInsightsBookingService } from "@calcom/lib/di/containers/InsightsBooking";
import { getInsightsRoutingService } from "@calcom/lib/di/containers/InsightsRouting";
import type { readonlyPrisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
@@ -36,6 +36,14 @@ export type ScheduleLabelsType = {
deleteTime: string;
};
export type SelectInnerClassNames = {
control?: string;
singleValue?: string;
valueContainer?: string;
input?: string;
menu?: string;
};
export type FieldPathByValue<TFieldValues extends FieldValues, TValue> = {
[Key in FieldPath<TFieldValues>]: FieldPathValue<TFieldValues, Key> extends TValue ? Key : never;
}[FieldPath<TFieldValues>];
@@ -105,6 +113,7 @@ export const ScheduleDay = <TFieldValues extends FieldValues>({
classNames={{
dayRanges: classNames?.dayRanges,
timeRangeField: classNames?.timeRangeField,
timePicker: classNames?.timePicker,
}}
/>
{!disabled && <div className="block">{CopyButton}</div>}
@@ -237,7 +246,7 @@ export const DayRanges = <TFieldValues extends FieldValues>({
disabled?: boolean;
labels?: ScheduleLabelsType;
userTimeFormat: number | null;
classNames?: Pick<scheduleClassNames, "dayRanges" | "timeRangeField">;
classNames?: Pick<scheduleClassNames, "dayRanges" | "timeRangeField" | "timePicker">;
}) => {
const { t } = useLocale();
const { getValues } = useFormContext();
@@ -260,6 +269,7 @@ export const DayRanges = <TFieldValues extends FieldValues>({
<TimeRangeField
className={classNames?.timeRangeField}
userTimeFormat={userTimeFormat}
timePickerClassNames={classNames?.timePicker}
{...field}
/>
)}
@@ -335,11 +345,27 @@ const TimeRangeField = ({
onChange,
disabled,
userTimeFormat,
timePickerClassNames,
}: {
className?: string;
disabled?: boolean;
userTimeFormat: number | null;
timePickerClassNames?: {
container?: string;
value?: string;
valueContainer?: string;
input?: string;
dropdown?: string;
};
} & ControllerRenderProps) => {
const innerClassNames: SelectInnerClassNames = {
control: timePickerClassNames?.container,
singleValue: timePickerClassNames?.value,
valueContainer: timePickerClassNames?.valueContainer,
input: timePickerClassNames?.input,
menu: timePickerClassNames?.dropdown,
};
// this is a controlled component anyway given it uses LazySelect, so keep it RHF agnostic.
return (
<div className={cn("flex flex-row gap-2 sm:gap-3", className)}>
@@ -349,6 +375,7 @@ const TimeRangeField = ({
isDisabled={disabled}
value={value.start}
menuPlacement="bottom"
innerClassNames={innerClassNames}
onChange={(option) => {
const newStart = new Date(option?.value as number);
if (newStart >= new Date(value.end)) {
@@ -367,6 +394,7 @@ const TimeRangeField = ({
isDisabled={disabled}
value={value.end}
min={value.start}
innerClassNames={innerClassNames}
menuPlacement="bottom"
onChange={(option) => {
onChange({ ...value, end: new Date(option?.value as number) });
@@ -388,6 +416,7 @@ const LazySelect = ({
min?: ConfigType;
max?: ConfigType;
userTimeFormat: number | null;
innerClassNames?: SelectInnerClassNames;
}) => {
// Lazy-loaded options, otherwise adding a field has a noticeable redraw delay.
const { options, filter } = useOptions(userTimeFormat);
+5 -10
View File
@@ -327,7 +327,9 @@ export default class EventManager {
}
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
const isMSTeamsWithOutlookCalendar = evt.location === MSTeamsLocationType && mainHostDestinationCalendar?.integration === "office365_calendar";
const isMSTeamsWithOutlookCalendar =
evt.location === MSTeamsLocationType &&
mainHostDestinationCalendar?.integration === "office365_calendar";
const results: Array<EventResult<Exclude<Event, AdditionalInformation>>> = [];
@@ -635,7 +637,7 @@ export default class EventManager {
const shouldUpdateBookingReferences =
!!changedOrganizer || isLocationChanged || !!isBookingRequestedReschedule || isDailyVideoRoomExpired;
if (evt.requiresConfirmation) {
if (evt.requiresConfirmation && !changedOrganizer) {
log.debug("RescheduleRequiresConfirmation: Deleting Event and Meeting for previous booking");
// As the reschedule requires confirmation, we can't update the events and meetings to new time yet. So, just delete them and let it be handled when organizer confirms the booking.
await this.deleteEventsAndMeetings({
@@ -644,14 +646,7 @@ export default class EventManager {
});
} else {
if (changedOrganizer) {
log.debug("RescheduleOrganizerChanged: Deleting Event and Meeting for previous booking");
await this.deleteEventsAndMeetings({
event: { ...event, destinationCalendar: previousHostDestinationCalendar },
bookingReferences: booking.references,
});
log.debug("RescheduleOrganizerChanged: Creating Event and Meeting for for new booking");
const createdEvent = await this.create(originalEvt);
results.push(...createdEvent.results);
updatedBookingReferences.push(...createdEvent.referencesToCreate);
@@ -728,7 +723,7 @@ export default class EventManager {
});
}
private async deleteEventsAndMeetings({
public async deleteEventsAndMeetings({
event,
bookingReferences,
isBookingInRecurringSeries,
@@ -1,31 +1,29 @@
import type { Mock } from "vitest";
import { describe, expect, it, vi, afterEach } from "vitest";
import prisma from "@calcom/prisma";
import type { BookingRepository } from "@calcom/lib/server/repository/booking";
import { filterHostsBySameRoundRobinHost } from "./filterHostsBySameRoundRobinHost";
import { FilterHostsService } from "./filterHostsBySameRoundRobinHost";
// Mocking setup
const prismaMock = {
booking: {
findFirst: vi.fn(), // Mock the findFirst method
},
};
const mockBookingRepo = {
findOriginalRescheduledBookingUserId: vi.fn(),
} as unknown as BookingRepository;
// Use `vi.spyOn` to make `prisma.booking.groupBy` call the mock instead
vi.spyOn(prisma.booking, "findFirst").mockImplementation(prismaMock.booking.findFirst);
afterEach(() => {
// Clear call history before each test to avoid cross-test interference
prismaMock.booking.findFirst.mockClear();
const filterHostsService = new FilterHostsService({
bookingRepo: mockBookingRepo,
});
describe("filterHostsBySameRoundRobinHost", () => {
afterEach(() => {
(mockBookingRepo.findOriginalRescheduledBookingUserId as Mock).mockClear();
});
describe("FilterHostsService", () => {
it("skips filter if rescheduleWithSameRoundRobinHost set to false", async () => {
const hosts = [
{ isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
];
expect(
filterHostsBySameRoundRobinHost({
filterHostsService.filterHostsBySameRoundRobinHost({
hosts,
rescheduleUid: "some-uid",
rescheduleWithSameRoundRobinHost: false,
@@ -35,10 +33,10 @@ describe("filterHostsBySameRoundRobinHost", () => {
});
it("skips filter if rerouting", async () => {
const hosts = [
{ isFixed: true as const, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
{ isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
];
expect(
filterHostsBySameRoundRobinHost({
filterHostsService.filterHostsBySameRoundRobinHost({
hosts,
rescheduleUid: "some-uid",
rescheduleWithSameRoundRobinHost: true,
@@ -48,15 +46,14 @@ describe("filterHostsBySameRoundRobinHost", () => {
});
it("correctly selects the same host if the filter applies and the host is in the RR users", async () => {
prismaMock.booking.findFirst.mockResolvedValue({ userId: 1 });
(mockBookingRepo.findOriginalRescheduledBookingUserId as Mock).mockResolvedValue({ userId: 1 });
const hosts = [
{ isFixed: false as const, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
{ isFixed: false as const, createdAt: new Date(), user: { id: 2, email: "example2@acme.com" } },
];
// Same host should be selected
expect(
filterHostsBySameRoundRobinHost({
filterHostsService.filterHostsBySameRoundRobinHost({
hosts,
rescheduleUid: "some-uid",
rescheduleWithSameRoundRobinHost: true,
@@ -68,7 +65,7 @@ describe("filterHostsBySameRoundRobinHost", () => {
// Tests for bookings that have more than one host
describe("Fixed hosts and round robin groups support", () => {
it("should return organizer and attendee hosts", async () => {
prismaMock.booking.findFirst.mockResolvedValue({
(mockBookingRepo.findOriginalRescheduledBookingUserId as Mock).mockResolvedValue({
userId: 1,
attendees: [
{ email: "host2@acme.com" },
@@ -84,7 +81,7 @@ describe("filterHostsBySameRoundRobinHost", () => {
{ isFixed: false as const, createdAt: new Date(), user: { id: 4, email: "host4@acme.com" } },
];
const result = await filterHostsBySameRoundRobinHost({
const result = await filterHostsService.filterHostsBySameRoundRobinHost({
hosts,
rescheduleUid: "some-uid",
rescheduleWithSameRoundRobinHost: true,
@@ -101,7 +98,7 @@ describe("filterHostsBySameRoundRobinHost", () => {
});
it("should return only organizer host when no attendees match current hosts", async () => {
prismaMock.booking.findFirst.mockResolvedValue({
(mockBookingRepo.findOriginalRescheduledBookingUserId as Mock).mockResolvedValue({
userId: 1,
attendees: [
{ email: "attendee1@example.com" }, // Non-host attendee
@@ -114,7 +111,7 @@ describe("filterHostsBySameRoundRobinHost", () => {
{ isFixed: false as const, createdAt: new Date(), user: { id: 2, email: "host2@acme.com" } },
];
const result = await filterHostsBySameRoundRobinHost({
const result = await filterHostsService.filterHostsBySameRoundRobinHost({
hosts,
rescheduleUid: "some-uid",
rescheduleWithSameRoundRobinHost: true,
@@ -1,57 +1,53 @@
import { prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { BookingRepository } from "@calcom/lib/server/repository/booking";
import { isRerouting } from "./routing/utils";
export const filterHostsBySameRoundRobinHost = async <
T extends {
isFixed: false; // ensure no fixed hosts are passed.
user: { id: number; email: string };
}
>({
hosts,
rescheduleUid,
rescheduleWithSameRoundRobinHost,
routedTeamMemberIds,
}: {
hosts: T[];
rescheduleUid: string | null;
rescheduleWithSameRoundRobinHost: boolean;
routedTeamMemberIds: number[] | null;
}) => {
if (
!rescheduleUid ||
!rescheduleWithSameRoundRobinHost ||
isRerouting({ rescheduleUid, routedTeamMemberIds })
) {
return hosts;
}
const originalRescheduledBooking = await prisma.booking.findFirst({
where: {
uid: rescheduleUid,
status: {
in: [BookingStatus.ACCEPTED, BookingStatus.CANCELLED, BookingStatus.PENDING],
},
},
select: {
userId: true,
attendees: {
select: {
email: true,
},
},
},
});
export interface IFilterHostsService {
bookingRepo: BookingRepository;
}
if (!originalRescheduledBooking) {
return hosts;
export class FilterHostsService {
constructor(public readonly dependencies: IFilterHostsService) {}
async filterHostsBySameRoundRobinHost<
T extends {
isFixed: false; // ensure no fixed hosts are passed.
user: { id: number; email: string };
}
>({
hosts,
rescheduleUid,
rescheduleWithSameRoundRobinHost,
routedTeamMemberIds,
}: {
hosts: T[];
rescheduleUid: string | null;
rescheduleWithSameRoundRobinHost: boolean;
routedTeamMemberIds: number[] | null;
}) {
if (
!rescheduleUid ||
!rescheduleWithSameRoundRobinHost ||
isRerouting({ rescheduleUid, routedTeamMemberIds })
) {
return hosts;
}
const originalRescheduledBooking =
await this.dependencies.bookingRepo.findOriginalRescheduledBookingUserId({
rescheduleUid,
});
if (!originalRescheduledBooking) {
return hosts;
}
const attendeeEmails = originalRescheduledBooking.attendees?.map((attendee) => attendee.email) || [];
return hosts.filter((host) => {
const isOrganizer = host.user.id === originalRescheduledBooking.userId;
const isAttendee = attendeeEmails.includes(host.user.email);
return isOrganizer || isAttendee;
});
}
const attendeeEmails = originalRescheduledBooking.attendees?.map((attendee) => attendee.email) || [];
return hosts.filter((host) => {
const isOrganizer = host.user.id === originalRescheduledBooking.userId;
const isAttendee = attendeeEmails.includes(host.user.email);
return isOrganizer || isAttendee;
});
};
}
@@ -5,8 +5,8 @@ import type { Mock } from "vitest";
import { RRResetInterval, SchedulingType } from "@calcom/prisma/enums";
import { getQualifiedHostsService } from "../di/containers/QualifiedHosts";
import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold";
import { findQualifiedHostsWithDelegationCredentials } from "./findQualifiedHostsWithDelegationCredentials";
import * as getRoutedUsers from "./getRoutedUsers";
// Mock the filterHostsByLeadThreshold function
@@ -21,6 +21,7 @@ afterEach(() => {
(filterHostsByLeadThreshold as Mock).mockClear();
});
const qualifiedHostsService = getQualifiedHostsService();
describe("findQualifiedHostsWithDelegationCredentials", async () => {
it("should return qualified hosts based on mock of filterHostsByLeadThreshold", async () => {
const hosts = [
@@ -92,7 +93,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
};
// Call the function under test
const result = await findQualifiedHostsWithDelegationCredentials({
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [],
rescheduleUid: null,
@@ -144,7 +145,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
};
// Call the function under test
const result = await findQualifiedHostsWithDelegationCredentials({
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [],
rescheduleUid: null,
@@ -231,7 +232,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
};
// Call the function under test
const result = await findQualifiedHostsWithDelegationCredentials({
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [],
rescheduleUid: null,
@@ -320,7 +321,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
};
// Call the function under test
const result = await findQualifiedHostsWithDelegationCredentials({
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [1],
rescheduleUid: null,
@@ -398,7 +399,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
};
prismaMock.booking.findFirst.mockResolvedValue({ userId: 2 });
const result = await findQualifiedHostsWithDelegationCredentials({
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [],
rescheduleUid: "recheduleUid",
@@ -478,7 +479,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
.mockImplementation(async () => [hosts[0]]);
// Call the function under test
const result = await findQualifiedHostsWithDelegationCredentials({
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [0, 1, 2],
rescheduleUid: null,
@@ -572,7 +573,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
]);
// Call the function under test
const result = await findQualifiedHostsWithDelegationCredentials({
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [2, 3],
rescheduleUid: null,
@@ -672,7 +673,7 @@ describe("findQualifiedHostsWithDelegationCredentials", async () => {
(filterHostsByLeadThreshold as Mock).mockResolvedValue(rrHostsAfterFairness);
// Call the function under test
const result = await findQualifiedHostsWithDelegationCredentials({
const result = await qualifiedHostsService.findQualifiedHostsWithDelegationCredentials({
eventType,
routedTeamMemberIds: [2, 3],
rescheduleUid: null,
@@ -1,16 +1,22 @@
import type { FilterHostsService } from "@calcom/lib/bookings/filterHostsBySameRoundRobinHost";
import {
findMatchingHostsWithEventSegment,
getNormalizedHostsWithDelegationCredentials,
} from "@calcom/lib/bookings/getRoutedUsers";
import type { EventType } from "@calcom/lib/bookings/getRoutedUsers";
import { withReporting } from "@calcom/lib/sentryWrapper";
import type { BookingRepository } from "@calcom/lib/server/repository/booking";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type { SchedulingType } from "@calcom/prisma/enums";
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
import type { RoutingFormResponse } from "../server/getLuckyUser";
import { filterHostsByLeadThreshold } from "./filterHostsByLeadThreshold";
import { filterHostsBySameRoundRobinHost } from "./filterHostsBySameRoundRobinHost";
export interface IQualifiedHostsService {
bookingRepo: BookingRepository;
filterHostsService: FilterHostsService;
}
type Host<T> = {
isFixed: boolean;
@@ -48,173 +54,174 @@ const isFixedHost = <T extends { isFixed: boolean }>(host: T): host is T & { isF
return host.isFixed;
};
const _findQualifiedHostsWithDelegationCredentials = async <
T extends {
email: string;
id: number;
credentials: CredentialPayload[];
userLevelSelectedCalendars: SelectedCalendar[];
} & Record<string, unknown>
>({
eventType,
rescheduleUid,
routedTeamMemberIds,
contactOwnerEmail,
routingFormResponse,
}: {
eventType: {
id: number;
maxLeadThreshold: number | null;
hosts?: Host<T>[];
users: T[];
schedulingType: SchedulingType | null;
isRRWeightsEnabled: boolean;
rescheduleWithSameRoundRobinHost: boolean;
includeNoShowInRRCalculation: boolean;
} & EventType;
rescheduleUid: string | null;
routedTeamMemberIds: number[];
contactOwnerEmail: string | null;
routingFormResponse: RoutingFormResponse | null;
}): Promise<{
qualifiedRRHosts: {
isFixed: boolean;
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
groupId?: string | null;
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
}[];
fixedHosts: {
isFixed: boolean;
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
groupId?: string | null;
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
}[];
// all hosts we want to fallback to including the qualifiedRRHosts (fairness + crm contact owner)
allFallbackRRHosts?: {
isFixed: boolean;
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
groupId?: string | null;
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
}[];
}> => {
const { hosts: normalizedHosts, fallbackHosts: fallbackUsers } =
await getNormalizedHostsWithDelegationCredentials({
eventType,
});
// not a team event type, or some other reason - segment matching isn't necessary.
if (!normalizedHosts) {
const fixedHosts = fallbackUsers.filter(isFixedHost);
const roundRobinHosts = fallbackUsers.filter(isRoundRobinHost);
return { qualifiedRRHosts: roundRobinHosts, fixedHosts };
}
export class QualifiedHostsService {
constructor(public readonly dependencies: IQualifiedHostsService) {}
const fixedHosts = normalizedHosts.filter(isFixedHost);
const roundRobinHosts = normalizedHosts.filter(isRoundRobinHost);
async _findQualifiedHostsWithDelegationCredentials<
T extends {
email: string;
id: number;
credentials: CredentialPayload[];
userLevelSelectedCalendars: SelectedCalendar[];
} & Record<string, unknown>
>({
eventType,
rescheduleUid,
routedTeamMemberIds,
contactOwnerEmail,
routingFormResponse,
}: {
eventType: {
id: number;
maxLeadThreshold?: number | null;
hosts?: Host<T>[];
users: T[];
schedulingType: SchedulingType | null;
isRRWeightsEnabled: boolean;
rescheduleWithSameRoundRobinHost: boolean;
includeNoShowInRRCalculation: boolean;
} & EventType;
rescheduleUid: string | null;
routedTeamMemberIds: number[];
contactOwnerEmail: string | null;
routingFormResponse: RoutingFormResponse | null;
}): Promise<{
qualifiedRRHosts: {
isFixed: boolean;
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
}[];
fixedHosts: {
isFixed: boolean;
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
}[];
// all hosts we want to fallback to including the qualifiedRRHosts (fairness + crm contact owner)
allFallbackRRHosts?: {
isFixed: boolean;
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
}[];
}> {
const { hosts: normalizedHosts, fallbackHosts: fallbackUsers } =
await getNormalizedHostsWithDelegationCredentials({
eventType,
});
// not a team event type, or some other reason - segment matching isn't necessary.
if (!normalizedHosts) {
const fixedHosts = fallbackUsers.filter(isFixedHost);
const roundRobinHosts = fallbackUsers.filter(isRoundRobinHost);
return { qualifiedRRHosts: roundRobinHosts, fixedHosts };
}
// If it is rerouting, we should not force reschedule with same host.
const hostsAfterRescheduleWithSameRoundRobinHost = applyFilterWithFallback(
roundRobinHosts,
await filterHostsBySameRoundRobinHost({
hosts: roundRobinHosts,
rescheduleUid,
rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost,
routedTeamMemberIds,
})
);
const fixedHosts = normalizedHosts.filter(isFixedHost);
const roundRobinHosts = normalizedHosts.filter(isRoundRobinHost);
if (hostsAfterRescheduleWithSameRoundRobinHost.length === 1) {
return {
qualifiedRRHosts: hostsAfterRescheduleWithSameRoundRobinHost,
fixedHosts,
};
}
// If it is rerouting, we should not force reschedule with same host.
const hostsAfterRescheduleWithSameRoundRobinHost = applyFilterWithFallback(
roundRobinHosts,
await this.dependencies.filterHostsService.filterHostsBySameRoundRobinHost({
hosts: roundRobinHosts,
rescheduleUid,
rescheduleWithSameRoundRobinHost: eventType.rescheduleWithSameRoundRobinHost,
routedTeamMemberIds,
})
);
const hostsAfterSegmentMatching = applyFilterWithFallback(
hostsAfterRescheduleWithSameRoundRobinHost,
(await findMatchingHostsWithEventSegment({
eventType,
hosts: hostsAfterRescheduleWithSameRoundRobinHost,
})) as typeof hostsAfterRescheduleWithSameRoundRobinHost
);
if (hostsAfterRescheduleWithSameRoundRobinHost.length === 1) {
return {
qualifiedRRHosts: hostsAfterRescheduleWithSameRoundRobinHost,
fixedHosts,
};
}
if (hostsAfterSegmentMatching.length === 1) {
return {
qualifiedRRHosts: hostsAfterSegmentMatching,
fixedHosts,
};
}
const hostsAfterSegmentMatching = applyFilterWithFallback(
hostsAfterRescheduleWithSameRoundRobinHost,
(await findMatchingHostsWithEventSegment({
eventType,
hosts: hostsAfterRescheduleWithSameRoundRobinHost,
})) as typeof hostsAfterRescheduleWithSameRoundRobinHost
);
//if segment matching doesn't return any hosts we fall back to all round robin hosts
const officalRRHosts = hostsAfterSegmentMatching.length
? hostsAfterSegmentMatching
: hostsAfterRescheduleWithSameRoundRobinHost;
if (hostsAfterSegmentMatching.length === 1) {
return {
qualifiedRRHosts: hostsAfterSegmentMatching,
fixedHosts,
};
}
const hostsAfterContactOwnerMatching = applyFilterWithFallback(
officalRRHosts,
officalRRHosts.filter((host) => host.user.email === contactOwnerEmail)
);
//if segment matching doesn't return any hosts we fall back to all round robin hosts
const officalRRHosts = hostsAfterSegmentMatching.length
? hostsAfterSegmentMatching
: hostsAfterRescheduleWithSameRoundRobinHost;
const hostsAfterRoutedTeamMemberIdsMatching = applyFilterWithFallback(
officalRRHosts,
officalRRHosts.filter((host) => routedTeamMemberIds.includes(host.user.id))
);
const hostsAfterContactOwnerMatching = applyFilterWithFallback(
officalRRHosts,
officalRRHosts.filter((host) => host.user.email === contactOwnerEmail)
);
const hostsAfterRoutedTeamMemberIdsMatching = applyFilterWithFallback(
officalRRHosts,
officalRRHosts.filter((host) => routedTeamMemberIds.includes(host.user.id))
);
if (hostsAfterRoutedTeamMemberIdsMatching.length === 1) {
if (hostsAfterContactOwnerMatching.length === 1) {
return {
qualifiedRRHosts: hostsAfterContactOwnerMatching,
allFallbackRRHosts: getFallBackWithContactOwner(
hostsAfterRoutedTeamMemberIdsMatching,
hostsAfterContactOwnerMatching[0]
),
fixedHosts,
};
}
return {
qualifiedRRHosts: hostsAfterRoutedTeamMemberIdsMatching,
fixedHosts,
};
}
const hostsAfterFairnessMatching = applyFilterWithFallback(
hostsAfterRoutedTeamMemberIdsMatching,
await filterHostsByLeadThreshold({
eventType,
hosts: hostsAfterRoutedTeamMemberIdsMatching,
maxLeadThreshold: eventType.maxLeadThreshold ?? null,
routingFormResponse,
})
);
if (hostsAfterRoutedTeamMemberIdsMatching.length === 1) {
if (hostsAfterContactOwnerMatching.length === 1) {
return {
qualifiedRRHosts: hostsAfterContactOwnerMatching,
allFallbackRRHosts: getFallBackWithContactOwner(
hostsAfterRoutedTeamMemberIdsMatching,
hostsAfterFairnessMatching,
hostsAfterContactOwnerMatching[0]
),
fixedHosts,
};
}
return {
qualifiedRRHosts: hostsAfterRoutedTeamMemberIdsMatching,
qualifiedRRHosts: hostsAfterFairnessMatching,
// only if fairness filtering is active
allFallbackRRHosts:
hostsAfterFairnessMatching.length !== hostsAfterRoutedTeamMemberIdsMatching.length
? hostsAfterRoutedTeamMemberIdsMatching
: undefined,
fixedHosts,
};
}
const hostsAfterFairnessMatching = applyFilterWithFallback(
hostsAfterRoutedTeamMemberIdsMatching,
await filterHostsByLeadThreshold({
eventType,
hosts: hostsAfterRoutedTeamMemberIdsMatching,
maxLeadThreshold: eventType.maxLeadThreshold,
routingFormResponse,
})
findQualifiedHostsWithDelegationCredentials = withReporting(
this._findQualifiedHostsWithDelegationCredentials.bind(this),
"findQualifiedHostsWithDelegationCredentials"
);
if (hostsAfterContactOwnerMatching.length === 1) {
return {
qualifiedRRHosts: hostsAfterContactOwnerMatching,
allFallbackRRHosts: getFallBackWithContactOwner(
hostsAfterFairnessMatching,
hostsAfterContactOwnerMatching[0]
),
fixedHosts,
};
}
return {
qualifiedRRHosts: hostsAfterFairnessMatching,
// only if fairness filtering is active
allFallbackRRHosts:
hostsAfterFairnessMatching.length !== hostsAfterRoutedTeamMemberIdsMatching.length
? hostsAfterRoutedTeamMemberIdsMatching
: undefined,
fixedHosts,
};
};
export const findQualifiedHostsWithDelegationCredentials = withReporting(
_findQualifiedHostsWithDelegationCredentials,
"findQualifiedHostsWithDelegationCredentials"
);
}
@@ -5,20 +5,22 @@ import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { prismaModule } from "@calcom/prisma/prisma.module";
import type { AvailableSlotsService } from "@calcom/trpc/server/routers/viewer/slots/util";
import { availableSlotsModule } from "../modules/available-slots";
import { bookingRepositoryModule } from "../modules/booking";
import { busyTimesModule } from "../modules/busy-times";
import { cacheModule } from "../modules/cache";
import { checkBookingLimitsModule } from "../modules/check-booking-limits";
import { eventTypeRepositoryModule } from "../modules/eventType";
import { featuresRepositoryModule } from "../modules/features";
import { getUserAvailabilityModule } from "../modules/get-user-availability";
import { oooRepositoryModule } from "../modules/ooo";
import { routingFormResponseRepositoryModule } from "../modules/routingFormResponse";
import { scheduleRepositoryModule } from "../modules/schedule";
import { selectedSlotsRepositoryModule } from "../modules/selectedSlots";
import { teamRepositoryModule } from "../modules/team";
import { userRepositoryModule } from "../modules/user";
import { availableSlotsModule } from "../modules/AvailableSlots";
import { bookingRepositoryModule } from "../modules/Booking";
import { busyTimesModule } from "../modules/BusyTimes";
import { cacheModule } from "../modules/Cache";
import { checkBookingLimitsModule } from "../modules/CheckBookingLimits";
import { eventTypeRepositoryModule } from "../modules/EventType";
import { featuresRepositoryModule } from "../modules/Features";
import { filterHostsModule } from "../modules/FilterHosts";
import { getUserAvailabilityModule } from "../modules/GetUserAvailability";
import { oooRepositoryModule } from "../modules/Ooo";
import { qualifiedHostsModule } from "../modules/QualifiedHosts";
import { routingFormResponseRepositoryModule } from "../modules/RoutingFormResponse";
import { scheduleRepositoryModule } from "../modules/Schedule";
import { selectedSlotsRepositoryModule } from "../modules/SelectedSlots";
import { teamRepositoryModule } from "../modules/Team";
import { userRepositoryModule } from "../modules/User";
const container = createContainer();
container.load(DI_TOKENS.REDIS_CLIENT, redisModule);
@@ -37,6 +39,9 @@ container.load(DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE_MODULE, checkBookingLimits
container.load(DI_TOKENS.AVAILABLE_SLOTS_SERVICE_MODULE, availableSlotsModule);
container.load(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE_MODULE, getUserAvailabilityModule);
container.load(DI_TOKENS.BUSY_TIMES_SERVICE_MODULE, busyTimesModule);
container.load(DI_TOKENS.BUSY_TIMES_SERVICE_MODULE, busyTimesModule);
container.load(DI_TOKENS.FILTER_HOSTS_SERVICE_MODULE, filterHostsModule);
container.load(DI_TOKENS.QUALIFIED_HOSTS_SERVICE_MODULE, qualifiedHostsModule);
export function getAvailableSlotsService() {
return container.get<AvailableSlotsService>(DI_TOKENS.AVAILABLE_SLOTS_SERVICE);
@@ -5,9 +5,9 @@ import { prismaModule } from "@calcom/prisma/prisma.module";
import type { CheckBookingAndDurationLimitsService } from "../../../features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits";
import type { CheckBookingLimitsService } from "../../intervalLimits/server/checkBookingLimits";
import { bookingRepositoryModule } from "../modules/booking";
import { checkBookingAndDurationLimitsModule } from "../modules/check-booking-and-duration-limits";
import { checkBookingLimitsModule } from "../modules/check-booking-limits";
import { bookingRepositoryModule } from "../modules/Booking";
import { checkBookingAndDurationLimitsModule } from "../modules/CheckBookingAndDurationLimits";
import { checkBookingLimitsModule } from "../modules/CheckBookingLimits";
const container = createContainer();
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
@@ -4,8 +4,8 @@ import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { prismaModule } from "@calcom/prisma/prisma.module";
import type { BusyTimesService } from "../../getBusyTimes";
import { bookingRepositoryModule } from "../modules/booking";
import { busyTimesModule } from "../modules/busy-times";
import { bookingRepositoryModule } from "../modules/Booking";
import { busyTimesModule } from "../modules/BusyTimes";
const container = createContainer();
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
@@ -4,8 +4,8 @@ import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { prismaModule } from "@calcom/prisma/prisma.module";
import type { CacheService } from "../../../features/calendar-cache/lib/getShouldServeCache";
import { cacheModule } from "../modules/cache";
import { featuresRepositoryModule } from "../modules/features";
import { cacheModule } from "../modules/Cache";
import { featuresRepositoryModule } from "../modules/Features";
const container = createContainer();
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
+17
View File
@@ -0,0 +1,17 @@
import { createContainer } from "@evyweb/ioctopus";
import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { prismaModule } from "@calcom/prisma/prisma.module";
import type { FilterHostsService } from "../../bookings/filterHostsBySameRoundRobinHost";
import { bookingRepositoryModule } from "../modules/Booking";
import { filterHostsModule } from "../modules/FilterHosts";
const container = createContainer();
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
container.load(DI_TOKENS.FILTER_HOSTS_SERVICE_MODULE, filterHostsModule);
export function getFilterHostsService() {
return container.get<FilterHostsService>(DI_TOKENS.FILTER_HOSTS_SERVICE);
}
@@ -5,11 +5,11 @@ import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { prismaModule } from "@calcom/prisma/prisma.module";
import type { UserAvailabilityService } from "../../getUserAvailability";
import { bookingRepositoryModule } from "../modules/booking";
import { busyTimesModule } from "../modules/busy-times";
import { eventTypeRepositoryModule } from "../modules/eventType";
import { getUserAvailabilityModule } from "../modules/get-user-availability";
import { oooRepositoryModule } from "../modules/ooo";
import { bookingRepositoryModule } from "../modules/Booking";
import { busyTimesModule } from "../modules/BusyTimes";
import { eventTypeRepositoryModule } from "../modules/EventType";
import { getUserAvailabilityModule } from "../modules/GetUserAvailability";
import { oooRepositoryModule } from "../modules/Ooo";
const container = createContainer();
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
@@ -9,7 +9,7 @@ import type {
import type { InsightsBookingService } from "@calcom/lib/server/service/InsightsBookingDIService";
import { prismaModule } from "@calcom/prisma/prisma.module";
import { insightsBookingModule } from "../modules/insights-booking";
import { insightsBookingModule } from "../modules/InsightsBooking";
export function getInsightsBookingService({
options,
@@ -9,7 +9,7 @@ import type {
import type { InsightsRoutingService } from "@calcom/lib/server/service/insightsRoutingDI";
import { prismaModule } from "@calcom/prisma/prisma.module";
import { insightsRoutingModule } from "../modules/insights-routing";
import { insightsRoutingModule } from "../modules/InsightsRouting";
export function getInsightsRoutingService({
options,
@@ -0,0 +1,19 @@
import { createContainer } from "@evyweb/ioctopus";
import { DI_TOKENS } from "@calcom/lib/di/tokens";
import { prismaModule } from "@calcom/prisma/prisma.module";
import type { QualifiedHostsService } from "../../bookings/findQualifiedHostsWithDelegationCredentials";
import { bookingRepositoryModule } from "../modules/Booking";
import { filterHostsModule } from "../modules/FilterHosts";
import { qualifiedHostsModule } from "../modules/QualifiedHosts";
const container = createContainer();
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
container.load(DI_TOKENS.FILTER_HOSTS_SERVICE_MODULE, filterHostsModule);
container.load(DI_TOKENS.QUALIFIED_HOSTS_SERVICE_MODULE, qualifiedHostsModule);
export function getQualifiedHostsService() {
return container.get<QualifiedHostsService>(DI_TOKENS.QUALIFIED_HOSTS_SERVICE);
}
@@ -20,4 +20,6 @@ availableSlotsModule.bind(DI_TOKENS.AVAILABLE_SLOTS_SERVICE).toClass(AvailableSl
checkBookingLimitsService: DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE,
userAvailabilityService: DI_TOKENS.GET_USER_AVAILABILITY_SERVICE,
busyTimesService: DI_TOKENS.BUSY_TIMES_SERVICE,
featuresRepo: DI_TOKENS.FEATURES_REPOSITORY,
qualifiedHostsService: DI_TOKENS.QUALIFIED_HOSTS_SERVICE,
} satisfies Record<keyof IAvailableSlotsService, symbol>);

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