Compare commits

...
Author SHA1 Message Date
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
emrysal a90d765fca chore: release v5.5.15
Post release / release (push) Canceled after 0s
2025-08-11 11:29:57 +00:00
af51df2b4d 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>
2025-08-11 11:04:23 +00:00
Ayush Kumar 076e93ca56 fix: Contact support button position changed from absolute to fixed (#23002) 2025-08-11 10:58:03 +00:00
mintlify[bot] 47f45409d2 Documentation edits made through Mintlify web editor (#23007)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-08-11 10:35:15 +00:00
Carina WollendorferandCarinaWolli 759d5b8663 fix error in handleNewBooking (#23011)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-08-11 10:33:46 +00:00
Sahitya Chandra c554396572 fix: Remove Reporting page within Routing Forms (#22990) 2025-08-11 11:26:43 +01:00
Benny Joo fafd3fb181 refactor: Get rid of getServerSideProps for /getting-started pages (#23003)
* refactor

* fix type check
2025-08-11 11:22:13 +01:00
Anik Dhabal Babu eb05d547de fix: Booker active booking limit can't be switched off (#23005) 2025-08-11 08:29:41 +00:00
keerthi vardhanandCarina Wollendorfer 1fa8a5ef25 feat: allow inserting webhook variables into custom payload template (#22835)
* feat: allow inserting webhook variables into custom payload template

* localize UI and apply design system styling

* add translations and minor UI fixes

* add translations

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2025-08-11 07:32:52 +00:00
5a350efb40 perf: optimize app registry performance with caching (#22632)
* feat: optimize app registry performance with memory-cache

- Implement in-memory caching for getInstallCountPerApp with 5-minute TTL
- Add memory-cache and @types/memory-cache dependencies to packages/lib
- Remove invalidateInstallCountCache functionality as requested
- Maintain existing API compatibility for app sorting functionality
- Improve performance by avoiding expensive SQL COUNT queries on every request

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

* update

* Update getInstallCountPerApp.ts

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-11 04:36:20 +00:00
VijayandAnik Dhabal Babu 242f86891a fix: MSTeams not created as online meetings (#21377)
* fix MSTeams formatting and duplicate events

* update without changing scopes, create onlineMeetings through CalendarService instead of VideoApiAdapter

* get url after online meeting is created required for downstream

* fallback to callvideo if only MSteams and update evt.videoCallData with url

* for backward compatibility set explicitly 'Microsoft Teams Meeting' for old meetings if rescheduled

* add dependency to msTeams App

* nit

* use interpolation

* update to create MSTeams event if only MSTeams installed and not Outlook Calendar

* make function MSTeams specific

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-11 08:11:45 +05:30
Lucas ce6969d058 Fix typo in env var name in log message (#22987) 2025-08-09 09:13:24 +00:00
Anirban SinghaandKartik Saini 26f8246b91 fix: text wrap on /conference (#22864)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-08 13:59:10 -03:00
Joe Au-Yeung 4baa9b6bc1 wrap samlResponse in try/catch (#22984) 2025-08-08 16:06:18 +00:00
Udit TakkarandHariom Balhara 02c86d0268 feat: cal ai self serve architecture #1 (#22919)
* feat: cal ai self serve architecture

* chore: add package

* chore: update evnet controller

* refactor: improvements

* chore: rename

* chore: type error and naming

* chore: just set it to nul

* chore: just set it to nul

* chore: some more improvements

* chore: packate version

* fix: API v2

* chore: change name of files

* chore: add select

* chore: add missing teamId

* chore: save progress

* refactor: split into multiple services

* refactor: make schema provider agonistic

* chore: improvements

* chore:

* chore: remove duplicate files

* chore: semicolon

* chore: formatting

* refactor: logging and error handling

* chore: rename variable

* refactor: use trpc error

* chore: replace with HttpError

* chore: remove from option

* We need the enum and not just the type

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-08-08 15:57:13 +00:00
Anik Dhabal Babu 49f99d69a8 fix: slugify event url in duplicate dialog (#22983)
* fix: flaky e2e

* fix: slugify url
2025-08-08 15:48:58 +00:00
Alex van Andel 659d9cf767 fix: Hotfix missing schema, out of sync with migrations (#22977) 2025-08-08 14:46:58 +01:00
4d662d31a1 feat: filter embed query params from booking success redirects (#22028)
* feat: filter embed query params from booking success redirects

- Add filterEmbedParams option to getNewSearchParams function
- Filter out embed, layout, embedType, and ui.color-scheme params when redirecting to external pages
- Add comprehensive unit tests for bookingSuccessRedirect covering:
  - External redirects with and without parameter forwarding
  - Embed parameter filtering functionality
  - Internal redirects to booking pages
  - Booking parameter extraction
- All tests pass and type checking succeeds

Fixes #20469

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

* chore: retrigger CI checks

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

* chore: remove temporary CI retrigger file

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

* Handle a case

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-08-08 19:12:36 +05:30
6fe6540bc6 fix: apply PBAC to routing form CRUD (#22859)
* fix: apply PBAC to routing form CRUD

* apply permission checks to the UI

* moving prisma call to repository [WIP]

* rename repository and fix type error

* update implementation

* fix formMutation handler

* remove unused import

* revert some rename

* add RolePermission for 'routingForm'

* Revert "revert some rename"

This reverts commit 0ef3114c0d27b821271b36296de531654b37749f.

* clean up PrismaRoutingFormRepository

* fix unit test

* remove no longer necessary code

* fix type definition

* explicit permission handling

* do not disable un-editable routing form

* fix: correct property name from readonly to readOnly in ListLinkItem

- Fix TypeScript error in routing forms component
- Change readonly={readOnly} to readOnly={readOnly} to match component interface
- Resolves type check failure in CI

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

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-08 13:18:18 +01:00
emrysal ee1904bb2c chore: release v5.5.14
Post release / release (push) Canceled after 0s
2025-08-08 12:11:15 +00:00
Alex van Andelandcoderabbitai[bot] aaa59f1455 chore: Add relation between a host and a membership for future cascades (#22975)
* chore: Add relation between a host and a membership for future cascades

* Apply suggestion

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

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-08 13:06:02 +01:00
Carina WollendorferandCarinaWolli e29b7e83b8 feat: Round Robin groups (#22296)
* add Add Group button

* add host groups to schema

* UI for host groups

* raname groups to hostGroups

* schema update

* show groups in assignment tab

* add no group hosts to Group 1

* add dummy group for non group hosts

* fix type errors

* use two dimensional array for luckyUserPools

* fix empty array

* group RR hosts in handleNewBooking

* improve logic for grouping lucky users

* find all lucky users of all groups

* allow several RR hosts on booking

* clean up migrations

* create helper function

* group hosts for slots logic

* add group logic to loading available slots

* adding hosts to groups

* add groupId to hostSchema

* disable hosts from other groups

* handle groups in checkedTeamSelect

* fix adding hosts to groups

* remove and add groups

* show hosts if there are no groups

* fixing adding first group with existing hosts

* show groups empty groups correctly

* UI upddate fixes

* fix adding hosts to existing first host group

* small fixes + code clean up

* add availability fix with test

* create new round-robin test file

* disable reassignment

* fix losing fixed hosts

* fix updating weights and priorities

* disable load balancing with Round Robin Groups

* automatically disable load balancing in update handler

* allRRHosts should only include hosts from same group

* fix type errors

* fix type error

* fix tests

* fix type error

* remove undefined from groupId type

* type changes

* add tests for hostGroups

* add tests for host groups

* fixes

* fix type errors with undefined groupId

* remove seperate host groups prop

* fix editing weights

* remove console.log

* code clean up

* improve getAggregatedAvailability tests

* throw error when no available hosts in a group

* add fixme comment

* create constant for DEFAULT_GROUP_ID

* clean up code

* mock default_group_id for unit tests

* don't show fixed hosts in edit weights side bar

* add DEFAULT_GROUP_ID to  mock test-setup

* remove unused index variable

* code clean up

* fix updating host groups

* fix imports

* add default_group_id to mocks

* add uuid() to zod schema

* remove unused code

* fix singular translation key

* remove unnessary !!

* Revert formatting changes

* add additional tests for bookingActions

* use createMany

* import DEFAULT_GROUP_ID for mocks

* fix mocks

* clean up EventTeamAssignmentTab

* fix type errors in tests

* fix mocks

* remove constants.example.test.ts

* fix type error

* add missing groupId

* fix margin

* clean up empty host groups

* fix constants mock

* useCalback

* use reduce

* extract handlers into seperate functions

* fix handler functions

* fix border radius

* fix type error in CheckForEmptyAssignment

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-08-08 12:56:13 +01:00
6af473a486 fix: prevent makeBodyVisible timeout from running after test teardown (#22972)
* fix: prevent makeBodyVisible timeout from running after test teardown

- Add timeout tracking to makeBodyVisible function to enable cleanup
- Add vi.clearAllTimers() to test teardown to clear recursive timeouts
- Prevents 'ReferenceError: document is not defined' in CI tests

Fixes intermittent test failures where makeBodyVisible recursive timeout
continues running after test environment is torn down.

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

* simplify: use only vi.clearAllTimers() to fix timeout issue

Based on code review feedback, test if vi.clearAllTimers() alone
is sufficient without modifying makeBodyVisible function.

- Reverted makeBodyVisible function changes
- Kept only vi.clearAllTimers() in test teardown
- Verified with multiple test runs and type checking

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-08 08:07:08 -03:00
Joe Au-YeungandHariom Balhara bf8f966811 chore: Add logging to SAML endpoints (#22968)
* Add logging to SAML endpoints

* Update packages/features/auth/lib/next-auth-options.ts

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-08-08 11:50:46 +01:00
Anik Dhabal Babu c64b663514 chore: Add zoom waiting room setting (#22888)
* chore: zoom waiting room setting

* Update VideoApiAdapter.ts

* Update VideoApiAdapter.ts
2025-08-08 10:37:36 +00:00
29314cde75 refactor: convert getBusyTimes to service class with dependency injection (#22949)
* refactor: convert getBusyTimes to service class with dependency injection

- Create BusyTimesService following UserAvailabilityService pattern
- Add DI tokens, module, and container setup for BusyTimesService
- Update all usage locations to use service instead of direct function calls
- Maintain existing function signatures for backward compatibility
- Add legacy exports to ensure smooth transition
- Update type references in trpc util to use service prototype
- Fix linting issues by making legacy exports async with proper imports
- All tests pass and type checking succeeds

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

* refactor: busy time service

* fix: api v2 eslint plugins version mismatch

* chore: bump platform libs

* chore: bump platform libs

* fix: missing di busyTimesModule in slots service

---------

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-08 12:14:08 +03:00
3ea73e75d6 feat: add Workflow resource to PBAC system with permission enforcement (#22845)
Co-authored-by: sean@cal.com <Sean@brydon.io>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-08-08 08:48:18 +00:00
Lauris Skraucis d18e23378b feat: toggle round robin org and team info in booker (#22956)
* feat: toggle round robin booker org and team

* chore: add changeset

* dont hide in dev
2025-08-08 13:55:53 +05:30
Sahitya ChandraandAnik Dhabal Babu 736f1f594c fix: add strict URL validation with consistent error message (#22843)
* added strict url validation with consistent Invalid URL error message

* chore

* Update schema.ts

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-08 06:48:28 +00:00
09b1643478 chore: added headless routing link to embed (#22921)
* added headless routing link to embed

* Update apps/web/public/static/locales/en/common.json

* Update apps/web/public/static/locales/en/common.json

* fix: add missing headless embed type handlers to resolve TypeScript errors

- Add headless handlers for react, react-atom, and HTML frameworks in EmbedCodes.tsx
- Add headless case handling in EmbedTabs.tsx getEmbedTypeSpecificString function
- Add embedCalOrigin parameter to HTML framework handlers for type consistency
- All handlers return documentation comments directing users to headless routing docs

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

* temp: enable IS_CALCOM for localhost testing

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

* fix: change headless handlers to return null instead of documentation code

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

* revert: remove temporary IS_CALCOM localhost change

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

* refactor: remove unnecessary parameter destructuring from headless embed handlers

- Headless handlers now take no parameters since they only return null
- Simplified function signatures to avoid unused parameter warnings
- Maintains existing functionality while improving code clarity

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariombalhara@gmail.com>
2025-08-08 06:18:59 +00:00
86f6e8bac3 Fix: Update BTCPay Server currencies and fix dark mode bug (#22887)
* Update BTCPay Server currencies

* fix type

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-08 05:50:14 +00:00
Sahitya Chandra 9c65d02e84 docs: refactor the v2 api README file (#22896)
* doc: fixed the V2 api README file

* chore
2025-08-08 05:49:27 +00:00
Romit be9ab02a7e fix: ICS file on workflows where the invite is added wont display the link of the integration (#22964)
* fix

* fix

* chore
2025-08-08 10:45:14 +05:30
Joe Au-Yeung cfefba6d4d Revert "perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC (#21781)" (#22963) 2025-08-07 11:55:13 -07:00
Joe Au-Yeung 815e13303b Compare membership with teamId (#22957) 2025-08-07 15:07:21 +00:00
Bandhan Majumder 7c8dbb3ea3 fix: contact support button overlap (#22952)
* add: lib folder in tailwind config

* move plainChat and contactForm from lib to components

* Update tailwind-preset.js
2025-08-07 14:29:21 +00:00
Alex van Andel cabd459682 chore: slots time range validation to zod (#22942)
* chore: Move date range validation to zod, removing TRPCError

* This is not a BAD_REQUEST but an internal error, should not happen

* Add a check to ensure the given start time is before end time
2025-08-07 15:46:34 +03:00
Somay ChauhanandRajiv Sahal 33aedbae20 fix: booker embed (#22898)
* fix: make organizationId optional number type in BookerEmbed

* feat: dont call useMe for embeds

* useOAuthClient hook should re-run when url is set

* feat: Remove `?orgId=0` from /public event query params

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-08-07 10:29:22 +02:00
Somay ChauhanandRajiv Sahal d3fbc73a39 feat: add callback for handleFormSubmit in EventType and AvailabilitySettings atoms (#22911)
* feat: Implement callback pattern in handleFormSubmit of EventTypePlatformWrapper

* feat: Implement callback pattern in handleFormSubmit of AvailabilitySettingsPlatformWrapper

* docs: add handleFormSubmit callback documentation for EventType and AvailabilitySettings atoms

* Update availability.tsx

* chore:added changelog

* update docs

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-08-07 10:26:09 +02:00
cfda4757e4 refactor: move getTotalBookingDuration to BookingRepository (#22931)
* refactor: move getTotalBookingDuration to BookingRepository

- Move getTotalBookingDuration function from standalone file to BookingRepository class
- Update all usage sites to call method through repository instance
- Remove standalone function file packages/lib/server/queries/booking/index.ts
- Add prisma import to util.ts for BookingRepository instantiation
- Maintain exact same method signature and functionality

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

* fix: remove eslint-config-next to resolve TypeScript ESLint conflicts

- Remove eslint-config-next dependency that was causing version conflicts
- Resolves 'Class extends value undefined is not a constructor or null' errors
- ESLint 'next' config issue appears to be pre-existing in main branch

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

* fixup! Merge branch 'main' into devin/move-getTotalBookingDuration-1754460208

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-08-07 10:53:18 +03:00
Anirban Singha 895ed48bd6 feat: rename cal.ai to transcribe (#22940)
* feat: rename cal.ai to transcribe

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>

* update

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>

---------

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>
2025-08-07 04:11:46 +00:00
Yadong (Adam) Zhang eda328d196 chore: update ESLint configuration (#22924) 2025-08-06 17:41:02 +00:00
Lee Salminen 1c501d1501 BTCPayServer App - fixes #22928 (#22933) 2025-08-06 16:48:41 +00:00
PallavandKartik Saini 08a57f32c5 fix: persist team filter across tab switches (#22892)
* fix/persist team filter across tab switches

* fix failing ts errors

* tackled failing ts checks

* fix prop type consistency

* removed unecessary props

* tackled in one file

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-06 22:14:31 +05:30
Sahitya Chandra b5a26bc227 fix: fixed the estonian languageon confirmation page (#22936) 2025-08-06 22:10:00 +05:30
Peer Richelsen 737b019199 hotfix for plain support (#22937) 2025-08-06 17:25:33 +01:00
Alex van Andel b41031626f chore: when using pii for rate limit (email, ip) hash (#22922) 2025-08-06 13:54:57 +01:00
Benny Joo a1fc4bccb9 fix: OG image error from custom fonts not being supported (#22916) 2025-08-06 17:19:15 +05:30
Benny Joo d764b6a32d chore: increase i18n fetch timeout to 30s for dev mode (#22912) 2025-08-06 09:56:27 +02:00
emrysal 87cb8b170a chore: release v5.5.13
Post release / release (push) Canceled after 0s
2025-08-06 07:33:08 +00:00
Omar LópezandDevin AI badd0de33c fix: resolve circular dependency in getUserAvailability by extracting getPeriodStartDatesBetween utility (#22913)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-06 06:10:38 +00:00
Benny Joo 124fd8ebca fix: OG image error from missing height field (#22914) 2025-08-06 14:12:28 +09:00
emrysal 5d0dc3f546 chore: release v5.5.12
Post release / release (push) Canceled after 0s
2025-08-06 01:00:26 +00:00
MorganandAlex van Andel e4baf15b53 feat: Sync timezone for users having delegation credentials for google/outlook (#22904)
* feat: sync timezone with google/outlook for delegated credentials users

* chore: dynamic sync timezone in get availble slots

* redis cache for get delegated timezone

* Update packages/lib/getUserAvailability.ts

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-06 01:55:47 +01:00
Alex van Andel a1bd8b6259 fix: invalid workflow (apiv2 e2e yaml) preventing checks running 2025-08-06 01:17:27 +01:00
Eunjae Lee 7987c9f45d revert: eslint-config-next to v13.2.1 (#22903) 2025-08-06 00:29:55 +01:00
Andreas Thomas 781d0d92df perf: upgrade unkey to v2 (#22902)
Unkey's v2 api runs on AWS (including us-east-2, where cal runs) and has
significantly lower latency than v1.
2025-08-05 10:49:51 -07:00
Alex van Andelandcal.com b71d8baccc chore: Implement short-lived redis cache for slots (#22787)
* chore: Implement short-lived redis cache for slots

* chore: adapt apiv2 redis service to match with upstash redis

* chore: safer redis service and ms ttl

* fixup! chore: safer redis service and ms ttl

* Wrap with timeout, currently doesn't work yet

* Updated @upstash/redis for better signal support

* Fix type errors, remove ts value

* Inject NoopRedisService for NODE_ENV test

* chore: bump platform libs

* chore: bump platform libs

* Upstash Redis upgrade no longer resulted in expected hard crash on init, so updated factory and our Upstash Redis Adapter to mimick old behaviour

* Add SLOTS_CACHE_TTL variable for configurable ttl on slots cache

* Update parseInt to use right types

* chore: bump platform libs

* chore: bump platform libs

* chore: bump platform libs

* update e2e api v2 action

* set SLOTS_CACHE_TTL env var api v2 e2e

---------

Co-authored-by: cal.com <morgan@cal.com>
2025-08-05 17:32:02 +00:00
a71d949a0c feat: add cron job to cleanup old queued form responses (#22035)
* feat: add cron job to cleanup old queued form responses

- Add cleanup cron job for App_RoutingForms_QueuedFormResponse table
- Delete records with null actualResponseId older than 1 hour
- Schedule to run twice daily (every 12 hours) via vercel.json
- Follow existing cron job patterns for authentication and structure

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

* fix: correct date comparison logic to delete older records

- Change from lte to gte in createdAt comparison
- Now properly deletes records older than 1 hour

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

* refactor: move cleanup logic to routing forms lib and import in API endpoint

- Create cleanupExpiredQueuedFormResponses function in routing forms lib
- Update API endpoint to import and use the new cleanup function
- Follow established patterns for code organization in Cal.com
- Maintain same functionality and authentication logic

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

* refactor: move entire cron logic to routing-forms cron folder

- Create routing-forms/cron/queuedFormResponseCleanup.ts with complete handler
- Move authentication, HTTP handling, and database operations to routing-forms
- Update API endpoint to simply import and call the cron handler
- Remove old lib cleanup function
- Follow user's preferred organizational pattern

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

* fix: correct date comparison logic to delete records older than 1 hour

- Change from gte (greater than or equal) to lt (less than) cutoffTime
- Now properly deletes records where createdAt < cutoffTime (older than 1 hour)
- Previous logic was deleting newer records instead of older ones

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

* fixes

* refactor: reorganize queuedFormResponse into domain-driven structure

- Move repository and service files to routingForm/queuedFormResponse subdirectory
- Clean up unused methods and tests for better maintainability
- Fix import paths and type definitions
- Ensure all tests pass and maintain backward compatibility

* fix: align QueuedFormResponse interface types with Prisma schema

- Update actualResponseId type from string to number to match Prisma Int type
- Make updatedAt nullable (Date | null) to match Prisma DateTime? type
- Fix import path for QueuedFormResponseRepository interface

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

* refactor: reorganize queuedFormResponse into domain-driven structure

- Move repository and service files to routingForm/queuedFormResponse subdirectory
- Clean up unused methods and tests for better maintainability
- Fix import paths and type definitions
- Ensure all tests pass and maintain backward compatibility

* test: add comprehensive where clause validation tests and missing test coverage

* chore: update cron schedule to weekly for 7-day retention period

* revert: restore 12-hour cron schedule for better batch management

* Add integration tests

* refactor: apply test data builder pattern to queuedFormResponse integration tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-08-05 22:17:50 +05:30
emrysal e55cee443b chore: release v5.5.11
Post release / release (push) Canceled after 0s
2025-08-05 12:20:27 +00:00
MorganandAlex van Andel c3634b3aba refactor: getUserAvailability into service with DI (#22881)
* refactor: getUserAvailability into service with DI

* chore: bump platform libs

* disable bull queue in e2e for bookings

* chore: bump platform libs

* chore: bump platform libs

* fix: should update event type bookingFields test

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-05 13:13:42 +01:00
Eunjae LeeandDevin AI cd576f5378 refactor: use dependency injection for InsightsBookingService (#22790)
* refactor: use dependency injection for InsightsBookingService

- Add DI tokens for InsightsBookingService and module
- Rename InsightsBookingService to InsightsBookingBaseService
- Create InsightsBookingService DI interface with create method
- Add DI module and container for InsightsBookingService
- Update tRPC router to use getInsightsBookingService DI container
- Update test file to use InsightsBookingBaseService
- Update documentation to reflect new DI pattern

Follows the same dependency injection pattern established for InsightsRoutingService in PR #22677

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

* refactor: restore createInsightsBookingService helper using DI internally

- Keep createInsightsBookingService helper function for cleaner API
- Use getInsightsBookingService DI container internally
- Maintain same function signature and behavior
- All existing calls continue to work unchanged

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

* refactor: rename service files with proper capitalization

- Rename insightsBookingBase.ts to InsightsBookingBaseService.ts
- Rename insightsBookingDI.ts to InsightsBookingDIService.ts
- Update all import statements to use new file names
- Maintain existing handler functionality using createInsightsBookingService(ctx, input)

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

* revert some changes

* rename

* update doc

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-05 11:07:07 +02:00
github-actions[bot] 2965450929 chore: version packages (#22890)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-05 06:15:25 +00:00
ddoemonnandKartik Saini db9b9c45dc fix(event-types): remove gap in URL input for event creation (#22123)
* fix(event-types): remove gap in URL input for event creation

* fix: event setup tab spacing

* fix: space in profile view

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-05 03:51:30 +00:00
emrysal 37f500526c chore: release v5.5.11 2025-08-05 00:46:56 +00:00
Eunjae Lee 006aecb066 fix: add backfill migration for routing response denormalized tables (#21474)
* fix: add backfill migration for routing response denormalized tables

* update script

* implement select type

* improve number handling

* clean up queries

* rename

* handle zero record case

* revert

* rename

* fix division problem

* log clean up

* force adding new rows after removing existing rows

* backfill only missing or wrong data

* remove sleep
2025-08-05 01:40:22 +01:00
Amit Sharma 0829929e98 perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC (#21781)
* perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC

* fix type check

* invalidate cache on webhook list item actions

* fix review comment

* fix merge conflicts
2025-08-05 01:25:31 +01:00
a61148cb51 test: add no show webhooks integration tests (#21697)
* test: add no show webhooks integration tests

* fix

* chore

* chore

* chore

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-08-05 01:22:44 +01:00
Eunjae Lee 593c48c8b4 refactor: replace tremor with recharts (#22791)
* refactor: replace tremor with recharts

* update tooltip

* clean up types

* replace tremor with recharts

* replace BarList with recharts

* replace ProgressBar

* remove tremor from the repository

* clean up

* fix UserStatsTable

* fix type error

* add explicit return type
2025-08-05 01:21:23 +01:00
Lauris Skraucis fb364971fa fix: atoms fonts for non next apps (#22886)
* fix: atoms fonts for non next apps

* chore: add changeset
2025-08-04 16:51:05 +00:00
Eunjae LeeandAlex van Andel a2205d497d feat: add booking status filter to /insights (#22842)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-04 13:48:17 +00:00
Eunjae LeeandDevanshu Sharma cf4e7a5d2c refactor: add useInsightsRoutingParameters() for InsightsRoutingService (#22792)
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-08-04 14:51:23 +02:00
Anik Dhabal Babu 0e2ee3adb2 chore: add logging for office365 calendar (#22867) 2025-08-04 12:26:57 +00:00
Anik Dhabal Babu dbe8a85cf5 fix: enable auto reording for organizer (#22882) 2025-08-04 12:23:56 +00:00
Anik Dhabal Babu 84210dc1b4 fix: build docs workflow failing in ci (#22883)
* fix: flaky e2e

* fix: build docs failure
2025-08-04 17:36:35 +05:30
Benny Joo 4061bfb3fe fix: refactor i18n loadTranslations and set timeout to 3s (#22878)
* refactor

* use abort controller

* address comment

* fix tests

* fix time

* fix tests

* fix platform library build
2025-08-04 10:30:18 +01:00
451 changed files with 22785 additions and 4198 deletions
+9
View File
@@ -0,0 +1,9 @@
---
"@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
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
feat: style calendar settings and availability overrides
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
booker atom: allow toggling org and team info when booking round robin
+5 -1
View File
@@ -1 +1,5 @@
module.exports = require("./packages/config/eslint-preset");
// This configuration only applies to the package manager root.
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["./packages/config/eslint-preset.js"],
};
+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
+1
View File
@@ -22,6 +22,7 @@ env:
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
jobs:
e2e:
timeout-minutes: 20
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+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"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+4 -3
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { z } from "zod";
import { getUserAvailability } from "@calcom/lib/getUserAvailability";
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";
@@ -191,8 +191,9 @@ const availabilitySchema = z
async function handler(req: NextApiRequest) {
const { isSystemWideAdmin, userId: reqUserId } = req;
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
const userAvailabilityService = getUserAvailabilityService();
if (!teamId)
return getUserAvailability({
return userAvailabilityService.getUserAvailability({
username,
dateFrom,
dateTo,
@@ -230,7 +231,7 @@ async function handler(req: NextApiRequest) {
const availabilities = members.map(async (user) => {
return {
userId: user.id,
availability: await getUserAvailability({
availability: await userAvailabilityService.getUserAvailability({
userId: user.id,
dateFrom,
dateTo,
+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: [],
}),
}),
}));
+6 -4
View File
@@ -42,15 +42,17 @@ $ yarn prisma generate
Copy `.env.example` to `.env` and fill values.
## Add license Key to deployments table in DB
## Add license Key to Deployment table in DB
id, logo theme licenseKey agreedLicenseAt
1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611'
id, logo, theme, licenseKey, agreedLicenseAt:-
1, null, null, '00000000-0000-0000-0000-000000000000', '2023-05-15 21:39:47.611'
Replace with your actual license key.
your CALCOM_LICENSE_KEY env var need to contain the same value
.env
CALCOM_LICENSE_KEY=c4234812-12ab-42s6-a1e3-55bedd4a5bb
CALCOM_LICENSE_KEY=00000000-0000-0000-0000-000000000000
## Running the app
+3 -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.276",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.300",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -98,6 +98,8 @@
"@types/luxon": "^3.3.7",
"@types/passport-jwt": "^3.0.13",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^6",
"@typescript-eslint/parser": "^6",
"jest": "^29.7.0",
"jest-date-mock": "^1.0.10",
"node-mocks-http": "^1.16.2",
@@ -104,10 +104,11 @@ export class OutputBookingsService_2024_08_13 {
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled
? rescheduledToInfo?.rescheduledBy
: databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -157,13 +158,12 @@ export class OutputBookingsService_2024_08_13 {
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
return {
uid: rescheduledTo?.uid,
rescheduledBy: rescheduledTo?.rescheduledBy
return {
uid: rescheduledTo?.uid,
rescheduledBy: rescheduledTo?.rescheduledBy,
};
}
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
if (databaseMetadata === null) return {};
@@ -279,9 +279,11 @@ export class OutputBookingsService_2024_08_13 {
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const rescheduledByEmail = databaseBooking.rescheduled
? rescheduledToInfo?.rescheduledBy
: databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -0,0 +1,178 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CreatePrivateLinkInput } from "@calcom/platform-types";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { randomString } from "test/utils/randomString";
describe("Event Types Private Links Endpoints", () => {
let app: INestApplication;
let oAuthClient: any;
let organization: any;
let userRepositoryFixture: UserRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let user: any;
let eventType: any;
const userEmail = `private-links-user-${randomString()}@api.com`;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({
name: `private-links-organization-${randomString()}`,
slug: `private-links-org-slug-${randomString()}`,
});
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
name: `private-links-user-${randomString()}`,
username: `private-links-user-${randomString()}`,
});
// create an event type owned by user
eventType = await eventTypesRepositoryFixture.create(
{
title: `private-links-event-type-${randomString()}`,
slug: `private-links-event-type-${randomString()}`,
length: 30,
locations: [],
},
user.id
);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("POST /v2/event-types/:eventTypeId/private-links - create private link", async () => {
const body: CreatePrivateLinkInput = {
expiresAt: undefined,
maxUsageCount: 5,
};
const response = await request(app.getHttpServer())
.post(`/api/v2/event-types/${eventType.id}/private-links`)
.set("Authorization", `Bearer whatever`)
.send(body)
.expect(201);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.linkId).toBeDefined();
expect(response.body.data.maxUsageCount).toBe(5);
expect(response.body.data.usageCount).toBeDefined();
});
it("GET /v2/event-types/:eventTypeId/private-links - list private links", async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}/private-links`)
.set("Authorization", `Bearer whatever`)
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThanOrEqual(1);
});
it("PATCH /v2/event-types/:eventTypeId/private-links/:linkId - update private link", async () => {
// create a link first
const createResp = await request(app.getHttpServer())
.post(`/api/v2/event-types/${eventType.id}/private-links`)
.set("Authorization", `Bearer whatever`)
.send({ maxUsageCount: 3 })
.expect(201);
const linkId = createResp.body.data.linkId;
const response = await request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}/private-links/${linkId}`)
.set("Authorization", `Bearer whatever`)
.send({ maxUsageCount: 10 })
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.maxUsageCount).toBe(10);
});
it("DELETE /v2/event-types/:eventTypeId/private-links/:linkId - delete private link", async () => {
// create a link to delete
const createResp = await request(app.getHttpServer())
.post(`/api/v2/event-types/${eventType.id}/private-links`)
.set("Authorization", `Bearer whatever`)
.send({ maxUsageCount: 2 })
.expect(201);
const linkId = createResp.body.data.linkId;
const response = await request(app.getHttpServer())
.delete(`/api/v2/event-types/${eventType.id}/private-links/${linkId}`)
.set("Authorization", `Bearer whatever`)
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.linkId).toBe(linkId);
});
afterAll(async () => {
// cleanup created entities
try {
if (eventType?.id) {
const repo = new EventTypesRepositoryFixture((app as any).select(AppModule));
await repo.delete(eventType.id);
}
} catch {}
await app.close();
});
});
@@ -0,0 +1,123 @@
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags, OmitType } from "@nestjs/swagger";
import {
EVENT_TYPE_READ,
EVENT_TYPE_WRITE,
SUCCESS_STATUS,
} from "@calcom/platform-constants";
import {
CreatePrivateLinkInput,
CreatePrivateLinkOutput,
DeletePrivateLinkOutput,
GetPrivateLinksOutput,
UpdatePrivateLinkInput,
UpdatePrivateLinkOutput,
} from "@calcom/platform-types";
import { PrivateLinksService } from "../services/private-links.service";
class UpdatePrivateLinkBody extends OmitType(UpdatePrivateLinkInput, ["linkId"] as const) {}
@Controller({
path: "/v2/event-types/:eventTypeId/private-links",
})
@UseGuards(PermissionsGuard)
@DocsTags("Event Types Private Links")
export class EventTypesPrivateLinksController {
constructor(private readonly privateLinksService: PrivateLinksService) {}
@Post("/")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Create a private link for an event type" })
async createPrivateLink(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: CreatePrivateLinkInput,
@GetUser("id") userId: number
): Promise<CreatePrivateLinkOutput> {
const privateLink = await this.privateLinksService.createPrivateLink(eventTypeId, userId, body);
return {
status: SUCCESS_STATUS,
data: privateLink,
};
}
@Get("/")
@Permissions([EVENT_TYPE_READ])
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
@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
): Promise<GetPrivateLinksOutput> {
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId, userId);
return {
status: SUCCESS_STATUS,
data: privateLinks,
};
}
@Patch("/:linkId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Update a private link for an event type" })
async updatePrivateLink(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string,
@Body() body: UpdatePrivateLinkBody,
@GetUser("id") userId: number
): Promise<UpdatePrivateLinkOutput> {
const updateInput = { ...body, linkId };
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, userId, updateInput);
return {
status: SUCCESS_STATUS,
data: privateLink,
};
}
@Delete("/:linkId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Delete a private link for an event type" })
async deletePrivateLink(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string,
@GetUser("id") userId: number
): Promise<DeletePrivateLinkOutput> {
await this.privateLinksService.deletePrivateLink(eventTypeId, userId, linkId);
return {
status: SUCCESS_STATUS,
data: {
linkId,
message: "Private link deleted successfully",
},
};
}
}
@@ -0,0 +1,27 @@
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 { EventTypesPrivateLinksController } from "./controllers/event-types-private-links.controller";
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],
controllers: [EventTypesPrivateLinksController],
providers: [
PrivateLinksService,
PrivateLinksInputService,
PrivateLinksOutputService,
PrivateLinksRepository,
EventTypeOwnershipGuard,
],
})
export class EventTypesPrivateLinksModule {}
@@ -0,0 +1,54 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class PrivateLinksRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async listByEventTypeId(eventTypeId: number) {
return this.dbRead.prisma.hashedLink.findMany({
where: { eventTypeId },
select: { link: true, expiresAt: true, maxUsageCount: true, usageCount: true },
});
}
async findWithEventTypeDetails(linkId: string) {
return this.dbRead.prisma.hashedLink.findUnique({
where: { link: linkId },
select: {
link: true,
expiresAt: true,
maxUsageCount: true,
usageCount: true,
},
});
}
async create(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
return this.dbWrite.prisma.hashedLink.create({
data: {
eventTypeId,
link: link.link,
expiresAt: link.expiresAt,
...(typeof link.maxUsageCount === "number" ? { maxUsageCount: link.maxUsageCount } : {}),
},
});
}
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: {
expiresAt: link.expiresAt,
...(typeof link.maxUsageCount === "number" ? { maxUsageCount: link.maxUsageCount } : {}),
},
});
}
async delete(eventTypeId: number, linkId: string) {
return this.dbWrite.prisma.hashedLink.deleteMany({ where: { eventTypeId, link: linkId } });
}
}
@@ -0,0 +1,34 @@
import { Injectable } from "@nestjs/common";
import { CreatePrivateLinkInput, UpdatePrivateLinkInput } from "@calcom/platform-types";
@Injectable()
export class PrivateLinksInputService {
constructor() {}
transformCreateInput(input: CreatePrivateLinkInput): CreatePrivateLinkInput {
const hasExpires = input.expiresAt !== undefined && input.expiresAt !== null;
const hasMaxCount = typeof input.maxUsageCount === "number";
if (!hasExpires && !hasMaxCount) {
throw new Error("Either expiresAt or maxUsageCount must be provided");
}
if (hasExpires && hasMaxCount) {
throw new Error("Provide only one of expiresAt or maxUsageCount");
}
return {
expiresAt: input.expiresAt,
maxUsageCount: input.maxUsageCount ?? (hasMaxCount ? input.maxUsageCount : undefined),
};
}
transformUpdateInput(input: UpdatePrivateLinkInput): UpdatePrivateLinkInput {
return {
linkId: input.linkId,
expiresAt: input.expiresAt,
maxUsageCount: input.maxUsageCount,
};
}
}
@@ -0,0 +1,43 @@
import { Injectable } from "@nestjs/common";
import { plainToClass } from "class-transformer";
export type PrivateLinkData = {
id: string;
eventTypeId: number;
isExpired: boolean;
bookingUrl: string;
expiresAt?: Date | null;
maxUsageCount?: number | null;
usageCount?: number;
};
import { PrivateLinkOutput, TimeBasedPrivateLinkOutput, UsageBasedPrivateLinkOutput } from "@calcom/platform-types";
@Injectable()
export class PrivateLinksOutputService {
constructor() {}
transformToOutput(data: PrivateLinkData): PrivateLinkOutput {
const baseData = {
linkId: data.id.toString(),
eventTypeId: data.eventTypeId,
isExpired: data.isExpired,
bookingUrl: data.bookingUrl,
};
if (data.expiresAt !== null && data.expiresAt !== undefined) {
return plainToClass(TimeBasedPrivateLinkOutput, { ...baseData, expiresAt: data.expiresAt });
}
return plainToClass(UsageBasedPrivateLinkOutput, {
...baseData,
maxUsageCount: data.maxUsageCount || 0,
usageCount: data.usageCount || 0,
});
}
transformArrayToOutput(data: PrivateLinkData[]): PrivateLinkOutput[] {
return data.map((item) => this.transformToOutput(item));
}
}
@@ -0,0 +1,128 @@
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 { 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";
@Injectable()
export class PrivateLinksService {
constructor(
private readonly inputService: PrivateLinksInputService,
private readonly outputService: PrivateLinksOutputService,
private readonly repo: PrivateLinksRepository
) {}
async createPrivateLink(
eventTypeId: number,
userId: number,
input: CreatePrivateLinkInput
): Promise<PrivateLinkOutput> {
try {
const transformedInput = this.inputService.transformCreateInput(input);
const created = await this.repo.create(eventTypeId, {
link: generateHashedLink(userId),
expiresAt: transformedInput.expiresAt ?? null,
maxUsageCount: transformedInput.maxUsageCount ?? null,
});
const mapped: PrivateLinkData = {
id: created.link,
eventTypeId,
isExpired: isLinkExpired(created as any),
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${created.link}`,
expiresAt: created.expiresAt ?? null,
maxUsageCount: (created as any).maxUsageCount ?? null,
usageCount: (created as any).usageCount ?? 0,
};
return this.outputService.transformToOutput(mapped);
} catch (error) {
if (error instanceof Error) {
throw new BadRequestException(error.message);
}
throw new BadRequestException("Failed to create private link");
}
}
async getPrivateLinks(eventTypeId: number, userId: number): Promise<PrivateLinkOutput[]> {
try {
const links = await this.repo.listByEventTypeId(eventTypeId);
const mapped: PrivateLinkData[] = links.map((l) => ({
id: l.link,
eventTypeId,
isExpired: isLinkExpired(l as any),
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${l.link}`,
expiresAt: l.expiresAt ?? null,
maxUsageCount: l.maxUsageCount ?? null,
usageCount: l.usageCount ?? 0,
}));
return this.outputService.transformArrayToOutput(mapped);
} catch (error) {
if (error instanceof Error) {
throw new BadRequestException(error.message);
}
throw new BadRequestException("Failed to get private links");
}
}
async updatePrivateLink(
eventTypeId: number,
userId: number,
input: UpdatePrivateLinkInput
): Promise<PrivateLinkOutput> {
try {
const transformedInput = this.inputService.transformUpdateInput(input);
const updatedResult = await this.repo.update(eventTypeId, {
link: transformedInput.linkId,
expiresAt: transformedInput.expiresAt ?? null,
maxUsageCount: transformedInput.maxUsageCount ?? null,
});
if (!updatedResult || (updatedResult as any).count === 0) {
throw new NotFoundException("Updated link not found");
}
const updated = await this.repo.findWithEventTypeDetails(transformedInput.linkId);
if (!updated) throw new NotFoundException("Updated link not found");
const mapped: PrivateLinkData = {
id: updated.link,
eventTypeId,
isExpired: isLinkExpired(updated as any),
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${updated.link}`,
expiresAt: updated.expiresAt ?? null,
maxUsageCount: updated.maxUsageCount ?? null,
usageCount: updated.usageCount ?? 0,
};
return this.outputService.transformToOutput(mapped);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("not found")) {
throw new NotFoundException(error.message);
}
throw new BadRequestException(error.message);
}
throw new BadRequestException("Failed to update private link");
}
}
async deletePrivateLink(eventTypeId: number, userId: number, linkId: string): Promise<void> {
try {
const { count } = await this.repo.delete(eventTypeId, linkId);
if (count === 0) {
throw new NotFoundException("Deleted link not found");
}
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("not found")) {
throw new NotFoundException(error.message);
}
throw new BadRequestException(error.message);
}
throw new BadRequestException("Failed to delete private link");
}
}
}
@@ -290,7 +290,10 @@ describe("Event types Endpoints", () => {
expect(responseBookingFields).toBeDefined();
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
expect(responseBookingField).toEqual(bookingFields[0]);
const fields = responseBookingField
//@ts-ignore
delete fields.labelAsSafeHtml
expect(fields).toEqual(bookingFields[0]);
eventType.bookingFields = responseBookingFields;
});
});
@@ -6,6 +6,7 @@ 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";
@@ -41,6 +42,7 @@ import {
GetEventTypesQuery_2024_06_14,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
} from "@calcom/platform-types";
@Controller({
@@ -14,6 +14,7 @@ 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";
@@ -35,6 +36,7 @@ import { Module } from "@nestjs/common";
SlotsModule_2024_09_04,
TeamsModule,
RoutingFormsModule,
EventTypesPrivateLinksModule,
],
})
export class PlatformEndpointsModule implements NestModule {
@@ -8,9 +8,14 @@ import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { 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";
import { Module } from "@nestjs/common";
@Module({
@@ -24,10 +29,15 @@ import { Module } from "@nestjs/common";
PrismaEventTypeRepository,
PrismaRoutingFormResponseRepository,
PrismaTeamRepository,
RedisService,
PrismaFeaturesRepository,
CheckBookingLimitsService,
CacheService,
AvailableSlotsService,
UserAvailabilityService,
BusyTimesService,
FilterHostsService,
QualifiedHostsService,
],
exports: [AvailableSlotsService],
})
@@ -7,12 +7,17 @@ import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.rep
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { 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";
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
import { UserAvailabilityService } from "./user-availability.service";
@Injectable()
export class AvailableSlotsService extends BaseAvailableSlotsService {
constructor(
@@ -24,7 +29,13 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
selectedSlotRepository: PrismaSelectedSlotRepository,
eventTypeRepository: PrismaEventTypeRepository,
userRepository: PrismaUserRepository,
featuresRepository: PrismaFeaturesRepository
redisService: RedisService,
featuresRepository: PrismaFeaturesRepository,
qualifiedHostsService: QualifiedHostsService,
checkBookingLimitsService: CheckBookingLimitsService,
cacheService: CacheService,
userAvailabilityService: UserAvailabilityService,
busyTimesService: BusyTimesService
) {
super({
oooRepo: oooRepoDependency,
@@ -35,8 +46,13 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
selectedSlotRepo: selectedSlotRepository,
eventTypeRepo: eventTypeRepository,
userRepo: userRepository,
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
cacheService: new CacheService(featuresRepository),
redisClient: redisService,
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 { BusyTimesService as BaseBusyTimesService } from "@calcom/platform-libraries/slots";
@Injectable()
export class BusyTimesService extends BaseBusyTimesService {
constructor(bookingRepository: PrismaBookingRepository) {
super({
bookingRepo: bookingRepository,
});
}
}
@@ -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,
});
}
}
@@ -0,0 +1,24 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { UserAvailabilityService as BaseUserAvailabilityService } from "@calcom/platform-libraries/schedules";
@Injectable()
export class UserAvailabilityService extends BaseUserAvailabilityService {
constructor(
oooRepoDependency: PrismaOOORepository,
bookingRepository: PrismaBookingRepository,
eventTypeRepository: PrismaEventTypeRepository,
redisService: RedisService
) {
super({
oooRepo: oooRepoDependency,
bookingRepo: bookingRepository,
eventTypeRepo: eventTypeRepository,
redisClient: redisService,
});
}
}
@@ -403,6 +403,10 @@ export class BillingService implements OnModuleDestroy {
fromReschedule?: string | null;
}
) {
if (this.configService.get("e2e")) {
return true;
}
const { uid, startTime, fromReschedule } = booking;
const delay = startTime.getTime() - Date.now();
@@ -427,6 +431,9 @@ export class BillingService implements OnModuleDestroy {
* Removing an attendee from a booking does not cancel the usage increment job.
*/
async cancelUsageByBookingUid(bookingUid: string) {
if (this.configService.get("e2e")) {
return true;
}
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
if (job) {
await job.remove();
@@ -458,6 +465,7 @@ export class BillingService implements OnModuleDestroy {
async onModuleDestroy() {
try {
await this.billingQueue.close();
} catch (err) {
this.logger.error(err);
@@ -0,0 +1,44 @@
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import {
BadRequestException,
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
@Injectable()
export class EventTypeOwnershipGuard implements CanActivate {
constructor(private readonly eventTypesService: EventTypesService_2024_06_14) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const user = request.user as ApiAuthGuardUser | undefined;
const eventTypeIdParam = request.params?.eventTypeId;
if (!user) {
throw new ForbiddenException("EventTypeOwnershipGuard - No user associated with the request.");
}
if (!eventTypeIdParam) {
throw new BadRequestException("Missing eventTypeId param.");
}
const eventTypeId = Number(eventTypeIdParam);
if (!Number.isInteger(eventTypeId) || eventTypeId <= 0) {
throw new BadRequestException("Invalid eventTypeId param.");
}
const eventType = await this.eventTypesService.getUserEventType(user.id, eventTypeId);
if (!eventType) {
// Mirrors EventTypesService behavior: NotFound when not owned or not present
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
return true;
}
}
@@ -7,12 +7,32 @@ import { Redis } from "ioredis";
export class RedisService implements OnModuleDestroy {
public redis: Redis;
private readonly logger = new Logger("RedisService");
private isReady = false; // Track connection status
constructor(readonly configService: ConfigService<AppConfig>) {
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
this.redis = new Redis(dbUrl);
this.redis.on("error", (err) => {
this.logger.error(`IoRedis connection error: ${err.message}`);
this.isReady = false;
});
this.redis.on("connect", () => {
this.logger.log("IoRedis connected!");
this.isReady = true;
});
this.redis.on("reconnecting", (delay: string) => {
this.logger.warn(`IoRedis reconnecting... next retry in ${delay}ms`);
});
this.redis.on("end", () => {
this.logger.warn("IoRedis connection ended.");
this.isReady = false;
});
}
async onModuleDestroy() {
@@ -22,4 +42,99 @@ export class RedisService implements OnModuleDestroy {
this.logger.error(err);
}
}
async get<TData>(key: string): Promise<TData | null> {
let data = null;
if (!this.isReady) {
return null;
}
try {
data = await this.redis.get(key);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis get failed: ${err.message}`);
}
if (data === null) {
return null;
}
try {
return JSON.parse(data) as TData;
} catch (e) {
return data as TData;
}
}
async del(key: string): Promise<number> {
if (!this.isReady) {
return 0;
}
try {
return this.redis.del(key);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis del failed: ${err.message}`);
return 0;
}
}
async set<TData>(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> {
if (!this.isReady) {
return null;
}
try {
const stringifiedValue = typeof value === "object" ? JSON.stringify(value) : String(value);
if (opts?.ttl) {
await this.redis.set(key, stringifiedValue, "PX", opts.ttl);
} else {
await this.redis.set(key, stringifiedValue);
}
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis set failed: ${err.message}`);
return null;
}
return "OK";
}
async expire(key: string, seconds: number): Promise<0 | 1> {
if (!this.isReady) {
return 0;
}
try {
return this.redis.expire(key, seconds) as Promise<0 | 1>;
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis expire failed: ${err.message}`);
return 0;
}
}
async lrange<TResult = string>(key: string, start: number, end: number): Promise<TResult[]> {
if (!this.isReady) {
return [];
}
try {
const results = await this.redis.lrange(key, start, end);
return results.map((item) => JSON.parse(item) as TResult);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis lrange failed: ${err.message}`);
return [];
}
}
async lpush<TData>(key: string, ...elements: TData[]): Promise<number> {
if (!this.isReady) {
return 0;
}
try {
const stringifiedElements = elements.map((element) =>
typeof element === "object" ? JSON.stringify(element) : String(element)
);
return this.redis.lpush(key, ...stringifiedElements);
} catch (err) {
if (err instanceof Error) this.logger.error(`IoRedis lpush failed: ${err.message}`);
return 0;
}
}
}
@@ -10,6 +10,7 @@ export const SMS_ATTENDEE = "sms_attendee";
export const SMS_NUMBER = "sms_number";
export const WHATSAPP_ATTENDEE = "whatsapp_attendee";
export const WHATSAPP_NUMBER = "whatsapp_number";
export const CAL_AI_PHONE_CALL = "cal_ai_phone_call";
export const STEP_ACTIONS = [
EMAIL_HOST,
@@ -19,6 +20,7 @@ export const STEP_ACTIONS = [
SMS_NUMBER,
WHATSAPP_ATTENDEE,
WHATSAPP_NUMBER,
CAL_AI_PHONE_CALL,
] as const;
export const STEP_ACTIONS_TO_ENUM = {
@@ -29,6 +31,7 @@ export const STEP_ACTIONS_TO_ENUM = {
[WHATSAPP_ATTENDEE]: WorkflowActions.WHATSAPP_ATTENDEE,
[WHATSAPP_NUMBER]: WorkflowActions.WHATSAPP_NUMBER,
[SMS_NUMBER]: WorkflowActions.SMS_NUMBER,
[CAL_AI_PHONE_CALL]: WorkflowActions.CAL_AI_PHONE_CALL,
} as const;
export const ENUM_TO_STEP_ACTIONS = {
@@ -39,6 +42,7 @@ export const ENUM_TO_STEP_ACTIONS = {
[WorkflowActions.WHATSAPP_ATTENDEE]: WHATSAPP_ATTENDEE,
[WorkflowActions.WHATSAPP_NUMBER]: WHATSAPP_NUMBER,
[WorkflowActions.SMS_NUMBER]: SMS_NUMBER,
[WorkflowActions.CAL_AI_PHONE_CALL]: CAL_AI_PHONE_CALL,
} as const;
export type StepAction = (typeof STEP_ACTIONS)[number];
+446 -15
View File
@@ -9893,6 +9893,198 @@
]
}
},
"/v2/event-types/{eventTypeId}/private-links": {
"post": {
"operationId": "EventTypesPrivateLinksController_createPrivateLink",
"summary": "Create a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"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/CreatePrivateLinkInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
},
"get": {
"operationId": "EventTypesPrivateLinksController_getPrivateLinks",
"summary": "Get all private links for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"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"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetPrivateLinksOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
}
},
"/v2/event-types/{eventTypeId}/private-links/{linkId}": {
"patch": {
"operationId": "EventTypesPrivateLinksController_updatePrivateLink",
"summary": "Update a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"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/UpdatePrivateLinkBody"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
},
"delete": {
"operationId": "EventTypesPrivateLinksController_deletePrivateLink",
"summary": "Delete a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"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"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeletePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
}
},
"/v2/organizations/{orgId}/organizations": {
"post": {
"operationId": "OrganizationsOrganizationsController_createOrganization",
@@ -21856,7 +22048,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
]
},
"recipient": {
@@ -22218,7 +22411,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_address",
"description": "Action to perform, send an email to a specific email address",
@@ -22303,7 +22497,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_attendee",
"description": "Action to perform, send an email to the attendees of the event",
@@ -22379,7 +22574,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_host",
"description": "Action to perform, send an email to the host of the event",
@@ -22474,7 +22670,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_attendee",
"description": "Action to perform",
@@ -22543,7 +22740,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_number",
"description": "Action to perform, send a text message via whatsapp to a specific phone number",
@@ -22621,7 +22819,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_number",
"description": "Action to perform, send a text message to a specific phone number",
@@ -22699,7 +22898,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_attendee",
"description": "Action to perform, send a text message to the phone numbers of the attendees",
@@ -22888,7 +23088,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_address",
"description": "Action to perform, send an email to a specific email address",
@@ -22978,7 +23179,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_attendee",
"description": "Action to perform, send an email to the attendees of the event",
@@ -23059,7 +23261,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_host",
"description": "Action to perform, send an email to the host of the event",
@@ -23140,7 +23343,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_attendee",
"description": "Action to perform, send a text message to the phone numbers of the attendees",
@@ -23218,7 +23422,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_number",
"description": "Action to perform, send a text message via whatsapp to a specific phone number",
@@ -23301,7 +23506,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_attendee",
"description": "Action to perform",
@@ -23375,7 +23581,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_number",
"description": "Action to perform, send a text message to a specific phone number",
@@ -28012,6 +28219,230 @@
"data"
]
},
"CreatePrivateLinkInput": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string",
"description": "Expiration date for time-based links",
"format": "date-time",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
"example": 10,
"minimum": 1,
"default": 1
}
}
},
"TimeBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"expiresAt": {
"type": "string",
"description": "Expiration date for this time-based link",
"format": "date-time",
"example": "2025-12-31T23:59:59.000Z"
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"expiresAt"
]
},
"UsageBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times this link can be used",
"example": 10
},
"usageCount": {
"type": "number",
"description": "Current usage count for this link",
"example": 3
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"maxUsageCount",
"usageCount"
]
},
"CreatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Created private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": [
"status",
"data"
]
},
"GetPrivateLinksOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "array",
"description": "Array of private links for the event type (mix of time-based and usage-based)",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
}
},
"required": [
"status",
"data"
]
},
"UpdatePrivateLinkBody": {
"type": "object",
"properties": {
"expiresAt": {
"format": "date-time",
"type": "string",
"description": "New expiration date for time-based links",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "New maximum number of times the link can be used",
"example": 10,
"minimum": 1
}
}
},
"UpdatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Updated private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": [
"status",
"data"
]
},
"DeletePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "object",
"description": "Deleted link information",
"properties": {
"linkId": {
"type": "string",
"example": "abc123def456"
},
"message": {
"type": "string",
"example": "Private link deleted successfully"
}
}
}
},
"required": [
"status",
"data"
]
},
"UserWebhookOutputDto": {
"type": "object",
"properties": {
+1
View File
@@ -36,4 +36,5 @@ process.env = {
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
INTEGRATION_TEST_MODE: "true",
e2e: "true",
SLOTS_CACHE_TTL: "1"
};
+2 -1
View File
@@ -27,7 +27,8 @@
"@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"],
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"],
"@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"],
"@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"]
"@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"],
"@calcom/platform-libraries/private-links": ["../../../packages/platform/libraries/private-links.ts"]
},
"incremental": true,
"skipLibCheck": true,
@@ -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,14 +1,17 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
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";
import { redirect } from "next/navigation";
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 { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/getting-started/[[...step]]/getServerSideProps";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import type { PageProps as ClientPageProps } from "~/getting-started/[[...step]]/onboarding-view";
import Page from "~/getting-started/[[...step]]/onboarding-view";
export const generateMetadata = async ({ params }: ServerPageProps) => {
@@ -23,13 +26,28 @@ export const generateMetadata = async ({ params }: ServerPageProps) => {
);
};
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const props = await getData(context);
return <Page {...props} />;
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userRepo = new UserRepository(prisma);
const meCaller = await createRouterCaller(meRouter);
const [userTeams, user] = await Promise.all([
userRepo.findUserTeams({
id: session.user.id,
}),
meCaller.get(),
]);
if (!userTeams || !user) {
return redirect("/auth/login");
}
return <Page user={user} hasPendingInvites={!!userTeams.teams.find((team) => team.accepted === false)} />;
};
export default ServerPage;
@@ -7,6 +7,7 @@ import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetReq
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { emailSchema } from "@calcom/lib/emailSchema";
import prisma from "@calcom/prisma";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
async function handler(req: NextRequest) {
const body = await parseRequestData(req);
@@ -28,7 +29,7 @@ async function handler(req: NextRequest) {
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: ip,
identifier: piiHasher.hash(ip),
});
try {
+4
View File
@@ -4,13 +4,16 @@ import { NextResponse } from "next/server";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
// This is the callback endpoint for the OIDC provider
// A team must set this endpoint in the OIDC provider's configuration
async function handler(req: NextRequest) {
const log = logger.getSubLogger({ prefix: ["[ODIC auth]"] });
const { searchParams } = req.nextUrl;
const code = searchParams.get("code");
const state = searchParams.get("state");
const tenant = searchParams.get("tenant");
if (!code || !state) {
return NextResponse.json({ message: "Code and state are required" }, { status: 400 });
@@ -30,6 +33,7 @@ async function handler(req: NextRequest) {
return NextResponse.redirect(redirect_url, 302);
} catch (err) {
log.error(`Error authorizing tenant ${tenant}: ${err}`);
const { message, statusCode = 500 } = err as HttpError;
return NextResponse.json({ message }, { status: statusCode });
@@ -5,17 +5,20 @@ import { NextResponse } from "next/server";
import type { OAuthReq } from "@calcom/features/ee/sso/lib/jackson";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import type { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
async function handler(req: NextRequest) {
const log = logger.getSubLogger({ prefix: ["[SAML authorize]"] });
const { oauthController } = await jackson();
const oAuthReq = Object.fromEntries(req.nextUrl.searchParams) as unknown as OAuthReq;
try {
const { redirect_url } = await oauthController.authorize(
Object.fromEntries(req.nextUrl.searchParams) as unknown as OAuthReq
);
const { redirect_url } = await oauthController.authorize(oAuthReq);
return NextResponse.redirect(redirect_url as string, 302);
} catch (err) {
log.error(`Error initaiting SAML login for tenant ${oAuthReq?.tenant}: ${err}`);
const { message, statusCode = 500 } = err as HttpError;
return NextResponse.json({ message }, { status: statusCode });
+20 -5
View File
@@ -2,19 +2,34 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { parseRequestData } from "app/api/parseRequestData";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { uuid } from "short-uuid";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import type { SAMLResponsePayload } from "@calcom/features/ee/sso/lib/jackson";
import logger from "@calcom/lib/logger";
async function handler(req: NextRequest) {
const uid = uuid();
const log = logger.getSubLogger({ prefix: ["[SAML callback]", `trace: ${uid}`] });
const { oauthController } = await jackson();
const { redirect_url } = await oauthController.samlResponse(
(await parseRequestData(req)) as SAMLResponsePayload
);
const requestData = (await parseRequestData(req)) as SAMLResponsePayload;
if (redirect_url) {
return NextResponse.redirect(redirect_url, 302);
try {
const { redirect_url, error } = await oauthController.samlResponse(requestData);
if (redirect_url) {
return NextResponse.redirect(redirect_url, 302);
}
if (error) {
const uid = uuid();
log.error(`Error authenticating user with error ${error} for relayState ${requestData?.RelayState}`);
return NextResponse.json({ message: `Error authorizing user. trace: ${uid}` }, { status: 400 });
}
} catch (error) {
log.error(`Error processing SAML response`, error);
return NextResponse.json({ message: `Error processing SAML response. trace: ${uid}` }, { status: 500 });
}
return NextResponse.json({ message: "No redirect URL provided" }, { status: 400 });
+14 -2
View File
@@ -2,14 +2,26 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { parseRequestData } from "app/api/parseRequestData";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { uuid } from "short-uuid";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import type { OAuthTokenReq } from "@calcom/features/ee/sso/lib/jackson";
import logger from "@calcom/lib/logger";
async function handler(req: NextRequest) {
const { oauthController } = await jackson();
const tokenResponse = await oauthController.token((await parseRequestData(req)) as OAuthTokenReq);
return NextResponse.json(tokenResponse);
const log = logger.getSubLogger({ prefix: ["[SAML token]"] });
const oauthTokenReq = (await parseRequestData(req)) as OAuthTokenReq;
try {
const tokenResponse = await oauthController.token(oauthTokenReq);
return NextResponse.json(tokenResponse);
} catch (error) {
const uid = uuid();
log.error(`Error getting auth token for client id ${oauthTokenReq?.client_id}: ${error} trace: ${uid}`);
throw new Error(`Error getting auth token with error ${error} trace: ${uid}`);
}
}
export const POST = defaultResponderForAppDir(handler);
+23 -5
View File
@@ -1,23 +1,33 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import z from "zod";
import { uuid } from "short-uuid";
import { z } from "zod";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
const extractAuthToken = (req: NextRequest) => {
const log = logger.getSubLogger({ prefix: ["SAML extractAuthToken"] });
const uid = uuid();
const authHeader = req.headers.get("authorization");
const parts = (authHeader || "").split(" ");
if (parts.length > 1) return parts[1];
// check for query param
let arr: string[] = [];
const { access_token } = requestQuery.parse(Object.fromEntries(req.nextUrl.searchParams));
const tokenParse = requestQuery.safeParse(Object.fromEntries(req.nextUrl.searchParams));
let access_token;
if (!tokenParse.success) {
log.error(`Error parsing request query: ${tokenParse.error} trace ${uid}`);
throw new HttpError({ statusCode: 401, message: `Unauthorized trace: ${uid}` });
}
access_token = tokenParse.data.access_token;
arr = arr.concat(access_token);
if (arr[0].length > 0) return arr[0];
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
throw new HttpError({ statusCode: 401, message: `Unauthorized trace: ${uid}` });
};
const requestQuery = z.object({
@@ -25,10 +35,18 @@ const requestQuery = z.object({
});
async function handler(req: NextRequest) {
const log = logger.getSubLogger({ prefix: ["SAML userinfo"] });
const { oauthController } = await jackson();
const token = extractAuthToken(req);
const userInfo = await oauthController.userInfo(token);
return NextResponse.json(userInfo);
try {
const userInfo = await oauthController.userInfo(token);
return NextResponse.json(userInfo);
} catch (error) {
const uid = uuid();
log.error(`trace: ${uid} Error getting user info from token: ${error}`);
throw new Error(`Error getting user info from token. trace: ${uid}`);
}
}
export const GET = defaultResponderForAppDir(handler);
@@ -0,0 +1,5 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { handleQueuedFormResponseCleanup } from "@calcom/app-store/routing-forms/cron/queuedFormResponseCleanup";
export const POST = defaultResponderForAppDir(handleQueuedFormResponseCleanup);
+18 -7
View File
@@ -35,20 +35,31 @@ async function handler(req: NextRequest) {
const imageType = searchParams.get("type");
try {
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
const fontResults = await Promise.allSettled([
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
]);
const fonts: SatoriOptions["fonts"] = [];
if (fontResults[1].status === "fulfilled") {
fonts.push({ name: "inter", data: fontResults[1].value, weight: 400 });
}
if (fontResults[2].status === "fulfilled") {
fonts.push({ name: "inter", data: fontResults[2].value, weight: 500 });
}
if (fontResults[0].status === "fulfilled") {
fonts.push({ name: "cal", data: fontResults[0].value, weight: 400 });
fonts.push({ name: "cal", data: fontResults[0].value, weight: 600 });
}
const ogConfig = {
width: 1200,
height: 630,
fonts: [
{ name: "inter", data: interFontData, weight: 400 },
{ name: "inter", data: interFontMediumData, weight: 500 },
{ name: "cal", data: calFontData, weight: 400 },
{ name: "cal", data: calFontData, weight: 600 },
] as SatoriOptions["fonts"],
fonts,
};
switch (imageType) {
@@ -546,9 +546,7 @@ function BookingListItem(booking: BookingItemProps) {
</Link>
</div>
</div>
<div
data-testid="title-and-attendees"
className={`w-full px-4${isRejected ? " line-through" : ""}`}>
<div data-testid="title-and-attendees" className={`w-full px-4${isRejected ? "line-through" : ""}`}>
<Link href={bookingLink}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
@@ -893,7 +891,7 @@ const FirstAttendee = ({
) : (
<a
key={user.email}
className=" hover:text-blue-500"
className="hover:text-blue-500"
href={`mailto:${user.email}`}
onClick={(e) => e.stopPropagation()}>
{user.name || user.email}
@@ -1081,7 +1079,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
/>
))}
<DropdownMenuSeparator />
<div className="flex justify-end p-2 ">
<div className="flex justify-end p-2">
<Button
data-testid="update-no-show"
color="secondary"
@@ -1204,7 +1202,7 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<div className="flex justify-end space-x-2 p-2 ">
<div className="flex justify-end space-x-2 p-2">
<Link href={`mailto:${selectedEmail}`}>
<Button
color="secondary"
@@ -278,13 +278,14 @@ describe("Booking Actions", () => {
expect(rerouteAction).toBeDefined();
});
it("should include reassign action for round robin events", () => {
it("should include reassign action for round robin events with no host groups", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.ROUND_ROBIN,
hostGroups: [],
},
},
});
@@ -294,6 +295,60 @@ describe("Booking Actions", () => {
expect(reassignAction).toBeDefined();
});
it("should include reassign action for round robin events with one host group", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.ROUND_ROBIN,
hostGroups: [{ id: "group-1", name: "Group 1" }],
},
},
});
const actions = getEditEventActions(context);
const reassignAction = actions.find((a) => a.id === "reassign");
expect(reassignAction).toBeDefined();
});
it("should exclude reassign action for round robin events with more than one host groups", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.ROUND_ROBIN,
hostGroups: [
{ id: "group-1", name: "Group 1" },
{ id: "group-2", name: "Group 2" },
],
},
},
});
const actions = getEditEventActions(context);
const reassignAction = actions.find((a) => a.id === "reassign");
expect(reassignAction).toBeUndefined();
});
it("should exclude reassign action for non-round-robin events", () => {
const context = createMockContext({
booking: {
...createMockContext().booking,
eventType: {
...createMockContext().booking.eventType,
schedulingType: SchedulingType.COLLECTIVE,
hostGroups: [],
},
},
});
const actions = getEditEventActions(context);
const reassignAction = actions.find((a) => a.id === "reassign");
expect(reassignAction).toBeUndefined();
});
it("should exclude add_members when guests are disabled", () => {
const context = createMockContext({
booking: {
@@ -146,8 +146,9 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
icon: "user-plus",
disabled: false,
},
// Reassign (if round robin)
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN
// Reassign if round robin with no or one host groups
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
(!booking.eventType.hostGroups || booking.eventType.hostGroups?.length <= 1)
? {
id: "reassign",
label: t("reassign"),
@@ -144,7 +144,7 @@ const PlainContactForm = () => {
};
return (
<div className="absolute bottom-4 right-4 z-50">
<div className="fixed bottom-[1rem] right-[1rem] z-50">
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild className="enabled:hover:bg-subtle bg-subtle shadow-none">
<Button
@@ -156,7 +156,7 @@ const PlainContactForm = () => {
<PopoverContent
style={{ maxWidth: "450px", maxHeight: "650px" }}
className="!bg-muted no-scrollbar mr-8 mb-2 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
className="!bg-muted no-scrollbar mb-2 mr-8 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
<div className="flex w-full justify-between">
<p className="mb-5 text-lg font-semibold">Contact support</p>
<Button
@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { showToast } from "@calcom/ui/components/toast";
import PlainContactForm from "../../../lib/plain/PlainContactForm";
import PlainContactForm from "../PlainContactForm";
vi.mock("next-auth/react", () => ({
useSession: vi.fn(),
@@ -124,8 +124,9 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
autoComplete="none"
autoCapitalize="none"
autoCorrect="none"
containerClassName="[&>div]:gap-0"
className={classNames(
"mb-0 mt-0 rounded-md rounded-l-none",
"mb-0 mt-0 rounded-md rounded-l-none pl-0",
markAsError
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
: ""
@@ -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;
};
@@ -1,30 +0,0 @@
import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req } = context;
const session = await getServerSession({ req });
if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
const userRepo = new UserRepository(prisma);
const user = await userRepo.findUserTeams({
id: session.user.id,
});
if (!user) {
throw new Error("User from session not found");
}
return {
props: {
hasPendingInvites: user.teams.find((team) => team.accepted === false) ?? false,
},
};
};
+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 }),
},
});
};
+3 -1
View File
@@ -2,4 +2,6 @@ import dynamic from "next/dynamic";
import { Fragment } from "react";
// Preload caused by dynamic import doesn't seem to add nonce and thus preload fails but the functionality still works - https://github.com/vercel/next.js/issues/81260
export default process.env.NEXT_PUBLIC_PLAIN_CHAT_ID ? dynamic(() => import("./plainChat")) : Fragment;
export default process.env.NEXT_PUBLIC_PLAIN_CHAT_ID
? dynamic(() => import("../../components/plain/plainChat"))
: Fragment;
@@ -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) {
@@ -117,7 +117,7 @@ const checkIfUserIsHost = async ({
});
// If eventType exists, it means user is either owner, host or user
return !eventType;
return !!eventType;
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
@@ -10,16 +10,13 @@ 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";
import { Steps } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import type { getServerSideProps } from "@lib/getting-started/[[...step]]/getServerSideProps";
import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars";
import { ConnectedVideoStep } from "@components/getting-started/steps-views/ConnectedVideoStep";
import { SetupAvailability } from "@components/getting-started/steps-views/SetupAvailability";
@@ -85,13 +82,16 @@ const stepRouteSchema = z.object({
from: z.string().optional(),
});
export type PageProps = inferSSRProps<typeof getServerSideProps>;
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();
@@ -22,7 +22,6 @@ import {
RecentFeedbackTable,
TimezoneBadge,
} from "@calcom/features/insights/components/booking";
import "@calcom/features/insights/components/tremor.css";
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
import { Download } from "@calcom/features/insights/filters/Download";
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
@@ -1,6 +1,5 @@
"use client";
import { ProgressBar } from "@tremor/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useMemo } from "react";
@@ -16,6 +15,7 @@ import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Select } from "@calcom/ui/components/form";
import { TextField, Label, InputError } from "@calcom/ui/components/form";
import { ProgressBar } from "@calcom/ui/components/progress-bar";
import { showToast } from "@calcom/ui/toast";
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
+1 -1
View File
@@ -13,7 +13,7 @@ export const BUTTONS = {
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
},
START_TRANSCRIPTION: {
label: "Cal.ai",
label: "Transcribe",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
@@ -85,7 +85,7 @@ export default function JoinCall(props: PageProps) {
...(showTranscriptionButton
? {
transcription: {
label: "Cal.ai",
label: "Transcribe",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
+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
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.5.10",
"version": "5.5.16",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -76,10 +76,9 @@
"@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^5.17.15",
"@team-plain/typescript-sdk": "^5.9.0",
"@tremor/react": "^2.11.0",
"@types/turndown": "^5.0.1",
"@unkey/ratelimit": "^0.1.1",
"@upstash/redis": "^1.21.0",
"@upstash/redis": "^1.35.2",
"@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1",
"@vercel/og": "^0.6.3",
@@ -139,6 +138,7 @@
"react-use-intercom": "1.5.1",
"recoil": "^0.7.7",
"remove-markdown": "^0.5.0",
"retell-sdk": "^4.40.0",
"rrule": "^2.7.1",
"sanitize-html": "^2.10.0",
"schema-dts": "^1.1.0",
+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()})();
`,
}}
/>
+2 -1
View File
@@ -7,6 +7,7 @@ import getIP from "@calcom/lib/getIP";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { CreationSource } from "@calcom/prisma/enums";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
async function handler(req: NextApiRequest & { userId?: number }) {
const userIp = getIP(req);
@@ -20,7 +21,7 @@ async function handler(req: NextApiRequest & { userId?: number }) {
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: userIp,
identifier: piiHasher.hash(userIp),
});
const session = await getServerSession({ req });
+2 -1
View File
@@ -6,13 +6,14 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE
import getIP from "@calcom/lib/getIP";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { CreationSource } from "@calcom/prisma/enums";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
async function handler(req: NextApiRequest & { userId?: number }) {
const userIp = getIP(req);
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: `instant.event-${userIp}`,
identifier: `instant.event-${piiHasher.hash(userIp)}`,
});
const session = await getServerSession({ req });
+2 -1
View File
@@ -7,6 +7,7 @@ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowE
import getIP from "@calcom/lib/getIP";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
@@ -37,7 +38,7 @@ async function handler(req: NextApiRequest & RequestMeta) {
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: userIp,
identifier: piiHasher.hash(userIp),
});
const session = await getServerSession({ req });
/* To mimic API behavior and comply with types */
@@ -3,9 +3,9 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { ZGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import type { TGetRetellLLMSchema } from "@calcom/features/ee/cal-ai-phone/zod-utils";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
import { ZGetRetellLLMSchema } from "@calcom/features/calAIPhone/zod-utils";
import type { TGetRetellLLMSchema } from "@calcom/features/calAIPhone/zod-utils";
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";
+7 -15
View File
@@ -203,32 +203,24 @@ test.describe("Insights", async () => {
await page.keyboard.press("Escape");
// Choose User filter item from dropdown
await addFilter(page, "bookingUserId");
await addFilter(page, "userId");
// Wait for the URL to include bookingUserId
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
// Wait for the URL to include userId
await page.waitForURL((url) => url.toString().includes("userId"));
// Click User filter to see a user list
await openFilter(page, "bookingUserId");
await openFilter(page, "userId");
await page
.locator('[data-testid="select-filter-options-bookingUserId"]')
.getByRole("option")
.nth(0)
.click();
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click();
await page
.locator('[data-testid="select-filter-options-bookingUserId"]')
.getByRole("option")
.nth(1)
.click();
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click();
// press escape button to close the filter
await page.keyboard.press("Escape");
await clearFilters(page);
await expect(page.url()).not.toContain("bookingUserId");
await expect(page.url()).not.toContain("userId");
});
test("should test download button", async ({ page, users }) => {
+44 -1
View File
@@ -120,7 +120,7 @@
"already_signed_up_for_this_booking_error": "You are already signed up for this booking.",
"hosts_unavailable_for_booking": "Some of the hosts are unavailable for booking.",
"fixed_hosts_unavailable_for_booking": "Some of the fixed hosts are unavailable for booking.",
"round_robin_hosts_unavailable_for_booking": "No Round Robin hosts is available for booking.",
"round_robin_host_unavailable_for_booking": "Round Robin host is unavailable for booking.",
"help": "Help",
"price": "Price",
"paid": "Paid",
@@ -349,6 +349,7 @@
"available_apps_lower_case": "Available apps",
"available_apps_desc": "View popular apps below and explore more in our <0>App Store</0>",
"fixed_host_helper": "Add anyone who needs to attend the event. <0>Learn more</0>",
"round_robin_groups_helper": "People in a group take turns, one person of each group will show up for the event.",
"round_robin_helper": "People in the group take turns and only one person will show up for the event.",
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
"finish": "Finish",
@@ -2251,6 +2252,8 @@
"floating_button_trigger_modal": "Puts a floating button on your site that triggers a modal with your event type.",
"pop_up_element_click": "Pop up via element click",
"open_dialog_with_element_click": "Open your calendar as a dialog when someone clicks an element.",
"use_my_own_form": "Use my own form",
"use_our_headless_routing_api": "Use our headless routing API and use your own form for submissions.",
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
"book_my_cal": "Book my Cal",
"first_name": "First name",
@@ -3286,6 +3289,7 @@
"pbac_resource_booking": "Bookings",
"pbac_resource_insights": "Insights",
"pbac_resource_role": "Roles",
"pbac_resource_workflow": "Workflows",
"pbac_action_all": "All Actions",
"pbac_action_create": "Create",
"pbac_action_read": "View",
@@ -3317,6 +3321,11 @@
"pbac_desc_update_roles": "Update roles",
"pbac_desc_delete_roles": "Delete roles",
"pbac_desc_manage_roles": "All actions on roles across organization teams",
"pbac_desc_create_workflows": "Create and set up new workflows",
"pbac_desc_view_workflows": "View existing workflows and their configurations",
"pbac_desc_update_workflows": "Edit and modify workflow settings",
"pbac_desc_delete_workflows": "Remove workflows from the system",
"pbac_desc_manage_workflows": "Full management access to all workflows",
"pbac_desc_create_event_types": "Create event types",
"pbac_desc_view_event_types": "View event types",
"pbac_desc_update_event_types": "Update event types",
@@ -3354,6 +3363,7 @@
"use_booker_timezone_info": "Apply the selected restriction schedule in the booker's timezone",
"load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types",
"rr_load_balancing_disabled": "Load Balancing is only available when the timestamp basis is set to Booking Creation Time",
"rr_load_balancing_disabled_with_groups": "Load Balancing is not available with Round Robin Groups",
"round_robin_settings_description": "Customize the default round robin settings for this team",
"round_robin_settings_updated_successfully": "Round Robin settings updated successfully",
"booking_creation_time": "Booking Creation Time",
@@ -3370,6 +3380,7 @@
"booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.",
"restriction_schedule_not_found_error": "Restriction schedule not found",
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
"add_group": "Add Group",
"routing_funnel": "Routing Funnel",
"routing_funnel_total_submissions": "Total Submissions",
"routing_funnel_successful_routings": "Successful Routings",
@@ -3402,6 +3413,7 @@
"license_key_saved": "License key saved successfully",
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
"failed_bookings_by_field": "Failed Bookings By Field",
"failed_bookings": "Failed bookings",
"event_type_no_hosts": "No hosts are assigned to event type",
"cache_status": "Cache Status",
"cache_last_updated": "Last updated: {{timestamp}}",
@@ -3429,5 +3441,36 @@
"usage_based_expiration_description": "This link can be used for {{count}} booking",
"usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings",
"usage_based_expiration_description_plural": "This link can be used for {{count}} bookings",
"webhook_trigger_event": "The name of the trigger event (e.g., BOOKING_CREATED, BOOKING_CANCELLED)",
"webhook_created_at": "The time of the webhook",
"webhook_type": "The event type slug",
"webhook_title": "The event type name",
"webhook_start_time": "The event's start time",
"webhook_end_time": "The event's end time",
"webhook_description": "The event's description as described in the event type settings",
"webhook_location": "Location of the event",
"webhook_uid": "The UID of the booking",
"webhook_reschedule_uid": "The UID for rescheduling",
"webhook_cancellation_reason": "Reason for cancellation",
"webhook_rejection_reason": "Reason for rejection",
"webhook_organizer_name": "Name of the organizer",
"webhook_organizer_email": "Email of the organizer",
"webhook_organizer_timezone": "Timezone of the organizer (e.g., 'America/New_York', 'Asia/Kolkata')",
"webhook_organizer_locale": "Locale of the organizer (e.g., 'en', 'fr')",
"webhook_attendee_name": "Name of the first attendee",
"webhook_attendee_email": "Email of the first attendee",
"webhook_attendee_timezone": "Timezone of the first attendee",
"webhook_attendee_locale": "Locale of the first attendee",
"webhook_team_name": "Name of the team booked",
"webhook_team_members": "Members of the team booked",
"webhook_video_call_url": "Video call URL for the meeting",
"webhook_hide_variables": "Hide variables",
"webhook_show_variable": "Show available variables",
"webhook_event_and_booking": "Event and Booking",
"webhook_people": "People",
"webhook_teams": "Teams",
"webhook_metadata": "Metadata",
"stats": "Stats",
"booking_status": "Booking status",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -380,7 +380,7 @@
"connect": "Ühenda",
"try_for_free": "Proovi tasuta",
"create_booking_link_with_calcom": "Looge oma broneerimislink rakendusega {{appName}}",
"who": "WHO",
"who": "Kes",
"what": "Mida",
"when": "Millal",
"where": "Kus",
@@ -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 -5
View File
@@ -2,10 +2,6 @@ const base = require("@calcom/config/tailwind-preset");
/** @type {import('tailwindcss').Config} */
module.exports = {
...base,
content: [
...base.content,
"../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}",
"../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
],
content: [...base.content, "../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}"],
plugins: [...base.plugins, require("tailwindcss-animate")],
};
+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";
+7 -6
View File
@@ -1,4 +1,5 @@
import CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager";
import { constantsScenarios } from "@calcom/lib/__mocks__/constants";
import {
getDate,
@@ -16,19 +17,19 @@ 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";
import { setupAndTeardown } from "./getSchedule/setupAndTeardown";
import { timeTravelToTheBeginningOfToday } from "./getSchedule/utils";
vi.mock("@calcom/lib/constants", () => ({
IS_PRODUCTION: true,
constantsScenarios.set({
IS_PRODUCTION: true as any,
WEBAPP_URL: "http://localhost:3000",
RESERVED_SUBDOMAINS: ["auth", "docs"],
SINGLE_ORG_SLUG: "",
}));
RESERVED_SUBDOMAINS: ["auth", "docs"] as any,
SINGLE_ORG_SLUG: "" as any,
});
describe("getSchedule", () => {
const availableSlotsService = getAvailableSlotsService();
@@ -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";
@@ -26,7 +26,12 @@ vi.mock("@calcom/emails/email-manager", () => {
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: (key: string) => key,
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
@@ -121,6 +121,7 @@ type InputHost = {
userId: number;
isFixed?: boolean;
scheduleId?: number | null;
groupId?: string | null;
};
type InputSelectedSlot = {
@@ -215,6 +216,7 @@ export type InputEventType = {
useEventLevelSelectedCalendars?: boolean;
users?: { id: number }[];
hosts?: InputHost[];
hostGroups?: { id: string; name: string }[];
schedulingType?: SchedulingType;
parent?: { id: number };
beforeEventBuffer?: number;
@@ -290,6 +292,17 @@ export const Timezones = {
async function addHostsToDb(eventTypes: InputEventType[]) {
for (const eventType of eventTypes) {
// Create host groups first if they exist
if (eventType.hostGroups?.length) {
await prismock.hostGroup.createMany({
data: eventType.hostGroups.map((group) => ({
id: group.id, // Preserve the input ID
name: group.name,
eventTypeId: eventType.id,
})),
});
}
if (!eventType.hosts?.length) continue;
for (const host of eventType.hosts) {
const data: Prisma.HostCreateInput = {
@@ -311,6 +324,13 @@ async function addHostsToDb(eventTypes: InputEventType[]) {
},
}
: undefined,
group: host.groupId
? {
connect: {
id: host.groupId,
},
}
: undefined,
};
await prismock.host.create({
@@ -363,6 +383,7 @@ export async function addEventTypesToDb(
workflows: true,
destinationCalendar: true,
schedule: true,
hostGroups: true,
},
});
@@ -473,6 +494,7 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In
workflows: [],
users,
hosts,
hostGroups: eventType.hostGroups || [],
destinationCalendar: eventType.destinationCalendar
? {
create: eventType.destinationCalendar,
+4
View File
@@ -4,6 +4,10 @@
"path": "/api/cron/calendar-cache-cleanup",
"schedule": "0 5 * * *"
},
{
"path": "/api/cron/queuedFormResponseCleanup",
"schedule": "0 */12 * * *"
},
{
"path": "/api/tasks/cron",
"schedule": "* * * * *"
+446 -15
View File
@@ -9893,6 +9893,198 @@
]
}
},
"/v2/event-types/{eventTypeId}/private-links": {
"post": {
"operationId": "EventTypesPrivateLinksController_createPrivateLink",
"summary": "Create a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"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/CreatePrivateLinkInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
},
"get": {
"operationId": "EventTypesPrivateLinksController_getPrivateLinks",
"summary": "Get all private links for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"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"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetPrivateLinksOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
}
},
"/v2/event-types/{eventTypeId}/private-links/{linkId}": {
"patch": {
"operationId": "EventTypesPrivateLinksController_updatePrivateLink",
"summary": "Update a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"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/UpdatePrivateLinkBody"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
},
"delete": {
"operationId": "EventTypesPrivateLinksController_deletePrivateLink",
"summary": "Delete a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"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"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeletePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
}
},
"/v2/organizations/{orgId}/organizations": {
"post": {
"operationId": "OrganizationsOrganizationsController_createOrganization",
@@ -21856,7 +22048,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
]
},
"recipient": {
@@ -22218,7 +22411,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_address",
"description": "Action to perform, send an email to a specific email address",
@@ -22303,7 +22497,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_attendee",
"description": "Action to perform, send an email to the attendees of the event",
@@ -22379,7 +22574,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_host",
"description": "Action to perform, send an email to the host of the event",
@@ -22474,7 +22670,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_attendee",
"description": "Action to perform",
@@ -22543,7 +22740,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_number",
"description": "Action to perform, send a text message via whatsapp to a specific phone number",
@@ -22621,7 +22819,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_number",
"description": "Action to perform, send a text message to a specific phone number",
@@ -22699,7 +22898,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_attendee",
"description": "Action to perform, send a text message to the phone numbers of the attendees",
@@ -22888,7 +23088,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_address",
"description": "Action to perform, send an email to a specific email address",
@@ -22978,7 +23179,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_attendee",
"description": "Action to perform, send an email to the attendees of the event",
@@ -23059,7 +23261,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_host",
"description": "Action to perform, send an email to the host of the event",
@@ -23140,7 +23343,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_attendee",
"description": "Action to perform, send a text message to the phone numbers of the attendees",
@@ -23218,7 +23422,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_number",
"description": "Action to perform, send a text message via whatsapp to a specific phone number",
@@ -23301,7 +23506,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_attendee",
"description": "Action to perform",
@@ -23375,7 +23581,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_number",
"description": "Action to perform, send a text message to a specific phone number",
@@ -28012,6 +28219,230 @@
"data"
]
},
"CreatePrivateLinkInput": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string",
"description": "Expiration date for time-based links",
"format": "date-time",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
"example": 10,
"minimum": 1,
"default": 1
}
}
},
"TimeBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"expiresAt": {
"type": "string",
"description": "Expiration date for this time-based link",
"format": "date-time",
"example": "2025-12-31T23:59:59.000Z"
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"expiresAt"
]
},
"UsageBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times this link can be used",
"example": 10
},
"usageCount": {
"type": "number",
"description": "Current usage count for this link",
"example": 3
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"maxUsageCount",
"usageCount"
]
},
"CreatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Created private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": [
"status",
"data"
]
},
"GetPrivateLinksOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "array",
"description": "Array of private links for the event type (mix of time-based and usage-based)",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
}
},
"required": [
"status",
"data"
]
},
"UpdatePrivateLinkBody": {
"type": "object",
"properties": {
"expiresAt": {
"format": "date-time",
"type": "string",
"description": "New expiration date for time-based links",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "New maximum number of times the link can be used",
"example": 10,
"minimum": 1
}
}
},
"UpdatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Updated private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": [
"status",
"data"
]
},
"DeletePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "object",
"description": "Deleted link information",
"properties": {
"linkId": {
"type": "string",
"example": "abc123def456"
},
"message": {
"type": "string",
"example": "Private link deleted successfully"
}
}
}
},
"required": [
"status",
"data"
]
},
"UserWebhookOutputDto": {
"type": "object",
"properties": {

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