Compare commits

..
189 Commits
Author SHA1 Message Date
GitHub Actions 84717ca73e chore: release v5.3.11
Post release / release (push) Canceled after 0s
2025-06-11 23:50:19 +00:00
86b1db57d5 fix: Resending team invites now updates token expiry date (#21774)
* Create `VerificationTokenRepository`

* Update the verification token expiry when resending the invite

* Handle error

* Fix import

* Get past unique constraint

* Update packages/trpc/server/routers/viewer/teams/resendInvitation.handler.ts

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

* Update packages/lib/server/repository/verificationToken.ts

Update set expiry time

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

* Type fix

* Update how we're fetching the token

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-11 22:21:58 +00:00
Amit Sharmaandcubic-dev-ai[bot] 50c15f0d51 fix: dub installation on teams (#21654)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-11 18:17:55 +02:00
Romit.andEunjae Lee 86d5c43307 fix: select component icons misaligned (#21688)
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-06-11 13:22:00 +00:00
Anik Dhabal Babuandcubic-dev-ai[bot] e82bd45b55 fix: Incorrect RR host is shown in the email and calendar event for a seated RR event (#21684)
* fix: seated rr event

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

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

* update

* Update handleNewBooking.ts

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-06-11 11:21:21 +00:00
13966e0ec8 perf: use repository for queries & caching in /teams RSC (#21399)
* cache trpc queries in /teams

* fix

* revalidate

* refactor: migrate listHandler to TeamRepository.findTeamsByUserId

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

* add comment

* refactor

* refactor

* make it even more performant

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>
2025-06-10 20:44:51 -04:00
GitHub Actions 14b9e0c032 chore: release v5.3.10
Post release / release (push) Canceled after 0s
2025-06-10 22:43:07 +00:00
Anik Dhabal BabuandSyed Ali Shahbaz f0f168e920 fix: improve updatehostineventname function (#21734)
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2025-06-10 21:47:30 +00:00
devin-ai-integration[bot] dd37caf32c feat: move team privacy settings to dedicated settings page (#21590) 2025-06-10 22:23:28 +02:00
Hariom Balhara 856d223535 fix: Use setTimeout instead of requestAnimationFrame which is more reliable and support calOrigin in prerender #21205 (#21730) 2025-06-10 11:00:57 -04:00
Sarthak KapilaandMorgan ad697365ce fix: Add onFormStateChange callback on EventTypeSettings atom [CAL-5856] (#21615)
* Adds onFormStateChange callback on EventTypeSettings atom

* updated docs for event-type

* added watch all form values

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-06-10 12:36:34 +00:00
Morgan 84171928cb chore: enable disable rewrite for apiv2 selfhost (#21742) 2025-06-10 11:04:25 +00:00
Sebastian BrunowandSebastian Brunow e781a78eea fix: Prevent Timing Attacks by Removing await in Password Reset Flow (#21747)
* fix: correct password reset request handling logic

* fix: handle password reset request errors more gracefully

---------

Co-authored-by: Sebastian Brunow <90863358+Bratpfanne-4000@users.noreply.github.com>
2025-06-09 22:44:24 +00:00
Keith Williams 7d08c6724b chore: @Foundation required for embeds changes (#21748) 2025-06-09 23:13:04 +01:00
GitHub Actions 899da51f06 chore: release v5.3.9
Post release / release (push) Canceled after 0s
2025-06-09 21:56:25 +00:00
Joe Au-Yeung 4e085e404a Downgrade Next to 15.3.0 (#21746) 2025-06-09 17:21:02 +00:00
Morgan ff3aaadbaa fix: update delegation credentials api-v2 (#21739)
* fix: update delegation credentials api-v2

* fixup! fix: update delegation credentials api-v2
2025-06-09 11:46:38 +00:00
11bddbb71d feat: add setting to allow booking through a reschedule link (#21652)
* feat: add setting to disable rescheduling cancelled bookings

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

* fix: resolve type errors for disableReschedulingCancelledBookings field

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

* fix: update test expectations and builder to include disableReschedulingCancelledBookings field

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

* fix: add disableReschedulingCancelledBookings field to managed event types

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

* fix: remove duplicate disableReschedulingCancelledBookings property in test

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

* fix: change default value to true for disableReschedulingCancelledBookings

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

* fix: update managed event types to use true as default for disableReschedulingCancelledBookings

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

* test: add comprehensive tests for disableReschedulingCancelledBookings feature

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

* update and remove unnecesarry test

* update e2e test

* Update reschedule.e2e.ts

* update

* fix

* Reverse the meaning of column

* Simpify logic of rescheduling redirects

* fix test

* revert

---------

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: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-06-07 22:30:54 +02:00
devklepacki 55a80ba253 Fixed pl translation typo (#21723) 2025-06-06 13:59:58 +00:00
Hariom Balhara 32a89c30de fix: [object object] for name when rescheduling a past booking (#21695) 2025-06-05 09:42:48 +01:00
Parteek malikandAnik Dhabal Babu 5404eeedc2 fix: no show not updating in sync (#21687)
* fix: no show not updating in sync

* fix footer in no show dialog

* using await

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-06-04 10:14:08 +00:00
Hariom Balhara d54ba90304 fix-wrong-url-on-auth-error (#21693) 2025-06-04 09:55:19 +05:30
Omar López ca206915bf fix: self hosted feature check (#21690) 2025-06-03 17:00:48 +00:00
Anik Dhabal Babu 8461b82f0d fix: Update handlers and refactor code (#21686)
* fix: update safeCredentialSelect

* Update credential.ts

* Update

* update
2025-06-03 15:32:40 +00:00
Alex van Andel 07ffc3fade fix: Remove hard dependency on NEXT_PUBLIC_API_V2_URL (#21682)
* fix: Remove hard dependency on NEXT_PUBLIC_API_V2_URL

* turn spread into a push, simpler
2025-06-02 19:21:38 +01:00
sean-brydon c2240f0bad only use event api v2 call if route is in env (#21680) 2025-06-02 17:31:35 +00:00
Zenrac 36a712f979 fix: Correct typo 'siège' to 'place' in French translation (#21671) 2025-06-02 08:54:24 +05:30
Lauris Skraucis 842ba09896 refactor: v2 reschedule booking error messages (#21647) 2025-05-30 13:24:06 +00:00
devin-ai-integration[bot] b3c822abf7 feat: move filter segment selection from localStorage to database (#21523) 2025-05-30 12:56:49 +00:00
Akarsh JainandMorgan 1f52ed7290 feat: Add loadingStateChildren prop to AvailabilitySettingsPlatformWrapper (#21602)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-05-30 10:39:02 +00:00
Lauris Skraucis a2c13e576e refactor: v2 dont allow rescheduling cancelled,rescheduled bookings (#21643) 2025-05-30 10:23:51 +00:00
Morgan 24bb52d2e9 fix: platformPlanGuard per active users plan (#21642)
* fix: platformPlanGuard per active users plan

* fix: refactor
2025-05-30 09:42:23 +00:00
Joe Au-Yeung d8145bbc78 Remove error if team members and hosts do not match (#21598) 2025-05-30 08:31:34 +00:00
Hariom BalharaandAlex van Andel 8753a6e4d6 Function return type is illegal, not sure how this passed eslint (#21567) (#21622)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-05-30 13:18:04 +05:30
Benny Joo b91607b5b4 fix: missing places for revalidations (#21632) 2025-05-29 22:45:41 +01:00
Sarthak Kapila af0e839f27 feat: Enable Adding Custom children components at the bottom of EventMeta within Booker using children component props (#21618)
* feat: Enable Adding Custom children components at the bottom of EventMeta

* removed default style

* added prop in BookerPlatformWrapper
2025-05-29 21:26:30 +00:00
Udit Takkar b6de03de96 Revert "perf: use app dir to fetch team feature flags in settings (#21433)" (#21628)
This reverts commit 1dca160db0.
2025-05-29 20:10:12 +00:00
e115095198 perf: migrate listHandler to AttributeRepository + cache attributes fetching in RSCs (#21552)
* refactor: migrate listHandler to AttributeRepository

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

* cache attributes list

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-05-29 15:01:44 -04:00
Lauris Skraucis c3add4bae8 fix: v2 slots reservation (#21623)
* feat: return team event type hosts username

* docs: team event types hosts username in return

* fix: ignore cancelled and rejected bookings from slot reservation

* fix: dont allow slot to overlap existing active booking

* refactor: remove log
2025-05-29 16:25:33 +00:00
Alex van Andel 3040e14624 refactor: Use a deployment repository to interact with license keys (#21613) 2025-05-29 10:58:33 -04:00
Calcom BotandLingo.dev 34993e3f36 feat: update translations via @LingoDotDev (#21260)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-05-29 14:58:17 +00:00
Sarthak Kapilaandcubic-dev-ai[bot] 8d5c6b8877 feat: OnformChange on Availability Settings Atom (#21560)
* feat: OnformChange on Availability Settings Atom

* docs: update availability-settings

* Update docs/platform/atoms/availability-settings.mdx

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

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2025-05-29 20:05:01 +05:30
Devanshu SharmaandMorgan c9eac73fec feat: add support for custom no schedule children in AvailabilitySettingsPlatformWrapper (#21601)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-05-29 20:02:56 +05:30
Udit Takkar ab475b80f3 fix: recording button bug (#21609)
* fix: recording btn visible bug

* fix: check for showRecordingButton

* refactor: create a reusable function
2025-05-29 14:23:51 +00:00
Benny Joo 2b79039b70 perf: upgrade to next 15.3.2 (#21610)
* perf: upgrade nextjs to 15.3.2

* update yarn lock
2025-05-29 14:50:42 +01:00
Lauris Skraucis 044df04d3f feat: v2 return team event type hosts username (#21617)
* feat: return team event type hosts username

* docs: team event types hosts username in return
2025-05-29 14:58:40 +03:00
Layan Jyoti Duttaandlayand12 2cbf805ffb Fixed generator enums in prisma db (#21595)
Co-authored-by: layand12 <d.layan@unque.me>
2025-05-28 22:49:20 +00:00
GitHub Actions d208074063 chore: release v5.3.8
Post release / release (push) Canceled after 0s
2025-05-28 21:11:43 +00:00
Joe Au-Yeung 30541da1fa Fix logging (#21593) 2025-05-28 16:30:14 +00:00
Carina WollendorferandCarinaWolli 97af0af0ba fix: only generate sms message bodies when needed (#21566)
* only generate template when needed

* rename function to getSMSMessageWithVariables

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-28 16:01:40 +00:00
Faiz e9b26921da fix: misaligned and undersized tick mark in timezone dropdown (#21550) 2025-05-28 12:37:16 +00:00
Alex van Andel e4434f199d Function return type is illegal, not sure how this passed eslint (#21567) 2025-05-28 13:21:14 +01:00
Joe Au-Yeung f4b0219a0e Variable fix (#21557) 2025-05-28 02:38:20 +01:00
Joe Au-Yeung 6f1eaaa02e fix: Create separate calendar events to hide organizer (#21555)
* Add notes to `EventManager.create`

* Create `CalendarServiceEvent`

* Generate calendar description and remove attendees if event is private in `CalendarManager`

* Process event for reschedule method

* Refactor calendar services

* Clean up comment

* Type fixes

* Type fix

* Type fix
2025-05-27 20:46:26 -04:00
Udit TakkarandAlex van Andel e5868837d5 perf: service worker lifecycle method (#21532)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-05-27 23:12:25 +00:00
GitHub Actions 71a5d8b261 chore: release v5.3.7
Post release / release (push) Canceled after 0s
2025-05-27 22:35:54 +00:00
Alex van Andel dfc022d890 Revert "perf: parallelize getBusyTimes calls to improve performance (#21372)"
This reverts commit a06b2009ab.
2025-05-27 23:30:30 +01:00
GitHub Actions 68cb0cf197 chore: release v5.3.7 2025-05-27 22:28:37 +00:00
Syed Ali ShahbazandUdit Takkar 14592de6c4 fix: Anonymize host email in ics (#21544)
* add virta check for anonymizing host

* move to env var

* move env var to constants

* fix: filter empty string

* fix test

* fix test

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2025-05-27 19:28:03 +01:00
Omar López d41dcc69ad refactor: prevent mixed imports (#21500)
Signed-off-by: Omar López <zomars@me.com>
2025-05-27 11:55:02 -04:00
Eunjae Lee 0a28b5825b fix: date range computation on /insights (#21436)
* fix: date range computation on /insights

* remove log

* error handling

* update test cases

* WIP

* fix implementation

* support weekStart

* clean up

* fix date range presets

* clean up timeView

* fix timeView

* clean up

* fix event trends

* remove unused variables

* fix flaky e2e tests

* fix date range

* update formatting logic
2025-05-27 17:47:40 +02:00
Eunjae Lee 3131318b6a fix: hide organizer info on rescheduled events (#21541)
* fix(email): hide organizer info on rescheduled events

* hide organizer email on bookings-single-view

* replace organizer email with name

* sanitize previous booking only

* clean up code

* fix dumb error
2025-05-27 15:35:16 +00:00
sean-brydonandAlex van Andel 08e35e4698 perf: stop merging routers twice. (#21538)
* move away from merging twice

* move to use loggedIn Router directly

* fix router outputs types

* fix mocks in create button with teasm tests

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-05-27 15:42:14 +01:00
ed4b3f082a feat: cal video settings (#21347)
* feat: cal video settings

* fix: var names

* feat: finish setting

* chore: add enabled

* chore: remove logs

* feat: support redirect url on exit

* feat: finish settings

* refactor: improve UI

* chore: remove logs

* fix: type err

* fix: finish redirect url

* fix: add type

* chore: change schema

* fix: type error

* chore: improvements

* chore: remove index

* feat: add organizer

* chore: remove log

* chore: use safe parse

* fix: add nullable

* refactor: hide toggles if platorm

* chore: update description

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
2025-05-27 10:13:24 -04:00
Rajiv SahalandAnik Dhabal Babu a1007398f6 fix: update stripe webhooks to handle past due subscriptions (#20466)
* handle case for past due subscriptions

* update user subscription again if active

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-05-27 13:06:42 +00:00
Lauris Skraucis 2aafa1c163 feat: v2 managed organizations pagination (#21359)
* feat: getPagination helper

* refactor: bookings use getPagination helper

* feat: getPagination helper

* refactor: group pagination inputs and outputs in types/pagination

* feat: paginated managed orgs

* fix tests

* swagger
2025-05-27 13:50:31 +03:00
FaizandUdit Takkar 008178480a fix: 'Hide from profile' toggle background not sized correctly on mobile (#21535)
* Fix: 'Hide from profile' toggle background not sized correctly on mobile

* fix: changed padding back to original

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-05-26 18:15:29 -04:00
Amit SharmaandPeer Richelsen 2334c3bef8 fix: optimize SEO healthscore (#21509)
Post release / release (push) Canceled after 0s
* compress app store images

* replace http linkages with https

* fix link raycast

* update http links to https

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-05-26 20:24:08 +00:00
GitHub Actions 9542116ec1 chore: release v5.3.6 2025-05-26 19:58:52 +00:00
Anik Dhabal Babu f9c11c2856 fix: (Reschedule) Email Settings being ignored for RR event (#21520)
* fix: delete dilog button not visible

* fix: (Reschedule) Email Settings being ignored for RR event

* update
2025-05-26 16:17:05 +00:00
Hariom BalharaandOmar López 30060f03eb fix: GoogleCalendar - Ensure that Delegation Credentials are able to reuse access_token (#21389)
Co-authored-by: Omar López <zomars@me.com>
2025-05-26 15:19:52 +01:00
Vijay b8ffcef45e fix: Remove global GTM container on private booking page. (#21490)
* add route filter to inject script

* update middleware for pathname as Referer might not work all time

* move d to (booking-page-wrapper) instead of route filter and change in middleware

* add e2e tests
2025-05-26 10:14:24 +05:30
Rajiv Sahal 999882b795 dont pass attribues and facetedTeamValues for platform (#21508) 2025-05-25 22:17:42 +00:00
Omar López bda963b851 refactor: prevents mixed server/clients imports (#21499) 2025-05-25 09:07:49 +00:00
GitHub Actions 8511494d68 chore: release v5.3.5
Post release / release (push) Canceled after 0s
2025-05-25 00:08:53 +00:00
Benny Joo 1c3ad9dad6 refactor: make routing forms code more maintainable (#21478)
* create layout for routing/forms

* refactor

* refators

* fix

* clean up
2025-05-24 22:44:05 +00:00
Anik Dhabal Babu d43afe7dbd fix: e2e test failure in ci (#21510)
* fix: delete dilog button not visible

* fix: global e2e fialure

* update

* tweak
2025-05-24 13:11:36 -04:00
devin-ai-integration[bot]andkeith@cal.com a06b2009ab perf: parallelize getBusyTimes calls to improve performance (#21372)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
2025-05-24 08:43:06 -04:00
Omar López 2a786b4652 Revert "test: fix few e2e flakes (#21408)" (#21501) 2025-05-23 15:30:33 -07:00
Anik Dhabal Babu ef1844557f fix: Host schedule not updating upon updating/deleting default schedule (#20750)
* fix: host schdule not updating

* Update util.ts

* tweak

* Update delete.handler.ts

* add test

* update

* Update util.test.ts
2025-05-23 17:53:40 -04:00
Alex van Andel d1f7b043e8 chore: Improve error handling for avatar API, shouldn't result in timeouts (#21493) 2025-05-23 18:08:04 +00:00
3a8e40d029 feat(calendar): add error tracking with attempts to SelectedCalendar (#21326)
Co-authored-by: hariom@cal.com <hariom@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2025-05-23 16:38:54 +00:00
bca46228e7 feat: upgrade typescript to 5.8.3 (#21449)
* feat: upgrade typescript to 5.8.3

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

* chore: fix TypeScript 5.8.3 compatibility issues

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

* Uncomment `optmizeImage`

* Type fix

* Type fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: joe@cal.com <joe@cal.com>
Co-authored-by: Joe <j.auyeung419@gmail.com>
2025-05-23 13:57:37 +01:00
Omar López d15b24b8ea ci: remove deprecated submodule code (#20731) 2025-05-23 12:25:33 +00:00
Udit TakkarandSyed Ali Shahbaz d9df9b3d01 feat: recording url with 6 months expiry time (#18707)
* feat: store cal video recording on s3

* feat: create new route to fetch download link

* tests: add unit test

* fix: unit test

* fix: type error

* chore: add env var

* fix: open in new tab

* fix: type err

* chore: update test

* fix: import

* chore: use cal video recording token secret

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2025-05-23 13:01:33 +01:00
Lauris Skraucis fc1d6ae402 refactor: platform docs and oauth webhook auth (#21488)
* fix: Cannot read properties of undefined (reading 'id')

* docs: managed users dont have cal.com page

* docs: reassign works only for round robin bookings

* refactor: put OAuthClientWebhooksController behind ApiAuthGuard

* fix: tests

* fix: typo
2025-05-23 14:06:47 +03:00
Peer Richelsen 26b4b7a868 fixed two dark mode app logos (#21482) 2025-05-23 08:08:27 +00:00
Anik Dhabal Babu a839630ca3 test: fix few e2e flakes (#21408)
* fix flakes

* update

* Update booking.e2e.ts

* update

* Update booking.e2e.ts

* Update booking.e2e.ts
2025-05-23 12:40:27 +05:30
Hariom Balhara e21dbb704e fix error in selected-calendars cron for delegation credential (#21465) 2025-05-23 03:37:24 +00:00
GitHub Actions 53a06797a9 chore: release v5.3.4
Post release / release (push) Canceled after 0s
2025-05-22 23:03:42 +00:00
Carina WollendorferandCarinaWolli 99eb743772 add verifiedAt timestamp (#21477)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-22 22:42:25 +00:00
73adfb0cec fix: header in routing forms (#21445)
* fix header

* remove unused variables

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-05-22 19:47:10 +00:00
Rajiv SahalandMorgan 7127c60116 fix: stripe integration in EventTypeSettings atom (#20919)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-05-22 12:18:28 -07:00
Joe Au-Yeung a6d37fd167 feat: Round robin, when attendee books same timeslot return original booking (#21455) 2025-05-22 12:06:28 -07:00
Benny Joo 16ea15076d fix: increase timeout to 800 seconds for workflow reminders (#21476) 2025-05-22 18:31:02 +00:00
Anik Dhabal Babu 041871f1b6 fix: only fixed hosts added (#21407) 2025-05-22 18:14:46 +00:00
Carina WollendorferandCarinaWolli 8c8ae127fe feat: allow custom SMS templates for free users (#21469)
* charge free users for SMS to CA an US

* allow custom template for free users

* fix backend + some UI fixes

* make text editable

* rename back to needsTeamsUpgrade

* add twilio webhook handler tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-22 19:09:49 +01:00
357c82129c feat: Dub App integration (#21321)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-05-22 09:18:45 -07:00
Eunjae Lee 324ac691eb fix: use local date range on /insights (based on profile's timezone) (#21472)
* fix: use local date range on /insights (based on profile's timezone)

* update test case
2025-05-22 16:03:56 +00:00
devin-ai-integration[bot]andeunjae@cal.com da99001a7a - Prefer the implementation in packages/trpc/react/hooks as it's used by most of the codebase (#21468)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-05-22 11:24:58 -04:00
Anik Dhabal Babu bd10d07083 fix: delete dilog button not visible (#21470) 2025-05-22 14:42:35 +00:00
Hariom BalharaandUdit Takkar 0e9b419a07 fix: Delegation Credentials - Use Cal Video if set as the default conferencing app (#21431)
* fix google-meet being used instead of Default Cal Video preference if
set

* Review addressed

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-05-22 08:21:25 -04:00
Hariom Balhara d5bd08d279 improve-delegation-disable-email-communication (#21456) 2025-05-22 09:06:31 +00:00
devin-ai-integration[bot]andeunjae@cal.com 4cc83530f9 refactor(e2e): use filter helper methods instead of raw selectors (#21460)
* refactor(e2e): use filter helper methods instead of raw selectors

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

* refactor(e2e): rename selectFilter to addFilter and update all usages

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-05-22 08:46:51 +00:00
Hariom BalharaandOmar López 4fcf4081d0 feat: Send emails to potentially affected Organization Members on disabling delegation credential (#21276)
* mvp

* Improve UI

* wip

* self-review

* self-review

---------

Co-authored-by: Omar López <zomars@me.com>
2025-05-22 03:28:21 +00:00
GitHub Actions d84d80eea6 chore: release v5.3.3
Post release / release (push) Canceled after 0s
2025-05-22 01:05:29 +00:00
f3e1d67de1 feat: internationalize user table column headers (#21444)
* feat: internationalize user table column headers

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

* revert

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-05-21 20:10:28 +00:00
Benny Joo 2ce25f54d4 refactor: simplify arg of useBookerLayout hook (#21425) 2025-05-21 10:29:02 -07:00
Benny Joo 25eac5d29b chore: remove last booking response dead code (#21426) 2025-05-21 10:27:52 -07:00
Benny Joo 1710e30719 perf: use server fetched data in instant-meeting and private booking pages (#21424)
* remove useEvent hook

* refactor

* use repository functions

* remove trpc event

* wip

* fix

* remove

* fix

* revert some files

* revert Embed

* revert useEvent hook

* fix

* fix

* revert

* refactor
2025-05-21 10:27:23 -07:00
devin-ai-integration[bot]andeunjae@cal.com 6d970d0b57 fix: improve date selection behavior in DateRangePicker (#21438)
* fix: implement Airbnb-style date selection in DateRangePicker

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

* fix: add behavior for clicking on start/end date in DateRangePicker

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-05-21 16:13:55 +00:00
41a6035a12 fix: replace bookingTimeStatus with bookingTimeStatusDenormalized (#21028)
* test: Add unit tests for buildBaseWhereCondition function

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

* refactor: Export buildBaseWhereCondition function instead of copying it

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

* refactor: Remove implementation detail checks from tests

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

* test: Fix tests to match actual function behavior

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

* test: Fix test issues with buildBaseWhereCondition

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

* test: Update tests to match actual function behavior

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

* fix: Address security issues in buildBaseWhereCondition function

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

* refactor: Simplify buildBaseWhereCondition function

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

* refactor: Simplify code and remove isEmptyResponse property

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

* refactor: Use array of conditions approach for buildBaseWhereCondition

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

* refactor: Make buildBaseWhereCondition implementation consistent and update tests

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

* fix: Fix incorrect usage of buildBaseWhereCondition function

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

* chore: Revert yarn.lock changes

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

* fix: Comment out non-existent Sentry API method to fix type error

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

* Revert "fix: Comment out non-existent Sentry API method to fix type error"

This reverts commit 44c21a7396a518c63047b33967cd8af7e6e4f268.

* remove unnecessary if-statement

* fix: replace bookingTimeStatus with bookingTimeStatusDenormalized

* change column

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
2025-05-21 16:10:43 +00:00
Akarsh Jain f84c8239e7 feat(bookings): implement Booking UID filter for bookings data table. (#21380)
Fixes: #21357
2025-05-21 11:51:50 -04:00
sean-brydon 1dca160db0 perf: use app dir to fetch team feature flags in settings (#21433)
* use app dir to fetch team feature flags

* guard against type error if teamId does not exist in teamfeatures

* remove redundant router
2025-05-21 12:41:45 +00:00
Carina WollendorferandCarinaWolli 4ff366cd99 fix: canceling workflow reminders (#21416)
* fix deleting reminders

* schedule reminders if iffy is disabled

* fix unit test

* clean up code

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-21 12:35:31 +00:00
Carina WollendorferandCarinaWolli 21a4711f07 fix: don't schedule past workflow reminders with tasker (#21434)
* don't schedule past reminders

* add test

* fix dates in tests

* add early return

* fix sms test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-21 12:24:34 +00:00
Anik Dhabal Babu f1dd217de7 fix: don’t let the booking page crash on invalid metadata (#21410)
* fix: booking tab crash

* Update zod-utils.ts

* Update zod-utils.ts

* update
2025-05-21 09:44:17 +01:00
Anik Dhabal BabuandCarinaWolli 3e8f0b125d fix: WhatsApp workflows are not working (#21421)
* fix: whatsapp template

* update

* fixes for sending whatsapp messages

* add message service id when scheduling

* add timezone to startTime variable

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-21 08:12:12 +00:00
devin-ai-integration[bot]andeunjae@cal.com 25246ef8ff test: add e2e tests for filters on /insights/routing (#21332)
* test: add e2e tests for insights routing filters

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

* fix: improve stability of insights routing filters e2e tests

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

* fix: address PR feedback for insights routing filters e2e tests

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

* fix: add explicit page navigation to resolve timeout issues in filters.e2e.ts

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

* fix: navigate to routing tab after loading insights page

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

* fix e2e tests

* fix tests

* rename

* fix tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-05-20 18:23:57 -04:00
Benny Joo bb98143e2e perf: Server Fetching for platform members and org members pages (#21342)
* remove members page

* refactor to server fetch

* same for /members

* use server fetches for all queires

* fix

* add skeletons

* refactor

* fix loading page

* fix

* fix type checks

* fix details

* refactor platform members page
2025-05-20 16:36:34 -04:00
devin-ai-integration[bot]andeunjae@cal.com 6258ca14d0 feat: add segments prop to DataTableProvider (#21409)
* feat: add segments prop to DataTableProvider

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

* refactor: modify useSegments hook instead of creating a new one

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

* fix: implement PR feedback and fix failing test

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

* clean up

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-05-20 22:30:32 +02:00
Eunjae Lee d0e17e8611 fix: add migration to backfill BookingDenormalized (#21420) 2025-05-20 10:01:09 -07:00
Eunjae Lee 79dc76239b fix: apply transaction to public methods of CreditService (#21356) 2025-05-20 18:20:45 +02:00
Eunjae Lee 95d62354d9 test: update flaky tests (#21419)
* test: update flaky integration tests

* change limit
2025-05-20 14:57:43 +00:00
b9fadf82a7 feat: pbac (#21296)
* inital pbac setup

* fix tests

* docs intro

* more docs

* add comments in docs

* revert pr_todo

* update to use action + custom as defaults

* use enums

* move to change user role + multi check

* add beforeEach import

* docs: add PERMISSIONS.md documenting team/organization role usages

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

* docs: add permission string alternatives for helper functions

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

* feat: enhance permission registry and default role permissions

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

* add routers

* add hooks + rsc. Also adjsuted test to use prismock

* make hooks type safe + add some tests

* add a permission context - trpc router to populate context

* update docs

* explore global and default roles member,admin,owner

* fix tests

* use kysley and abstract uril

* tidy up

* update trpc to use pbac route in endpoint + distinct on teamId

* make transform util

* remove role service dependancy from permission check class

* checkTeamId fall back to orgId if team is parent

* todos

* remove manual migrations to launch softly

* address feedback

* move permission checks to a repo

* move role service database calls to repo. improve mocks

* update tests to have describe import

* fix types

* remove router that is not used to pass build

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-05-20 15:22:52 +01:00
devin-ai-integration[bot]andalex@cal.com 1740e34c79 perf: remove unused submitFeedback tRPC route to reduce type generation load (#21418)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: alex@cal.com <me@alexvanandel.com>
2025-05-20 15:14:51 +01:00
Eunjae Lee 9e09066e99 chore: use correct import path for AppRouter (#21415) 2025-05-20 13:50:20 +00:00
Benny Joo 0a9cfd3a08 perf: cache TRPC queries in /availability RSC (#21398)
* cache trpc queries in /availability

* fix

* fix

* add comments

* use undefined
2025-05-20 08:04:52 +01:00
Benny Joo 0be711ad6e perf: cache TRPC queries in /event-types/[eventTypeId] RSC (#21401)
* cache /event-types details page

* fix
2025-05-20 08:04:01 +01:00
Alex van Andel 1c507b8169 chore: Increase workflows/trpc timeout to 800s (#21397) 2025-05-20 06:44:12 +00:00
81bdb3dc91 fix: Handle invalid non-delegation credential in cron (#21270)
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2025-05-20 03:17:13 +00:00
devin-ai-integration[bot]andkeith@cal.com 5efc7f3856 perf: optimize O(n²) algorithms in slot generation to O(n) or O(n log n) (#21373)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
2025-05-19 18:40:18 -04:00
Benny Joo 0afecec607 perf: follow up of server query caching in /event-types (#21402) 2025-05-19 19:39:51 +00:00
Benny Joo 920574de65 perf: cache TRPC queries in /event-types RSC (#21381)
* improve trpc funcs

* cache /event-types

* remove unused code

* add comments

* add keys

* improve typing
2025-05-19 18:23:06 +00:00
GitHub Actions d15490d5aa chore: release v5.3.2
Post release / release (push) Canceled after 0s
2025-05-19 16:45:11 +00:00
Lauris Skraucis ba6e547f6c chore: bump platform libraries for v2 (#21391) 2025-05-19 16:30:13 +00:00
Anik Dhabal Babu 2493315f92 fix: wrong event details (#21390) 2025-05-19 16:20:07 +00:00
Keith Williams ec117d4034 revert: "perf: add p-limit to _getUsersAvailability to limit concurrent executions to 10 (#21374)" (#21396)
This reverts commit 6cb3bdc567.
2025-05-19 11:47:27 -04:00
Eunjae Lee 646058b8a1 chore: add missing migration for RoutingFormResponseDenormalized (#21388)
* chore: add missing migration for RoutingFormResponseDenormalized

* more changes

* remove cascade deleting

* update migration

* updates

* update

* update schema

* update

* update

* update

* update
2025-05-19 11:28:57 -04:00
Keith Williams 06c359ac9b revert: "chore: release v5.3.2"
This reverts commit 0d96d9c44c.
2025-05-19 08:44:37 -04:00
GitHub Actions 0d96d9c44c chore: release v5.3.2 2025-05-19 12:43:24 +00:00
Udit Takkar e5f8d4a1ac feat: support no show webhook for instant meetings (#21251)
* feat: support no show webhook for instant meetings

* fix: type error
2025-05-19 13:05:06 +04:00
Alex van Andel 19c72323b8 chore: Delete .github/workflows/advisories-to-slack.yml 2025-05-18 23:17:33 +01:00
Alex van Andel f19b2804b6 Update advisories-to-slack.yml (#21384) 2025-05-18 20:03:49 +01:00
Alex van Andel f387fdb166 chore: Add workflow to post security advisories to Slack (#21383) 2025-05-18 17:45:17 +00:00
ec8f9d5f7a feat: option to enforce language for bookingPage (#18782)
* feature eventType specific language or bookingPage language

* chore

* fix type checks

* chore

* chore: remove log

* improvement: this updates in DatePicker is not required with context provider method

* added e2e tests for InterfaceLanguage feature

* corrected prev merge conflict issues

* update to align with hooks usage pattern

* chore

* unrelated auto-formatting changes

* unrelated auto-formatting changes

* unrelated auto-formatting changes

* update to affect interfaceLanguage on success page

* undone prettier changes

* update test for success page translation

* chore

* return null from memo

* fix typecheck error due to atom imports in useLocale

* set default interfaceLanguage as null

* updated to use null instead of constant

* enhancements

* fix latest unit test, use prop instead of useSession hook

* fix build issue

* chore

* fix atom build issue

* fix unit test with reqd mock

* update to use server i18n rendering

* nit

* Move import localeOptions to EventSetupTabWebWrapper

* make sure we only display interface language option in web app

* using customI18nProvider

---------

Co-authored-by: amrit <iamamrit27@gmail.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: Tushar <tusharbhatt0135@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-05-18 01:12:26 -04:00
devin-ai-integration[bot]andkeith@cal.com 6cb3bdc567 perf: add p-limit to _getUsersAvailability to limit concurrent executions to 10 (#21374)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
2025-05-17 17:05:45 -04:00
NISHANT SINGH d574840c3f fix: make 'Send reset email' button text visible on hover (#21334) (#21335)
* fix: ensure button text remains visible on hover in forgot password page

Before: Button text turned black on hover, blending with the dark background.
After: Added 'enabled:hover:text-white' to maintain readability.

* Re-update forgot-password-view.tsx

* Update globals.css brand-text

* Update globals.css --cal-brand-text: white

* Reverted globals.css update

* Update forgot-password-view.tsx , change brand-text color

change "--cal-brand-text": 
white to Black
2025-05-17 09:48:21 +00:00
devin-ai-integration[bot]andkeith@cal.com 6a85d0e10b perf: optimize getSlots performance by selectively merging overlapping date ranges (#21370)
* perf: optimize getSlots performance by selectively merging overlapping date ranges

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

* fix: handle date ranges with end time before start time in filterRedundantDateRanges

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

* perf: apply filterRedundantDateRanges to all scheduling types

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

* fix: preserve uniqueness check before filtering redundant date ranges

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
2025-05-17 03:44:56 +00:00
devin-ai-integration[bot]andkeith@cal.com 816df3704e perf: Optimize getSlots function to handle large dateRanges arrays efficiently (#21371)
* perf: Optimize getSlots function to handle large dateRanges arrays efficiently

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

* Update packages/lib/slots.test.ts

* test: Update performance test to use 2000 date ranges

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

* test: Update performance test to process 2000 date ranges across multiple days

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
2025-05-17 04:00:13 +01:00
Alex van Andel badd9a5698 perf: Better perf by going event type first w/o booking (#21369)
* perf: Better perf by going event type first w/o booking

* minor fixup

* Use an IN with children instead of an OR with parent

* Turns out we already had a variable with the right values

* Make sure the right event type is used even for children

* few fixes

* Full select is needed to keep typings working

* Fix last remaining issue
2025-05-16 17:55:57 -07:00
GitHub Actions 4e02fe4251 chore: release v5.3.1
Post release / release (push) Canceled after 0s
2025-05-17 00:03:32 +00:00
Benny Joo 01b9780938 perf: ignore Object Not Found Matching Id error (#21365) 2025-05-16 23:11:33 +00:00
Anik Dhabal Babu 6be144f95f fix: unable to send verifiation code (#21367) 2025-05-16 23:09:19 +00:00
Anik Dhabal Babu 37dd6d888b fix: add reschedule by (#21363) 2025-05-16 17:14:06 +00:00
Lauris Skraucis f5ffee0a20 feat: automated changelogs and releases by changesets (#21299)
* feat: automated changesets

* chore: update changesets to latest

* readme
2025-05-16 12:52:45 -04:00
Carina WollendorferandCarinaWolli 6cd51e0a8d remove disabled state in addActionDialog (#21362)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-16 16:36:29 +00:00
Lauris Skraucis 80fad2aab9 fix: create event type with output of another create event type (#21273)
* feat: accept location booking field with label customization

* feat: return location label

* chore: bump libraries

* test

* fix: tests
2025-05-16 16:34:44 +03:00
Lauris Skraucis 1ccfc65766 fix: POST /v2/bookings validate submitted booking fields (#21163) 2025-05-16 13:00:58 +00:00
Lauris Skraucis 86918b03cf fix: booker atom seated events reservation (#21339)
* fix: reserving first seated booking slot

* fix: input type in useReserveSlot

* fix: handleReserveSlot submit bookingUid to api

* null -> undefined

* dont touch web app
2025-05-16 14:32:06 +02:00
a5b684af1f test: add unit tests for getWhereForTeamOrAllTeams function (#21152)
* test: add unit tests for getWhereForTeamOrAllTeams function

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

* test: remove implementation details and add tests for invalid parameter combinations

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

* test: update mock values to use more distinct numbers

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

* test: update mock values to use completely distinct numbers

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

* test: update mock values to maintain data integrity

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

* test: convert to integration tests for getWhereForTeamOrAllTeams function

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

* chore: remove integration test file

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

* test: reorganize tests with proper describe blocks

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

* test: move test file to __tests__ directory

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

* test: update import path for routing-events in test file

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-05-16 08:19:17 +00:00
8889e8566c fix: eventType filter on /insights and add unit tests for buildBaseWhereCondition (#21154)
* test: Add unit tests for buildBaseWhereCondition function

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

* refactor: Export buildBaseWhereCondition function instead of copying it

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

* refactor: Remove implementation detail checks from tests

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

* test: Fix tests to match actual function behavior

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

* test: Fix test issues with buildBaseWhereCondition

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

* test: Update tests to match actual function behavior

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

* fix: Address security issues in buildBaseWhereCondition function

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

* refactor: Simplify buildBaseWhereCondition function

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

* refactor: Simplify code and remove isEmptyResponse property

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

* refactor: Use array of conditions approach for buildBaseWhereCondition

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

* refactor: Make buildBaseWhereCondition implementation consistent and update tests

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

* fix: Fix incorrect usage of buildBaseWhereCondition function

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

* chore: Revert yarn.lock changes

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

* fix: Comment out non-existent Sentry API method to fix type error

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

* Revert "fix: Comment out non-existent Sentry API method to fix type error"

This reverts commit 44c21a7396a518c63047b33967cd8af7e6e4f268.

* remove unnecessary if-statement

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-05-16 10:10:57 +02:00
fab364f084 fix: Allow zero routingFormResponseId in API v2 available endpoint (#21301)
* Allow zero routingFormResponseId

* fix: conditionally validate routingFormResponseId to allow 0 only for dry run operations

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

* fix: pass _isDryRun parameter to API v2 available endpoint

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

* fix runtime error and improve message

* Add e2e test to verify the constraint works

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
2025-05-16 12:23:11 +05:30
Devanshu Sharma 5bc9d71a88 fix: (hu) correct typo 'ektávolítás' to 'eltávolítás' in Hungarian translation (#21348) 2025-05-16 03:27:58 +00:00
GitHub Actions 2b5ea649d4 chore: release v5.3.0
Post release / release (push) Canceled after 0s
2025-05-15 20:43:11 +00:00
Benny Joo 0802ebb17b refactor: check insights feature flag in higher stack (#21341) 2025-05-15 12:41:22 -07:00
Anik Dhabal Babu 29b8d68f45 fix: unable to save workflow with verified secondary email (#21338)
* fix: unable to save workflow

* Update util.ts
2025-05-15 19:40:45 +00:00
Lauris Skraucis 6994cac5e0 fix: v2 token secret conflicts (#21279)
* fix: accessToken.create unique constraint failed on the secret field

* fix: force refresh tokens within single transaction
2025-05-15 16:04:50 +00:00
Carina Wollendorfer df9b72583d add learn more help link (#21333) 2025-05-15 11:02:57 +00:00
Carina WollendorferandCarinaWolli 553eab59e6 fix: sms credits follow up (#21304)
* address mrge review comments

* code clean up

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-15 08:33:38 +00:00
Joe Au-Yeung 8d675fd8b6 feat: Add AssignmentReason to BOOKING_CREATED webhook (#21315)
* AssignmentREasonRecorder methods return the reason generated

* Add assignmentReason to `EventPayloadType`

* Pass `assignmentReason` to webhook payload data

* Undo constants commit
2025-05-14 23:33:25 +00:00
GitHub Actions a5639a8290 chore: release v5.2.17
Post release / release (push) Canceled after 0s
2025-05-14 23:10:04 +00:00
Anik Dhabal Babu 44d9c4bcc5 fix: Seated events and round robin - all round robin hosts are being added to the calendar event (#21317)
* fix: add org email into ics

* fix: Seated events and round robin - all round robin hosts are being added to the calendar event

* added test
2025-05-14 22:52:26 +00:00
Anik Dhabal Babu 1b586560f8 fix: userprimaryemail not updating (#21318) 2025-05-14 22:01:43 +00:00
Benny Joo e082cc7d3d refactor: Handle apiv2 team slots in useSchedule hook (#21309) 2025-05-14 10:12:09 -07:00
Keith Williams d411e70ea3 chore: Add @calcom/Consumer as CODEOWNER for ui-playground (#21312) 2025-05-14 16:17:13 +00:00
Keith Williams 8dffa4d371 chore: Back down @calcom/Consumer CODEOWNERS (#21311) 2025-05-14 16:09:58 +00:00
Eunjae Lee b7bf6e80e6 fix: case insensitive filter for attendees on /bookings (#21302) 2025-05-14 15:51:22 +00:00
Eunjae Lee 2a93e661f9 feat: add denormalized table for RoutingFormResponse view (#21009)
* feat: add denormalized table for RoutingFormResponse

* WIP

* improve

* update schema

* update schema

* improvement

* fix type of bookingStatus

* cascading DELETE

* split update trigger

* denormalize fields into a separate table

* error handling

* address feedback

* fix

* fix constraints and relations

* delete rows when rows are deleted in the joined tables

* add tests and make booking nullable in RoutingFormResponseDenormalized

* add tests

* update test

* use e2e tests instead

* fix type error

* address feedback

* move e2e to integration test

* add missing tests

* move index migration to another file

* clean up migrations
2025-05-14 10:41:28 -04:00
Lauris Skraucis bfb51b82b2 fix: useLocale hook is being used outside of App Router & time zone dropdown timeout (#21306) 2025-05-14 14:07:35 +00:00
Keith Williams f50be6c08b chore: Lower number of errors sent to Sentry (#21300) 2025-05-14 12:21:13 +00:00
Hariom Balhara df75df7008 fix: DelegationCredential - Duplicate GoogleMeet and unnecessary serviceAccount key access (#21269)
* Remove unused return statement

* Fix return from fn instead of continuing the loop

* Ensure userId is set for existing records on update

* ensure that serviceAccountKey is only retrieved whenb assked
2025-05-14 09:49:47 +00:00
Eunjae Lee deb55c0536 fix: sticky columns in DataTable (#21278)
* fix: sticky columns in DataTable

* update th color

* remove rounded corner

* border change
2025-05-14 08:34:50 +00:00
Lauris Skraucis 7fc11ff341 fix: v2 bookings with sms reminders (#21191)
* fix: v2 bookings with sms reminders

* fix: falsey booking field values
2025-05-14 10:25:29 +03:00
Lauris Skraucis 1a70f83d34 fix: v2 sentry errors (#21214)
* fix: PATCH, POST event-types Address location must have an address

* fix: GET event-types Address location must have an address

* fix: POST /v2/bookings Address location must have an address

* refactor: GET /v2/slots Invalid time range given.

* fix: POST v2/event-types Cannot read properties of null (reading 'type')

* fix: POST /v2/bookings/:bookingUid/mark-absent Failed to update no-show status
2025-05-14 10:11:35 +03:00
39077a08f1 docs: correct documentation for slot format in 2024-09-04 API (#21196)
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-05-14 06:57:41 +00:00
Benny Joo 9a671f9063 fix: add arg validation to ensureValidPhoneNumber (#21259) 2025-05-13 18:57:00 -07:00
3bc9fa11d0 chore: remove @calcom/lib barrel file (#21267)
* refactor: replace imports from @calcom/lib barrel file with direct imports

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

* fix: add deprecated barrel file with warning to support tests

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

* remove barrel file

* fix: restore barrel file with deprecation notice and add CreditType enum export

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

* fix: add CreditType enum definition to barrel file

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

* fix: add mock for CreditType enum in credit-service test

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

* fix: use importOriginal in CreditType enum mock

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

* fix: remove barrel file completely

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

* fix: restore barrel file with deprecation notice to support tests

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

* fix: update test-setup to use direct import from @calcom/ui/classNames

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

* fix: completely remove barrel file

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

* fix: update package.json to remove references to index.ts

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

* fix: remove main and types fields from package.json

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

* fix: restore barrel file with deprecation notice to support tests

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

* remove barrel file

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-05-13 21:55:58 -04:00
Joe Au-Yeung 7e699ab810 fix: Salesforce - fix option to create contact if account exists (#21288)
* Add `onCheckedChange`

* Remove console.logs
2025-05-13 22:17:57 +00:00
661 changed files with 21091 additions and 4823 deletions
+6 -2
View File
@@ -1,11 +1,15 @@
{
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
"changelog": "@changesets/cli/changelog",
"changelog": ["@changesets/changelog-github", { "repo": "calcom/cal.com" }],
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
"ignore": [],
"privatePackages": {
"version": false,
"tag": false
}
}
+9
View File
@@ -166,6 +166,7 @@ SENTRY_AUTH_TOKEN=
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_DEBUG=
SENTRY_MAX_SPANS=
SENTRY_SAMPLE_RATE=
SENTRY_TRACES_SAMPLE_RATE=
SENTRY_REPLAYS_SESSION_SAMPLE_RATE=
SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=
@@ -187,6 +188,10 @@ TWILIO_TOKEN=
TWILIO_MESSAGING_SID=
TWILIO_PHONE_NUMBER=
TWILIO_WHATSAPP_PHONE_NUMBER=
TWILIO_WHATSAPP_REMINDER_CONTENT_SID=
TWILIO_WHATSAPP_CANCELLED_CONTENT_SID=
TWILIO_WHATSAPP_RESCHEDULED_CONTENT_SID=
TWILIO_WHATSAPP_COMPLETED_CONTENT_SID=
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
NEXT_PUBLIC_SENDER_ID=
TWILIO_VERIFY_SID=
@@ -440,6 +445,10 @@ NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM=0
# Used to enable quick availability checks for x% of all visitors
NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
# Used to generate token for cal video recordings expiry token
# You can use: `openssl rand -base64 32` to generate one
CAL_VIDEO_RECORDING_TOKEN_SECRET=
# Deprecated. Use NEXT_PUBLIC_BODY_SCRIPTS instead. NEXT_PUBLIC_HEAD_SCRIPTS are now injected into the body only.
NEXT_PUBLIC_HEAD_SCRIPTS=
NEXT_PUBLIC_BODY_SCRIPTS=
+1 -2
View File
@@ -1,8 +1,6 @@
/**/package.json @calcom/Foundation
/apps/api/**/* @calcom/Platform @calcom/Foundation
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
/apps/web/components/**/* @calcom/Consumer @calcom/Foundation
/apps/web/modules/**/* @calcom/Consumer @calcom/Foundation
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
/apps/web/lib/**/* @calcom/Foundation
/apps/web/middleware.ts @calcom/Foundation
@@ -19,6 +17,7 @@
/packages/app-store/larkcalendar/**/* @calcom/Foundation
/packages/app-store/office365calendar/**/* @calcom/Foundation
/packages/app-store/zohocalendar/**/* @calcom/Foundation
/packages/embeds/**/* @calcom/Foundation
/packages/features/bookings/lib/**/* @calcom/Foundation
/packages/lib/getAggregatedAvailability.ts @calcom/Foundation
/packages/lib/getUserAvailability.ts @calcom/Foundation
+37
View File
@@ -0,0 +1,37 @@
name: Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions: {} # remove permissions and set explicitly under "permissions:" below
jobs:
release:
if: github.repository == 'calcom/cal.com' # prevent this action from running on forks
permissions:
id-token: write # for NPM provenance - developers can verify where and how it was built
contents: write # to create release by changesets/action below
pull-requests: write # to create pull request by changesets/action below
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup node, yarn and install dependencies
uses: ./.github/actions/yarn-install
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
version: yarn changesets-version
publish: yarn changesets-release
commit: "chore: version packages" # commit message
title: "chore: version packages" # PR title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
-21
View File
@@ -1,21 +0,0 @@
name: Submodule Sync
on:
schedule:
# Runs "At minute 15 past every 4th hour." (see https://crontab.guru)
- cron: "15 */4 * * *"
workflow_dispatch: ~
jobs:
submodule-sync:
name: Submodule update
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Commit
run: |
git config user.email "actions@github.com"
git config user.name "github-actions"
git commit -am "Auto updated submodule references" && git push || echo "No changes to commit"
-6
View File
@@ -1,6 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
echo "Updating submodules recursively"
pwd
git submodule update --init --recursive
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
View File
View File
+6
View File
@@ -0,0 +1,6 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
+7
View File
@@ -0,0 +1,7 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
View File
View File
View File
+189
View File
@@ -0,0 +1,189 @@
# Cal.com Permission Documentation
This document maps existing role-based permission checks to the new PBAC (Permission-Based Access Control) system's permission strings in the format `resource.action`.
## Permissions by Resource
### Team Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| team.create | Create teams | [packages/trpc/server/routers/viewer/teams/create.handler.ts](packages/trpc/server/routers/viewer/teams/create.handler.ts) | 55-57 |
| team.update | Update team settings | [packages/trpc/server/routers/viewer/teams/update.handler.ts](packages/trpc/server/routers/viewer/teams/update.handler.ts) | 18 |
| team.changeMemberRole | Change role of team members | [packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts](packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts) | 18-21 |
| team.remove | Remove members from team | [packages/trpc/server/routers/viewer/teams/removeMember.handler.ts](packages/trpc/server/routers/viewer/teams/removeMember.handler.ts) | 35-36, 51-55 |
| team.invite | Invite members to team | [packages/trpc/server/routers/viewer/teams/invite.handler.ts](packages/trpc/server/routers/viewer/teams/invite.handler.ts) | - |
### Event Type Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| eventType.create | Create event types | [packages/trpc/server/routers/viewer/eventTypes/create.handler.ts](packages/trpc/server/routers/viewer/eventTypes/create.handler.ts) | - |
| eventType.update | Update event types | [packages/trpc/server/routers/viewer/eventTypes/update.handler.ts](packages/trpc/server/routers/viewer/eventTypes/update.handler.ts) | 162-164 |
| eventType.delete | Delete event types | [packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts](packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts) | 13-31 |
### Booking Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| booking.read | Read booking details | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 95-107, 132-134 |
| booking.readTeamBookings | Read team bookings | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 240-252 |
| booking.readOrgBookings | Read organization bookings | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 240-252 |
### Organization Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| organization.read | Read organization details | [packages/trpc/server/routers/viewer/organizations/get.handler.ts](packages/trpc/server/routers/viewer/organizations/get.handler.ts) | - |
| organization.listMembers | List organization members | [packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts](packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts) | 68-76 |
| organization.create | Create organization | [packages/trpc/server/routers/viewer/organizations/create.handler.ts](packages/trpc/server/routers/viewer/organizations/create.handler.ts) | - |
### API Key Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| apiKey.create | Create API keys | [packages/trpc/server/routers/viewer/apiKeys/create.handler.ts](packages/trpc/server/routers/viewer/apiKeys/create.handler.ts) | 25-26 |
| apiKey.findKeyOfType | Find API keys by type | [packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts](packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts) | 18-19 |
## Helper Functions and Utilities
The following helper functions are commonly used for permission checks:
| Function | Description | File Path |
|----------|-------------|-----------|
| isTeamAdmin | Checks if user is admin or owner of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L385-L405) |
| isTeamOwner | Checks if user is owner of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L407-L416) |
| isTeamMember | Checks if user is a member of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L418-L426) |
| canEditEntity | Checks if user can edit an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L13-L22) |
| canAccessEntity | Checks if user can access an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L24-L34) |
| getEntityPermissionLevel | Gets permission level for an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L36-L73) |
| canCreateEntity | Checks if user can create an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L102-L115) |
| withRoleCanCreateEntity | Checks if role allows entity creation | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L119-L121) |
## Permission Level Enums
The codebase uses several enums to define permission levels:
### MembershipRole Enum
```typescript
enum MembershipRole {
OWNER = "OWNER",
ADMIN = "ADMIN",
MEMBER = "MEMBER"
}
```
### Entity Permission Level Enum
```typescript
enum ENTITY_PERMISSION_LEVEL {
NONE,
USER_ONLY_WRITE,
TEAM_READ_ONLY,
TEAM_WRITE
}
```
## Common Permission Patterns
1. **Team Admin/Owner Check**: Many operations require the user to be a team admin or owner.
```typescript
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
```
2. **Team Owner Check**: Some operations (like changing an owner's role) require the user to be a team owner.
```typescript
if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
```
3. **Organization Admin Check**: Operations within an organization require the user to be an organization admin.
```typescript
if (user.profile?.organizationId && !user.organization.isOrgAdmin) {
throw new TRPCError({ code: "FORBIDDEN", message: "org_admins_can_create_new_teams" });
}
```
4. **Entity Permission Check**: Entity operations use permission level checks.
```typescript
const permissionLevel = await getEntityPermissionLevel(entity, userId);
return permissionLevel === ENTITY_PERMISSION_LEVEL.TEAM_WRITE ||
permissionLevel === ENTITY_PERMISSION_LEVEL.USER_ONLY_WRITE;
```
5. **Booking Access Control**: Complex permission checks for retrieving bookings based on user roles and team/organization membership.
```typescript
const membershipIdsWhereUserIsAdminOwner = (
await prisma.membership.findMany({
where: {
userId: user.id,
role: {
in: ["ADMIN", "OWNER"],
},
},
select: {
id: true,
},
})
).map((membership) => membership.id);
```
## Migration to PBAC
When migrating to the new PBAC system, these existing role-based checks should be replaced with permission string checks using the `permissionMatches` function:
```typescript
import { permissionMatches } from "@calcom/features/pbac/types/permission-registry";
// Instead of:
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
// Use:
if (!permissionMatches("team.update", userPermissions)) throw new TRPCError({ code: "UNAUTHORIZED" });
```
## Permission String Alternatives for Helper Functions
The following table provides permission string alternatives for common helper functions:
| Helper Function | Permission String Alternative | Description |
|-----------------|-------------------------------|-------------|
| isTeamAdmin | `team.*` | Grants all team permissions |
| isTeamAdmin | `team.update` | Update team settings |
| isTeamAdmin | `team.invite` | Invite team members |
| isTeamAdmin | `team.remove` | Remove team members |
| isTeamOwner | `team.changeMemberRole` | Change role of team members |
| isTeamOwner | `team.delete` | Delete team |
| isTeamMember | `team.read` | Read-only access to team |
## Entity Permission Functions and Resources
The following functions are used to check permissions for entities with userId and teamId properties:
| Function | Description | Permission String Alternative |
|----------|-------------|-------------------------------|
| canEditEntity | Checks if user can edit an entity | `{resource}.update` |
| canAccessEntity | Checks if user can access an entity | `{resource}.read` |
| getEntityPermissionLevel | Gets permission level for an entity | N/A - Implementation detail |
| canCreateEntity | Checks if user can create an entity | `{resource}.create` |
### Resources Used with Entity Permission Functions
| Resource | canEditEntity Usage | canAccessEntity Usage | Permission String |
|----------|-------------------|---------------------|------------------|
| routingForm | [packages/app-store/routing-forms/trpc/forms.handler.ts](packages/app-store/routing-forms/trpc/forms.handler.ts#L72) | [packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts](packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts#L78) | `routingForm.update`, `routingForm.read` |
| routingForm | [packages/app-store/routing-forms/trpc/formMutation.handler.ts](packages/app-store/routing-forms/trpc/formMutation.handler.ts#L315) | | `routingForm.update` |
| routingForm | [packages/app-store/routing-forms/api/responses/[formId].ts](packages/app-store/routing-forms/api/responses/[formId].ts#L96) | | `routingForm.update` |
These functions can be used with any entity that has userId and teamId properties. When migrating to PBAC, replace these checks with appropriate permission string checks:
```typescript
// Instead of:
if (!(await canEditEntity(form, user.id))) throw new TRPCError({ code: "UNAUTHORIZED" });
// Use:
if (!permissionMatches("routingForm.update", userPermissions)) throw new TRPCError({ code: "UNAUTHORIZED" });
```
This allows for more granular permission control and the creation of custom roles with specific permissions.
+8
View File
@@ -646,6 +646,14 @@ following
15. Create a verify service
16. Copy Verify Service SID to your `.env` file into the `TWILIO_VERIFY_SID` field
## Changesets
We use changesets to generate changelogs and publish public packages (packages with `private: true` are ignored).
An example of good readme is [atoms readme](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/README.md). Every public package must:
1. Follow semantic versioning when using changesets.
2. Mark breaking changes using `❗️Breaking change`
<!-- LICENSE -->
## License
@@ -1,4 +1,4 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import prismock from "../../../../../tests/libs/__mocks__/prisma";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -7,12 +7,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import type { IDeploymentRepository } from "@calcom/lib/server/repository/deployment.interface";
import prisma from "@calcom/prisma";
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import { hashAPIKey } from "~/../../../packages/features/ee/api-keys/lib/apiKeys";
import { verifyApiKey } from "../../../lib/helpers/verifyApiKey";
import { verifyApiKey } from "./verifyApiKey";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
@@ -21,11 +21,16 @@ afterEach(() => {
vi.resetAllMocks();
});
describe("Verify API key", () => {
const mockDeploymentRepository: IDeploymentRepository = {
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value
};
// TODO: Fix the skip condition for this test suite
describe.skip("Verify API key", () => {
let service: ILicenseKeyService;
beforeEach(async () => {
service = await LicenseKeyService.create();
service = await LicenseKeyService.create(mockDeploymentRepository);
vi.spyOn(service, "checkLicense");
});
+4 -2
View File
@@ -3,6 +3,7 @@ import type { NextMiddleware } from "next-api-middleware";
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
import prisma from "@calcom/prisma";
import { isAdminGuard } from "../utils/isAdmin";
@@ -19,11 +20,12 @@ export const dateNotInPast = function (date: Date) {
// This verifies the apiKey and sets the user if it is valid.
export const verifyApiKey: NextMiddleware = async (req, res, next) => {
const licenseKeyService = await LicenseKeySingleton.getInstance();
const deploymentRepo = new DeploymentRepository(prisma);
const licenseKeyService = await LicenseKeySingleton.getInstance(deploymentRepo);
const hasValidLicense = await licenseKeyService.checkLicense();
if (!hasValidLicense && IS_PRODUCTION) {
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
return res.status(401).json({ message: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
}
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
@@ -18,6 +18,11 @@ export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyPa
id: true,
// No eventTypeId support in API v1
eventTypeId: true,
/** No watch related fields support in API v1 */
watchAttempts: true,
unwatchAttempts: true,
maxAttempts: true,
});
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams
+1 -1
View File
@@ -38,7 +38,7 @@
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
"next-validations": "^0.2.0",
"typescript": "^4.9.4",
"typescript": "^5.8.3",
"tzdata": "^1.0.30",
"uuid": "^8.3.2",
"zod": "^3.22.4"
@@ -38,7 +38,8 @@ describe("GET /api/bookings", async () => {
expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
expect(groupedUsers.size).toBe(1);
expect(groupedUsers.entries().next().value[0]).toBe(memberUser.id);
const firstEntry = groupedUsers.entries().next().value;
expect(firstEntry?.[0]).toBe(memberUser.id);
});
it("Returns bookings for regular user", async () => {
@@ -89,6 +89,10 @@ describe("POST /api/selected-calendars", () => {
googleChannelResourceUri: null,
googleChannelExpiration: null,
error: null,
lastErrorAt: null,
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
});
await handler(req, res);
@@ -128,6 +132,10 @@ describe("POST /api/selected-calendars", () => {
domainWideDelegationCredentialId: null,
eventTypeId: null,
error: null,
lastErrorAt: null,
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
});
await handler(req, res);
+5 -1
View File
@@ -37,4 +37,8 @@ AXIOM_TOKEN=
# INFO: 1
# WARN: 2
# ERROR: 3
LOGGER_BRIDGE_LOG_LEVEL="1"
LOGGER_BRIDGE_LOG_LEVEL="1"
# 1: Rewrite /api/v2 to /v2
# 0: Don't rewrite
REWRITE_API_V2_PREFIX="1"
+2 -3
View File
@@ -3,7 +3,6 @@
"version": "0.0.1",
"description": "Platform API for Cal.com",
"author": "Cal.com Inc.",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "yarn dev:build && nest build",
@@ -38,7 +37,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.197",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.208",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -109,7 +108,7 @@
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0",
"typescript": "^5.8.2"
"typescript": "^5.8.3"
},
"prisma": {
"schema": "../../../packages/prisma/schema.prisma"
@@ -290,7 +290,8 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reassign a booking to auto-selected host",
description: "The provided authorization header refers to the owner of the booking.",
description:
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
})
async reassignBooking(
@Param("bookingUid") bookingUid: string,
@@ -311,7 +312,8 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reassign a booking to a specific host",
description: "The provided authorization header refers to the owner of the booking.",
description:
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
})
async reassignBookingToUser(
@Param("bookingUid") bookingUid: string,
@@ -55,6 +55,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
let eventTypeId: number;
let seatedEvent: EventType;
let eventTypeWithBookingFields: EventType;
let eventTypeWithBookingFielsCustom: EventType;
const eventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
const eventTypeWithBookingFieldsSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
const seatedEventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
@@ -331,6 +332,313 @@ describe("Bookings Endpoints 2024-08-13", () => {
user.id
);
eventTypeWithBookingFielsCustom = await eventTypesRepositoryFixture.create(
{
title: "test booking field types",
slug: "test-booking-field-types",
length: 30,
bookingFields: [
{
name: "name",
type: "name",
sources: [{ id: "default", type: "default", label: "Default" }],
variant: "fullName",
editable: "system",
required: true,
defaultLabel: "your_name",
variantsConfig: {
variants: {
fullName: {
fields: [
{
name: "fullName",
type: "text",
required: true,
},
],
},
firstAndLastName: {
fields: [
{
name: "firstName",
type: "text",
label: "",
required: true,
placeholder: "",
},
{
name: "lastName",
type: "text",
label: "",
required: false,
placeholder: "",
},
],
},
},
},
},
{
name: "email",
type: "email",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: true,
defaultLabel: "email_address",
},
{
name: "location",
type: "radioInput",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: false,
defaultLabel: "location",
getOptionsAt: "locations",
optionsInputs: {
phone: { type: "phone", required: true, placeholder: "" },
somewhereElse: { type: "text", required: true, placeholder: "" },
attendeeInPerson: { type: "address", required: true, placeholder: "" },
},
hideWhenJustOneOption: true,
},
{
name: "test-phone",
type: "phone",
label: "test-phone",
hidden: false,
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-address",
type: "address",
label: "test-address",
hidden: false,
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-text",
type: "text",
label: "test-text",
hidden: false,
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-number",
type: "number",
label: "test-number",
hidden: false,
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-textarea",
type: "textarea",
label: "test-textarea",
hidden: false,
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-boolean",
type: "boolean",
label: "test-boolean",
hidden: false,
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: false,
labelAsSafeHtml: "<p>test-boolean</p>\n",
disableOnPrefill: false,
},
{
name: "test-url",
type: "url",
label: "test-url",
hidden: false,
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: false,
placeholder: "",
labelAsSafeHtml: "<p>test-url</p>\n",
disableOnPrefill: false,
},
{
name: "test-multiemail",
type: "multiemail",
label: "test-multiemail",
hidden: false,
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-multiselect",
type: "multiselect",
label: "test-multiselect",
hidden: false,
options: [
{
label: "apple",
value: "apple",
},
{
label: "orange",
value: "orange",
},
],
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-checkbox",
type: "checkbox",
label: "test-checkbox",
hidden: false,
options: [
{
label: "blue",
value: "blue",
},
{
label: "red",
value: "red",
},
],
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-radio",
type: "radio",
label: "test-radio",
hidden: false,
options: [
{
label: "pineapple",
value: "pineapple",
},
],
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-select",
type: "select",
label: "test-select",
options: [
{
label: "water",
value: "water",
},
{
label: "juice",
value: "juice",
},
],
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: true,
placeholder: "",
disableOnPrefill: false,
},
{
name: "title",
type: "text",
hidden: true,
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: true,
defaultLabel: "what_is_this_meeting_about",
defaultPlaceholder: "",
},
{
name: "notes",
type: "textarea",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_notes",
defaultPlaceholder: "share_additional_notes",
},
{
name: "guests",
type: "multiemail",
hidden: false,
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_guests",
defaultPlaceholder: "email",
},
{
name: "rescheduleReason",
type: "textarea",
views: [{ id: "reschedule", label: "Reschedule View" }],
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "reason_for_reschedule",
defaultPlaceholder: "reschedule_placeholder",
},
],
},
user.id
);
bookingWithSplitName = await bookingsRepositoryFixture.create({
user: {
connect: {
@@ -543,6 +851,318 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("Booking Field Type Validation", () => {
const basePayload = {
start: "2025-06-19T11:00:00.000Z",
attendee: {
name: "Charlie TypeTest",
email: "charlie.typetest@example.com",
timeZone: "Europe/Madrid",
language: "en",
},
};
it("should reject with 400 if 'test-phone' (phone) is not a string", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-phone": 12345,
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-phone'. Expected type string (compatible with field type 'phone'), but received number."
);
});
it("should reject with 400 if 'test-address' (address) is not a string", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-address": true,
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-address'. Expected type string (compatible with field type 'address'), but received boolean."
);
});
it("should reject with 400 if 'test-text' (text) is not a string", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-text": 123,
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-text'. Expected type string (compatible with field type 'text'), but received number."
);
});
it("should reject with 400 if 'test-number' (number) is not a number", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-number": "123",
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-number'. Expected type number (compatible with field type 'number'), but received string."
);
});
it("should reject with 400 if 'test-textarea' (textarea) is not a string", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-textarea": { text: "invalid" },
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-textarea'. Expected type string (compatible with field type 'textarea'), but received object."
);
});
it("should reject with 400 if 'test-boolean' (boolean) is not a boolean", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-boolean": "true",
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-boolean'. Expected type boolean (compatible with field type 'boolean'), but received string."
);
});
it("should reject with 400 if 'test-url' (url) is not a string", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-url": 12345,
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-url'. Expected type string (compatible with field type 'url'), but received number."
);
});
it("should reject with 400 if 'test-multiemail' (multiemail) is not an array", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-multiemail": "not-an-array@example.com",
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-multiemail'. Expected type array (compatible with field type 'multiemail'), but received string."
);
});
it("should reject with 400 if 'test-multiselect' (multiselect) is not an array", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-multiselect": "not-an-array",
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-multiselect'. Expected type array (compatible with field type 'multiselect'), but received string."
);
});
it("should reject with 400 if 'test-checkbox' (checkbox) is not an array", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-checkbox": true,
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-checkbox'. Expected type array (compatible with field type 'checkbox'), but received boolean."
);
});
it("should reject with 400 if 'test-radio' (radio) is not a string or number", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-radio": true,
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-radio'. Expected type string or number (compatible with field type 'radio'), but received boolean."
);
});
it("should reject with 400 if 'test-select' (select) is not a string or number", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-select": true,
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-select'. Expected type string or number (compatible with field type 'select'), but received boolean."
);
});
it("should throw BadRequestException for test-select field with invalid option", async () => {
const fieldName = "test-select";
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
[fieldName]: "INVALID_DRINK",
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toBe(
`Invalid option 'INVALID_DRINK' for booking field '${fieldName}'. Allowed options are: water, juice.`
);
});
it("should throw BadRequestException for test-radio field with invalid option", async () => {
const fieldName = "test-radio";
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
[fieldName]: "INVALID_FRUIT_FOR_RADIO",
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toBe(
`Invalid option 'INVALID_FRUIT_FOR_RADIO' for booking field '${fieldName}'. Allowed options are: pineapple.`
);
});
it("should throw BadRequestException for test-multiselect field with an invalid option", async () => {
const fieldName = "test-multiselect";
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
[fieldName]: ["apple", "INVALID_FRUIT"],
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toBe(
`One or more invalid options for booking field '${fieldName}'. Allowed options are: apple, orange.`
);
});
it("should throw BadRequestException for test-checkbox field with invalid options", async () => {
const fieldName = "test-checkbox";
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
[fieldName]: ["blue", "INVALID_COLOR"],
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toBe(
`One or more invalid options for booking field '${fieldName}'. Allowed options are: blue, red.`
);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -14,7 +14,7 @@ import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { EventType, User } from "@prisma/client";
import { EventType, User, Workflow } from "@prisma/client";
import { advanceTo, clear } from "jest-date-mock";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
@@ -22,6 +22,8 @@ import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-type
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { WorkflowReminderRepositoryFixture } from "test/fixtures/repository/workflow-reminder.repository.fixture";
import { WorkflowRepositoryFixture } from "test/fixtures/repository/workflow.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
@@ -32,6 +34,7 @@ import {
VERSION_2024_08_13,
X_CAL_CLIENT_ID,
} from "@calcom/platform-constants";
import { AttendeeScheduledEmail, OrganizerScheduledEmail } from "@calcom/platform-libraries/emails";
import {
CreateEventTypeInput_2024_06_14,
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
@@ -60,6 +63,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let workflowReminderRepositoryFixture: WorkflowReminderRepositoryFixture;
let workflowRepositoryFixture: WorkflowRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
@@ -68,6 +73,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
let eventTypeId: number;
let eventType: EventType;
let eventTypeWithAttendeeSmsReminder: EventType;
let workflow: Workflow;
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
let recurringEventTypeId: number;
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
@@ -97,6 +104,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
workflowReminderRepositoryFixture = new WorkflowReminderRepositoryFixture(moduleRef);
workflowRepositoryFixture = new WorkflowRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({
name: `user-bookings-organization-${randomString()}`,
@@ -177,6 +186,85 @@ describe("Bookings Endpoints 2024-08-13", () => {
rating: 10,
});
workflow = await workflowRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
trigger: "BEFORE_EVENT",
time: 24,
timeUnit: "HOUR",
position: 0,
isActiveOnAll: false,
name: "Attendee SMS Reminder",
steps: {
create: {
stepNumber: 1,
action: "SMS_ATTENDEE",
sendTo: null,
reminderBody:
"Hi {ATTENDEE}, this is a reminder that your meeting ({EVENT_NAME}) with {ORGANIZER} is on {EVENT_DATE_YYYY MMM D} at {EVENT_TIME_h:mma} {TIMEZONE}.",
emailSubject: "Reminder: {EVENT_NAME} - {EVENT_DATE_ddd, MMM D, YYYY h:mma}",
template: "REMINDER",
numberRequired: true,
sender: "Cal",
numberVerificationPending: false,
includeCalendarEvent: false,
verifiedAt: new Date(),
},
},
});
eventTypeWithAttendeeSmsReminder = await eventTypesRepositoryFixture.create(
{
title: "event with attendee sms reminder",
slug: "event-with-attendee-sms-reminder",
length: 15,
bookingFields: [
{
name: "smsReminderNumber",
type: "phone",
sources: [
{
id: String(workflow.id),
type: "workflow",
label: "Workflow",
editUrl: `/workflows/${workflow.id}`,
fieldRequired: true,
},
],
editable: "system",
required: true,
defaultLabel: "number_text_notifications",
defaultPlaceholder: "enter_phone_number",
},
],
metadata: {
disableStandardEmails: {
all: {
attendee: true,
host: true,
},
confirmation: {
host: true,
attendee: true,
},
},
},
workflows: {
create: {
workflow: {
connect: {
id: workflow.id,
},
},
},
},
},
user.id
);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -1668,7 +1756,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
describe("book by username and event type slug", () => {
it("should not create a booking by if neither event type id nor username and event type slug provided", async () => {
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
const body = {
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
attendee: {
@@ -1677,7 +1764,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
timeZone: "Europe/Rome",
language: "it",
},
location: googleMeetUrl,
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
@@ -2227,6 +2314,231 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("create booking with attendee sms reminder", () => {
it("should not be able create a booking if attendee sms reminder is missing", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
eventTypeId: eventTypeWithAttendeeSmsReminder.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Missing attendee phone number - it is required by the event type. Pass it as \"attendee.phoneNumber\" string in the request.`
);
});
it("should be able create a booking if attendee sms reminder is provided", async () => {
const phoneNumber = "+37122222222";
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
eventTypeId: eventTypeWithAttendeeSmsReminder.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
phoneNumber,
},
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201);
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 15, 15, 0)).toISOString());
expect(data.duration).toEqual(15);
expect(data.eventTypeId).toEqual(eventTypeWithAttendeeSmsReminder.id);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
phoneNumber: body.attendee.phoneNumber,
absent: false,
});
expect(data.bookingFieldsResponses).toEqual({
name: body.attendee.name,
email: body.attendee.email,
attendeePhoneNumber: body.attendee.phoneNumber,
smsReminderNumber: body.attendee.phoneNumber,
});
const dbBooking = await bookingsRepositoryFixture.getById(data.id);
expect(dbBooking?.smsReminderNumber).toEqual(phoneNumber);
const dbWorkflowReminder = await workflowReminderRepositoryFixture.getByBookingUid(data.uid);
expect(dbWorkflowReminder?.method).toEqual("SMS");
} else {
throw new Error("Invalid response data");
}
});
});
describe("cant't reschedule cancelled booking", () => {
it("should not be able to reschedule cancelled booking", async () => {
const cancelledBooking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `cancelled-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${cancelledBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Can't reschedule booking with uid=${cancelledBooking.uid} because it has been cancelled. Please provide uid of a booking that is not cancelled.`
);
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
});
it("should not be able to reschedule rescheduled booking", async () => {
const rescheduledBooking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
rescheduled: true,
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `cancelled-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const newBooking = await bookingsRepositoryFixture.create({
status: "ACCEPTED",
user: {
connect: {
id: user.id,
},
},
fromReschedule: rescheduledBooking.uid,
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `new-booking-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
});
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Can't reschedule booking with uid=${rescheduledBooking.uid} because it has been cancelled and rescheduled already to booking with uid=${newBooking.uid}. You probably want to reschedule ${newBooking.uid} instead by passing it within the request URL.`
);
await bookingsRepositoryFixture.deleteById(rescheduledBooking.id);
await bookingsRepositoryFixture.deleteById(newBooking.id);
});
});
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
return (
!Array.isArray(data) &&
@@ -5,6 +5,7 @@ import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servic
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { getPagination } from "@/lib/pagination/pagination";
import { BillingService } from "@/modules/billing/services/billing.service";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
@@ -20,6 +21,7 @@ import { UsersRepository, UserWithProfile } from "@/modules/users/users.reposito
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException } from "@nestjs/common";
import { Request } from "express";
import { DateTime } from "luxon";
import { z } from "zod";
import {
@@ -59,13 +61,17 @@ type CreatedBooking = {
start: string;
};
const eventTypeBookingFieldSchema = z.object({
name: z.string(),
required: z.boolean(),
editable: z.string(),
});
const eventTypeBookingFieldSchema = z
.object({
name: z.string(),
required: z.boolean(),
editable: z.string(),
type: z.string(),
options: z.array(z.object({ value: z.string() })).optional(),
})
.passthrough();
const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
export const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null };
@@ -103,6 +109,12 @@ export class BookingsService_2024_08_13 {
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
}
if (eventType.schedulingType === "MANAGED") {
throw new BadRequestException(
`Event type with id=${eventType.id} is the parent managed event type that can't be booked. You have to provide the child event type id aka id of event type that has been assigned to one of the users.`
);
}
body.eventTypeId = eventType.id;
if ("instant" in body && body.instant) {
@@ -171,36 +183,187 @@ export class BookingsService_2024_08_13 {
}
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
const bookingFields = { ...body.bookingFieldsResponses, attendeePhoneNumber: body.attendee.phoneNumber };
const bookingFieldsResponses: Record<string, unknown> = {
...body.bookingFieldsResponses,
attendeePhoneNumber: body.attendee.phoneNumber,
smsReminderNumber: body.attendee.phoneNumber,
};
if (!eventType?.bookingFields) {
return true;
}
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee. Only exception
// is smsReminderNumber, because if it is required and not passed sms workflow won't work.
const eventTypeBookingFields = eventTypeBookingFieldsSchema
.parse(eventType.bookingFields)
.filter((field) => !field.editable.startsWith("system"));
.filter((field) => !field.editable.startsWith("system") || field.name === "smsReminderNumber");
if (!eventTypeBookingFields.length) {
return true;
}
for (const field of eventTypeBookingFields) {
if (field.required && !(field.name in bookingFields)) {
if (field.name === "attendeePhoneNumber") {
for (const eventTypeBookingField of eventTypeBookingFields) {
if (
eventTypeBookingField.required &&
(bookingFieldsResponses[eventTypeBookingField.name] === null ||
bookingFieldsResponses[eventTypeBookingField.name] === undefined)
) {
if (
eventTypeBookingField.name === "attendeePhoneNumber" ||
eventTypeBookingField.name === "smsReminderNumber"
) {
throw new BadRequestException(
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" in the request.`
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" string in the request.`
);
}
throw new BadRequestException(
`Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. You can fetch the event type with ID ${eventType.id} to see the required fields.`
`Missing required booking field response: ${eventTypeBookingField.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. You can fetch the event type with ID ${eventType.id} to see the required fields.`
);
}
const bookingFieldResponseValue = bookingFieldsResponses[eventTypeBookingField.name];
if (bookingFieldResponseValue !== undefined) {
const bookingFieldResponseValueType = typeof bookingFieldResponseValue;
let expectedBookingFieldResponseValueType = "";
let isValidType = false;
const eventTypeBookingFieldType = eventTypeBookingField.type;
switch (eventTypeBookingFieldType) {
case "phone":
expectedBookingFieldResponseValueType = "string";
isValidType = bookingFieldResponseValueType === "string";
break;
case "address":
expectedBookingFieldResponseValueType = "string";
isValidType = bookingFieldResponseValueType === "string";
break;
case "text":
expectedBookingFieldResponseValueType = "string";
isValidType = bookingFieldResponseValueType === "string";
break;
case "number":
expectedBookingFieldResponseValueType = "number";
isValidType = bookingFieldResponseValueType === "number";
break;
case "textarea":
expectedBookingFieldResponseValueType = "string";
isValidType = bookingFieldResponseValueType === "string";
break;
case "multiemail":
expectedBookingFieldResponseValueType = "array";
isValidType = Array.isArray(bookingFieldResponseValue);
break;
case "boolean":
expectedBookingFieldResponseValueType = "boolean";
isValidType = bookingFieldResponseValueType === "boolean";
break;
case "select":
expectedBookingFieldResponseValueType = "string or number";
isValidType =
bookingFieldResponseValueType === "string" || bookingFieldResponseValueType === "number";
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
const submittedValue = bookingFieldResponseValue as string | number;
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
throw new BadRequestException(
`Invalid option '${submittedValue}' for booking field '${
eventTypeBookingField.name
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
);
}
}
break;
case "multiselect":
expectedBookingFieldResponseValueType = "array";
isValidType = Array.isArray(bookingFieldResponseValue);
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
const submittedValues = bookingFieldResponseValue as (string | number)[];
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
throw new BadRequestException(
`One or more invalid options for booking field '${
eventTypeBookingField.name
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
);
}
}
break;
case "checkbox":
expectedBookingFieldResponseValueType = "array";
isValidType = Array.isArray(bookingFieldResponseValue);
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
const submittedValues = bookingFieldResponseValue as (string | number)[];
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
throw new BadRequestException(
`One or more invalid options for booking field '${
eventTypeBookingField.name
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
);
}
}
break;
case "radio":
expectedBookingFieldResponseValueType = "string or number";
isValidType =
bookingFieldResponseValueType === "string" || bookingFieldResponseValueType === "number";
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
const submittedValue = bookingFieldResponseValue as string | number;
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
throw new BadRequestException(
`Invalid option '${submittedValue}' for booking field '${
eventTypeBookingField.name
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
);
}
}
break;
case "url":
expectedBookingFieldResponseValueType = "string";
isValidType = bookingFieldResponseValueType === "string";
break;
default:
// note(Lauris): by default pass the field if we have a missing "case" in the switch
isValidType = true;
break;
}
if (!isValidType) {
throw new BadRequestException(
`Invalid type for booking field '${eventTypeBookingField.name}'. Expected type ${expectedBookingFieldResponseValueType} (compatible with field type '${eventTypeBookingFieldType}'), but received ${bookingFieldResponseValueType}.`
);
}
}
}
return true;
}
private isValidSingleOptionValue(
bookingFieldResponseValue: string | number,
eventTypeBookingFieldOptions: string[]
): boolean {
if (eventTypeBookingFieldOptions.length === 0) {
// note(Lauris): If no options defined, cannot validate against them, so pass.
return true;
}
return eventTypeBookingFieldOptions.some((val) => val === String(bookingFieldResponseValue));
}
private areValidMultipleOptionValues(
bookingFieldResponseValues: (string | number)[],
eventTypeBookingFieldOptions: string[]
): boolean {
if (eventTypeBookingFieldOptions.length === 0) {
// note(Lauris): If no options defined, cannot validate against them, so pass.
return true;
}
return bookingFieldResponseValues.every((submittedVal) =>
eventTypeBookingFieldOptions.some((allowedVal) => allowedVal === String(submittedVal))
);
}
async createInstantBooking(
request: Request,
body: CreateInstantBookingInput_2024_08_13,
@@ -376,10 +539,13 @@ export class BookingsService_2024_08_13 {
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
}
const skip = Math.abs(queryParams?.skip ?? 0);
const take = Math.abs(queryParams?.take ?? 100);
const fetchedBookings: { bookings: { id: number }[]; totalCount: number } = await getAllUserBookings({
bookingListingByStatus: queryParams.status || [],
skip: queryParams.skip ?? 0,
take: queryParams.take ?? 100,
skip,
take,
filters: {
...this.inputService.transformGetBookingsFilters(queryParams),
...(userIds?.length ? { userIds } : {}),
@@ -432,28 +598,11 @@ export class BookingsService_2024_08_13 {
}
}
const skip = Math.abs(queryParams?.skip ?? 0);
const take = Math.abs(queryParams?.take ?? 100);
const itemsPerPage = take;
const totalPages = itemsPerPage !== 0 ? Math.ceil(fetchedBookings.totalCount / itemsPerPage) : 0;
const currentPage = Math.floor(skip / itemsPerPage) + 1;
const hasNextPage = skip + itemsPerPage < fetchedBookings.totalCount;
const hasPreviousPage = skip > 0;
const pagination = getPagination({ skip, take, totalCount: fetchedBookings.totalCount });
return {
bookings: formattedBookings,
pagination: {
totalItems: fetchedBookings.totalCount,
// clamp remainingItems between 0 and totalCount
remainingItems: Math.min(
Math.max(fetchedBookings.totalCount - (skip + take), 0),
fetchedBookings.totalCount
),
itemsPerPage: itemsPerPage,
currentPage: currentPage,
totalPages: totalPages,
hasNextPage: hasNextPage,
hasPreviousPage: hasPreviousPage,
},
pagination,
};
}
@@ -488,6 +637,7 @@ export class BookingsService_2024_08_13 {
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput) {
try {
await this.canRescheduleBooking(bookingUid);
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
request,
bookingUid,
@@ -533,15 +683,29 @@ export class BookingsService_2024_08_13 {
}
return this.outputService.getOutputBooking(databaseBooking);
} catch (error) {
if (error instanceof Error) {
if (error.message === "no_available_users_found_error") {
throw new BadRequestException("User either already has booking at this time or is not available");
}
}
throw error;
this.errorsBookingsService.handleBookingError(error, false);
}
}
async canRescheduleBooking(bookingUid: string) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new Error(`Booking with uid=${bookingUid} was not found in the database`);
}
if (booking.status === "CANCELLED" && !booking.rescheduled) {
throw new BadRequestException(
`Can't reschedule booking with uid=${bookingUid} because it has been cancelled. Please provide uid of a booking that is not cancelled.`
);
}
if (booking.status === "CANCELLED" && booking.rescheduled) {
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
throw new BadRequestException(
`Can't reschedule booking with uid=${bookingUid} because it has been cancelled and rescheduled already to booking with uid=${rescheduledTo?.uid}. You probably want to reschedule ${rescheduledTo?.uid} instead by passing it within the request URL.`
);
}
return booking;
}
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput) {
if (this.inputService.isCancelSeatedBody(body)) {
const seat = await this.bookingSeatRepository.getByReferenceUid(body.seatUid);
@@ -594,6 +758,20 @@ export class BookingsService_2024_08_13 {
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
if (!bookingBefore) {
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
}
const nowUtc = DateTime.utc();
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
if (nowUtc < bookingStartTimeUtc) {
throw new BadRequestException(
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
);
}
const platformClientParams = bookingBefore?.eventTypeId
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
: undefined;
@@ -1,5 +1,8 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { EventTypeWithOwnerAndTeam } from "@/ee/bookings/2024-08-13/services/bookings.service";
import {
eventTypeBookingFieldsSchema,
EventTypeWithOwnerAndTeam,
} from "@/ee/bookings/2024-08-13/services/bookings.service";
import {
bookingResponsesSchema,
seatedBookingDataSchema,
@@ -159,6 +162,12 @@ export class InputBookingsService_2024_08_13 {
this.isBookingLocationWithEventTypeLocations(inputLocation, eventType);
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
const needsSmsReminderNumber = eventType.bookingFields
? eventTypeBookingFieldsSchema
.parse(eventType.bookingFields)
.find((field) => field.name === "smsReminderNumber")
: undefined;
return {
start: startTime.toISO(),
end: endTime.toISO(),
@@ -174,6 +183,7 @@ export class InputBookingsService_2024_08_13 {
name: inputBooking.attendee.name,
email: attendeeEmail ?? "",
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
smsReminderNumber: needsSmsReminderNumber ? inputBooking.attendee.phoneNumber : undefined,
guests,
location,
},
@@ -1,4 +1,4 @@
import { OrganizerIntegrationLocation } from "@calcom/lib";
import { OrganizerIntegrationLocation } from "@calcom/lib/event-types/transformers/internal/locations";
type BaseEventType = {
length: number;
@@ -1428,7 +1428,6 @@ describe("Event types Endpoints", () => {
{
name: "location",
type: "radioInput",
label: "",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: false,
@@ -1558,7 +1557,6 @@ describe("Event types Endpoints", () => {
{
name: "location",
type: "radioInput",
label: "",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: false,
@@ -1783,6 +1781,68 @@ describe("Event types Endpoints", () => {
});
});
describe("creating event type with input of another event type output", () => {
let firstCreatedEventType: EventTypeOutput_2024_06_14;
let secondCreatedEventType: EventTypeOutput_2024_06_14;
it("should create first event type", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "first created coding class",
slug: "first-created-coding-class",
lengthInMinutes: 60,
locations: [
{
type: "address",
address: "via volturno 10, Roma",
public: true,
},
],
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.locations).toEqual(body.locations);
firstCreatedEventType = responseBody.data;
});
});
it("should create second event type using first as input", async () => {
const body = {
...firstCreatedEventType,
title: "second created coding class",
slug: "second-created-coding-class",
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(body.title);
secondCreatedEventType = responseBody.data;
const { id, title, slug, ...restFirst } = firstCreatedEventType;
const { id: id2, title: title2, slug: slug2, ...restSecond } = secondCreatedEventType;
expect(restFirst).toEqual(restSecond);
expect(id2).not.toEqual(id);
expect(title2).not.toEqual(title);
expect(slug2).not.toEqual(slug);
});
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -224,6 +224,7 @@ export class InputEventTypesService_2024_06_14 {
const systemCustomNameField = systemCustomFields?.find((field) => field.type === "name");
const systemCustomEmailField = systemCustomFields?.find((field) => field.type === "email");
const systemCustomTitleField = systemCustomFields?.find((field) => field.name === "title");
const systemCustomLocationField = systemCustomFields?.find((field) => field.name === "location");
const systemCustomNotesField = systemCustomFields?.find((field) => field.name === "notes");
const systemCustomGuestsField = systemCustomFields?.find((field) => field.name === "guests");
const systemCustomRescheduleReasonField = systemCustomFields?.find(
@@ -233,7 +234,7 @@ export class InputEventTypesService_2024_06_14 {
const defaultFieldsBefore: (SystemField | CustomField)[] = [
systemCustomNameField || systemBeforeFieldName,
systemCustomEmailField || systemBeforeFieldEmail,
systemBeforeFieldLocation,
systemCustomLocationField || systemBeforeFieldLocation,
];
const defaultFieldsAfter = [
@@ -274,7 +275,8 @@ export class InputEventTypesService_2024_06_14 {
field.name !== "title" &&
field.name !== "notes" &&
field.name !== "guests" &&
field.name !== "rescheduleReason"
field.name !== "rescheduleReason" &&
field.name !== "location"
);
}
+2
View File
@@ -32,6 +32,8 @@ export type Environment = {
AXIOM_TOKEN: string;
STRIPE_TEAM_MONTHLY_PRICE_ID: string;
IS_TEAM_BILLING_ENABLED: boolean;
// Used to enable/disable the rewrite of /api/v2 to /v2, active by default.
REWRITE_API_V2_PREFIX: string;
};
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
@@ -0,0 +1,101 @@
import { getPagination, clamp } from "./pagination";
describe("getPagination", () => {
it("handles the first page correctly", () => {
const pagination = getPagination({ skip: 0, take: 10, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 10,
totalItems: 35,
itemsPerPage: 10,
remainingItems: 25,
currentPage: 1,
totalPages: 4,
hasNextPage: true,
hasPreviousPage: false,
});
});
it("handles a middle page correctly", () => {
const pagination = getPagination({ skip: 10, take: 10, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 10,
totalItems: 35,
itemsPerPage: 10,
remainingItems: 15,
currentPage: 2,
totalPages: 4,
hasNextPage: true,
hasPreviousPage: true,
});
});
it("handles the last page when it is not completely full", () => {
const pagination = getPagination({ skip: 30, take: 10, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 5,
totalItems: 35,
itemsPerPage: 10,
remainingItems: 0,
currentPage: 4,
totalPages: 4,
hasNextPage: false,
hasPreviousPage: true,
});
});
it("clamps skip values that exceed the total item count", () => {
const pagination = getPagination({ skip: 40, take: 10, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 0,
totalItems: 35,
itemsPerPage: 10,
remainingItems: 0,
currentPage: 4,
totalPages: 4,
hasNextPage: false,
hasPreviousPage: true,
});
});
it("works when itemsPerPage (take) is zero", () => {
const pagination = getPagination({ skip: 0, take: 0, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 0,
totalItems: 35,
itemsPerPage: 0,
remainingItems: 35,
currentPage: 0,
totalPages: 0,
hasNextPage: true,
hasPreviousPage: false,
});
});
it("works when the collection is empty", () => {
const pagination = getPagination({ skip: 0, take: 10, totalCount: 0 });
expect(pagination).toEqual({
returnedItems: 0,
totalItems: 0,
itemsPerPage: 10,
remainingItems: 0,
currentPage: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
});
});
});
describe("clamp", () => {
it("returns the value unchanged when it is inside the range", () => {
expect(clamp({ value: 5, min: 1, max: 10 })).toBe(5);
});
it("clamps values below the minimum", () => {
expect(clamp({ value: -3, min: 0, max: 10 })).toBe(0);
});
it("clamps values above the maximum", () => {
expect(clamp({ value: 42, min: 0, max: 10 })).toBe(10);
});
});
@@ -0,0 +1,78 @@
import { PaginationMetaDto } from "@calcom/platform-types";
type Pagination = {
skip: number;
take: number;
totalCount: number;
};
export function getPagination(pagination: Pagination): PaginationMetaDto {
const { skip, take, totalCount } = pagination;
const safeSkip = clamp({ value: skip, min: 0, max: totalCount });
const itemsPerPage = take;
const remainingItems = getRemainingItems(safeSkip, itemsPerPage, totalCount);
const totalPages = getTotalPages(itemsPerPage, totalCount);
const currentPage = getCurrentPage(safeSkip, itemsPerPage, totalPages);
const hasNextPage = getHasNextPage(safeSkip, itemsPerPage, totalCount);
const hasPreviousPage = getHasPreviousPage(safeSkip);
const returnedItems = getReturnedItems(safeSkip, itemsPerPage, totalCount);
return {
returnedItems,
totalItems: totalCount,
itemsPerPage,
remainingItems,
currentPage,
totalPages,
hasNextPage,
hasPreviousPage,
};
}
function getRemainingItems(skip: number, itemsPerPage: number, totalCount: number) {
return clamp({
value: totalCount - (skip + itemsPerPage),
min: 0,
max: totalCount,
});
}
function getTotalPages(itemsPerPage: number, totalCount: number) {
return itemsPerPage !== 0 ? Math.ceil(totalCount / itemsPerPage) : 0;
}
function getCurrentPage(skip: number, itemsPerPage: number, totalPages: number) {
const rawCurrentPage = Math.floor(skip / itemsPerPage) + 1;
if (totalPages === 0) {
return 0;
}
return clamp({ value: rawCurrentPage, min: 1, max: totalPages });
}
function getHasNextPage(skip: number, itemsPerPage: number, totalCount: number) {
return skip + itemsPerPage < totalCount;
}
function getHasPreviousPage(skip: number) {
return skip > 0;
}
function getReturnedItems(skip: number, itemsPerPage: number, totalCount: number) {
const remaining = totalCount - skip;
return clamp({
value: remaining,
min: 0,
max: itemsPerPage,
});
}
type ClampArgs = {
value: number;
min: number;
max: number;
};
export function clamp({ value, min, max }: ClampArgs): number {
return Math.max(min, Math.min(value, max));
}
@@ -1,10 +1,11 @@
import { getEnv } from "@/env";
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response } from "express";
@Injectable()
export class RewriterMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => void) {
if (req.url.startsWith("/api/v2")) {
if (shouldRewriteApiV2Prefix() && req.url.startsWith("/api/v2")) {
req.url = req.url.replace("/api/v2", "/v2");
}
if (req.url.startsWith("/v2/ee")) {
@@ -16,3 +17,12 @@ export class RewriterMiddleware implements NestMiddleware {
next();
}
}
const shouldRewriteApiV2Prefix = () => {
try {
// rewrite is active even if REWRITE_API_V2_PREFIX is not set
return getEnv("REWRITE_API_V2_PREFIX", "1") === "1";
} catch (error) {
return true;
}
};
@@ -226,20 +226,26 @@ export class EventTypesAtomService {
} else {
credentials = credentials.concat(teamAppCredentials);
}
} else {
if (slug !== "stripe") {
// We only add delegationCredentials if the request for location options is for a user because DelegationCredential Credential is applicable to Users only.
const { credentials: allCredentials } =
await enrichUserWithDelegationConferencingCredentialsWithoutOrgId({
user: {
...user,
credentials,
},
});
credentials = allCredentials;
}
}
// We only add delegationCredentials if the request for location options is for a user because DelegationCredential Credential is applicable to Users only.
const { credentials: allCredentials } = await enrichUserWithDelegationConferencingCredentialsWithoutOrgId(
const enabledApps = await getEnabledAppsFromCredentials(
credentials as unknown as CredentialDataWithTeamName[],
{
user: {
...user,
credentials,
},
where: { slug },
}
);
credentials = allCredentials;
const enabledApps = await getEnabledAppsFromCredentials(allCredentials, {
where: { slug },
});
const apps = await Promise.all(
enabledApps
.filter(({ ...app }) => app.slug === slug)
@@ -1,6 +1,6 @@
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { PlatformPlanType } from "@/modules/billing/types";
import { PlatformPlanType, orderedPlans } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
@@ -61,7 +61,7 @@ export class PlatformPlanGuard implements CanActivate {
!hasMinimumPlan({
currentPlan: organization.platformBilling?.plan as PlatformPlanType,
minimumPlan: minimumPlan,
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
plans: orderedPlans,
})
) {
throw new ForbiddenException(
@@ -79,7 +79,7 @@ export class PlatformPlanGuard implements CanActivate {
type HasMinimumPlanProp = {
currentPlan: PlatformPlanType;
minimumPlan: PlatformPlanType;
plans: PlatformPlanType[];
plans: readonly PlatformPlanType[];
};
export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
@@ -87,7 +87,7 @@ export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
const minimumPlanIndex = props.plans.indexOf(props.minimumPlan);
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
throw new Error(
throw new ForbiddenException(
`PlatformPlanGuard - Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
);
}
@@ -124,6 +124,9 @@ export class BillingController {
case "checkout.session.completed":
await this.billingService.handleStripeCheckoutEvents(event);
break;
case "customer.subscription.updated":
await this.billingService.handleStripePaymentPastDue(event);
break;
case "customer.subscription.deleted":
await this.billingService.handleStripeSubscriptionDeleted(event);
break;
@@ -216,6 +216,30 @@ export class BillingService implements OnModuleDestroy {
}
}
async handleStripePaymentPastDue(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
const customerId = this.getCustomerIdFromInvoice(invoice);
if (subscriptionId && customerId) {
const existingUserSubscription = await this.stripeService
.getStripe()
.subscriptions.retrieve(subscriptionId);
if (existingUserSubscription.status === "past_due") {
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, true);
}
if (existingUserSubscription.status === "active") {
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, false);
}
}
if (!subscriptionId || !customerId) {
this.logger.log(`SubscriptionId: ${subscriptionId} or customerId: ${customerId} missing`);
}
}
async handleStripeCheckoutEvents(event: Stripe.Event) {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
+10 -1
View File
@@ -7,4 +7,13 @@ export enum PlatformPlan {
PER_ACTIVE_USER = "PER_ACTIVE_USER",
}
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE" | "PER_ACTIVE_USER";
export const orderedPlans = [
"FREE",
"STARTER",
"ESSENTIALS",
"SCALE",
"PER_ACTIVE_USER",
"ENTERPRISE",
] as const;
export type PlatformPlanType = (typeof orderedPlans)[number];
@@ -177,7 +177,7 @@ export class OAuthClientUsersController {
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
await this.tokensRepository.createOAuthTokens(oAuthClientId, id, true);
await this.tokensRepository.forceRefreshOAuthTokens(oAuthClientId, id);
return {
status: SUCCESS_STATUS,
@@ -21,7 +21,7 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withNextAuth } from "test/utils/withNextAuth";
import { withApiAuth } from "test/utils/withApiAuth";
import { PlatformOAuthClient, Team, Webhook } from "@calcom/prisma/client";
@@ -47,7 +47,7 @@ describe("OAuth client WebhooksController (e2e)", () => {
let webhook: OAuthClientWebhookOutputResponseDto["data"];
beforeAll(async () => {
const moduleRef = await withNextAuth(
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
@@ -1,6 +1,6 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator";
import { IsOAuthClientWebhookGuard } from "@/modules/webhooks/guards/is-oauth-client-webhook-guard";
@@ -29,7 +29,7 @@ import { OAuthClientGuard } from "../../guards/oauth-client-guard";
path: "/v2/oauth-clients/:clientId/webhooks",
version: API_VERSIONS_VALUES,
})
@UseGuards(NextAuthGuard, OrganizationRolesGuard, OAuthClientGuard)
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, OAuthClientGuard)
@DocsTags("Platform / Webhooks")
@ApiHeader({
name: X_CAL_SECRET_KEY,
@@ -1,11 +1,12 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@calcom/prisma/client";
@Injectable()
export class OrganizationsDelegationCredentialRepository {
constructor(private readonly dbRead: PrismaReadService) {}
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async findById(delegationCredentialId: string) {
return this.dbRead.prisma.delegationCredential.findUnique({ where: { id: delegationCredentialId } });
@@ -22,7 +23,7 @@ export class OrganizationsDelegationCredentialRepository {
delegationCredentialId: string,
data: Prisma.DelegationCredentialUncheckedUpdateInput
) {
return this.dbRead.prisma.delegationCredential.update({
return this.dbWrite.prisma.delegationCredential.update({
where: { id: delegationCredentialId },
data,
include: { workspacePlatform: true },
@@ -47,7 +47,7 @@ export class OrganizationsDelegationCredentialService {
delegatedServiceAccountUser: User,
body: UpdateDelegationCredentialInput
) {
const delegationCredential =
let delegationCredential =
await this.organizationsDelegationCredentialRepository.findByIdWithWorkspacePlatform(
delegationCredentialId
);
@@ -56,6 +56,14 @@ export class OrganizationsDelegationCredentialService {
throw new NotFoundException(`DelegationCredential with id ${delegationCredentialId} not found`);
}
if (body.serviceAccountKey !== undefined) {
const updatedDelegationCredential = await this.updateDelegationCredentialServiceAccountKey(
delegationCredential.id,
body.serviceAccountKey
);
delegationCredential = updatedDelegationCredential ?? delegationCredential;
}
if (body.enabled !== undefined) {
await this.updateDelegationCredentialEnabled(
orgId,
@@ -64,16 +72,13 @@ export class OrganizationsDelegationCredentialService {
body.enabled
);
}
if (body.serviceAccountKey !== undefined) {
await this.updateDelegationCredentialServiceAccountKey(delegationCredentialId, body.serviceAccountKey);
}
// once delegation credentials are enabled, slowly set all the destination calendars of delegated users
if (body.enabled === true && delegationCredential.enabled === false) {
await this.ensureDefaultCalendars(orgId, delegationCredential.domain);
}
return { ...delegationCredential, enabled: body?.enabled ?? delegationCredential?.enabled };
return { ...delegationCredential, enabled: body?.enabled ?? delegationCredential.enabled };
}
async ensureDefaultCalendars(orgId: number, domain: string) {
@@ -132,6 +137,7 @@ export class OrganizationsDelegationCredentialService {
delegationCredentialId,
{
serviceAccountKey: encryptedServiceAccountKey,
enabled: false,
}
);
return delegationCredential;
@@ -89,11 +89,13 @@ describe("Organizations Event Types Endpoints", () => {
teammate1 = await userRepositoryFixture.create({
email: teammate1Email,
username: teammate1Email,
name: "alice",
});
teammate2 = await userRepositoryFixture.create({
email: teammate2Email,
username: teammate2Email,
name: "bob",
});
falseTestUser = await userRepositoryFixture.create({
@@ -432,6 +434,21 @@ describe("Organizations Event Types Endpoints", () => {
const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id);
expect(responseTeamEvent).toBeDefined();
expect(responseTeamEvent?.hosts).toEqual([
{
userId: teammate1.id,
name: teammate1.name,
username: teammate1.username,
avatarUrl: teammate1.avatarUrl,
},
{
userId: teammate2.id,
name: teammate2.name,
username: teammate2.username,
avatarUrl: teammate2.avatarUrl,
},
]);
if (!responseTeamEvent) {
throw new Error("Team event not found");
}
@@ -93,7 +93,7 @@ export class OutputOrganizationsEventTypesService {
const hosts =
databaseEventType.schedulingType === "MANAGED"
? await this.getManagedEventTypeHosts(databaseEventType.id)
: await this.transformHosts(databaseEventType.hosts, databaseEventType.schedulingType);
: await this.getNonManagedEventTypeHosts(databaseEventType.hosts, databaseEventType.schedulingType);
return {
...rest,
@@ -138,13 +138,18 @@ export class OutputOrganizationsEventTypesService {
for (const child of children) {
if (child.userId) {
const user = await this.usersRepository.findById(child.userId);
transformedHosts.push({ userId: child.userId, name: user?.name || "" });
transformedHosts.push({
userId: child.userId,
name: user?.name || "",
username: user?.username || "",
avatarUrl: user?.avatarUrl,
});
}
}
return transformedHosts;
}
async transformHosts(
async getNonManagedEventTypeHosts(
databaseHosts: Host[],
schedulingType: SchedulingType | null
): Promise<TeamEventTypeResponseHost[]> {
@@ -160,6 +165,7 @@ export class OutputOrganizationsEventTypesService {
transformedHosts.push({
userId: databaseHost.userId,
name: databaseUser?.name || "",
username: databaseUser?.username || "",
mandatory: !!databaseHost.isFixed,
priority: getPriorityLabel(databaseHost.priority || 2),
avatarUrl: databaseUser?.avatarUrl,
@@ -168,6 +174,7 @@ export class OutputOrganizationsEventTypesService {
transformedHosts.push({
userId: databaseHost.userId,
name: databaseUser?.name || "",
username: databaseUser?.username || "",
avatarUrl: databaseUser?.avatarUrl,
});
}
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { SkipTakePagination } from "@calcom/platform-types";
import { Prisma } from "@calcom/prisma/client";
@Injectable()
@@ -34,11 +35,26 @@ export class ManagedOrganizationsRepository {
});
}
async getByManagerOrganizationId(managerOrganizationId: number) {
return this.dbRead.prisma.managedOrganization.findMany({
where: {
managerOrganizationId,
},
});
async getByManagerOrganizationIdPaginated(managerOrganizationId: number, pagination: SkipTakePagination) {
const { skip, take } = pagination;
const where: Prisma.ManagedOrganizationWhereInput = {
managerOrganizationId,
};
const [totalItems, linkRows] = await this.dbRead.prisma.$transaction([
this.dbRead.prisma.managedOrganization.count({ where }),
this.dbRead.prisma.managedOrganization.findMany({
where,
skip,
take,
orderBy: { managedOrganizationId: "asc" },
include: { managedOrganization: true },
}),
]);
const items = linkRows.map((l) => l.managedOrganization);
return { totalItems, items };
}
}
@@ -7,6 +7,7 @@ import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controller
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
import { CreateOrganizationInput } from "@/modules/organizations/organizations/inputs/create-managed-organization.input";
import { UpdateOrganizationInput } from "@/modules/organizations/organizations/inputs/update-managed-organization.input";
import { GetManagedOrganizationsOutput } from "@/modules/organizations/organizations/outputs/get-managed-organizations.output";
import {
ManagedOrganizationWithApiKeyOutput,
ManagedOrganizationOutput,
@@ -304,7 +305,7 @@ describe("Organizations Organizations Endpoints", () => {
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationOutput[]> = response.body;
const responseBody: GetManagedOrganizationsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseManagedOrgs = responseBody.data;
expect(responseManagedOrgs?.length).toEqual(1);
@@ -312,6 +313,14 @@ describe("Organizations Organizations Endpoints", () => {
expect(responseManagedOrg?.id).toBeDefined();
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
expect(responseBody.pagination).toBeDefined();
expect(responseBody.pagination.totalItems).toEqual(1);
expect(responseBody.pagination.remainingItems).toEqual(0);
expect(responseBody.pagination.returnedItems).toEqual(1);
expect(responseBody.pagination.itemsPerPage).toEqual(250);
expect(responseBody.pagination.currentPage).toEqual(1);
expect(responseBody.pagination.totalPages).toEqual(1);
});
});
@@ -28,10 +28,12 @@ import {
HttpCode,
HttpStatus,
Delete,
Query,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
const SCALE = "SCALE";
@@ -99,14 +101,15 @@ export class OrganizationsOrganizationsController {
"Requires the user to have at least the 'ORG_ADMIN' role within the organization. Additionally, for platform, the plan must be 'SCALE' or higher to access this endpoint.",
})
async getOrganizations(
@Param("orgId", ParseIntPipe) managerOrganizationId: number
@Param("orgId", ParseIntPipe) managerOrganizationId: number,
@Query() queryPagination: SkipTakePagination
): Promise<GetManagedOrganizationsOutput> {
const organizations = await this.managedOrganizationsService.getManagedOrganizations(
managerOrganizationId
);
const { organizations, pagination: responsePagination } =
await this.managedOrganizationsService.getManagedOrganizations(managerOrganizationId, queryPagination);
return {
status: SUCCESS_STATUS,
data: organizations,
pagination: responsePagination,
};
}
@@ -4,6 +4,7 @@ import { Expose, Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { PaginationMetaDto } from "@calcom/platform-types";
export class GetManagedOrganizationsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@@ -17,4 +18,9 @@ export class GetManagedOrganizationsOutput {
@ValidateNested({ each: true })
@Type(() => ManagedOrganizationOutput)
data!: ManagedOrganizationOutput[];
@ApiProperty({ type: () => PaginationMetaDto })
@Type(() => PaginationMetaDto)
@ValidateNested()
pagination!: PaginationMetaDto;
}
@@ -1,3 +1,4 @@
import { getPagination } from "@/lib/pagination/pagination";
import { ApiKeysService } from "@/modules/api-keys/services/api-keys.service";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
@@ -11,6 +12,7 @@ import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { slugify } from "@calcom/platform-libraries";
import { SkipTakePagination } from "@calcom/platform-types";
@Injectable()
export class ManagedOrganizationsService {
@@ -99,18 +101,22 @@ export class ManagedOrganizationsService {
return this.managedOrganizationsOutputService.getOutputManagedOrganization(organization);
}
async getManagedOrganizations(managerOrganizationId: number) {
const managedOrganizations = await this.managedOrganizationsRepository.getByManagerOrganizationId(
managerOrganizationId
);
const managedOrganizationsIds = managedOrganizations.map(
(managedOrganization) => managedOrganization.managedOrganizationId
);
async getManagedOrganizations(managerOrganizationId: number, pagination: SkipTakePagination) {
const { items: managedOrganizations, totalItems } =
await this.managedOrganizationsRepository.getByManagerOrganizationIdPaginated(
managerOrganizationId,
pagination
);
const organizations = await this.organizationsRepository.findByIds(managedOrganizationsIds);
return organizations.map((organization) =>
this.managedOrganizationsOutputService.getOutputManagedOrganization(organization)
);
return {
organizations: managedOrganizations.map((managedOrganization) =>
this.managedOrganizationsOutputService.getOutputManagedOrganization(managedOrganization)
),
pagination: getPagination({
...pagination,
totalCount: totalItems,
}),
};
}
async updateManagedOrganization(managedOrganizationId: number, body: UpdateOrganizationInput) {
@@ -685,6 +685,92 @@ describe("Slots 2024-04-15 Endpoints", () => {
await bookingsRepositoryFixture.deleteById(booking.id);
});
describe("routingFormResponseId and _isDryRun validation", () => {
const baseUrl = "/api/v2/slots/available";
const baseParams = {
startTime: "2050-09-05",
endTime: "2050-09-10",
};
it("should allow routingFormResponseId=0 in dry-run mode", async () => {
return request(app.getHttpServer())
.get(baseUrl)
.query({
...baseParams,
eventTypeId,
routingFormResponseId: 0,
_isDryRun: true,
})
.expect(200)
.expect((res) => {
expect(res.body.status).toEqual(SUCCESS_STATUS);
});
});
it("should reject routingFormResponseId=1 in dry-run mode", async () => {
return request(app.getHttpServer())
.get(baseUrl)
.query({
...baseParams,
eventTypeId,
routingFormResponseId: 1,
_isDryRun: true,
})
.expect(400)
.expect((res) => {
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
"routingFormResponseId must be 0 for dry run"
);
});
});
it("should allow routingFormResponseId=1 in non-dry-run mode", async () => {
return request(app.getHttpServer())
.get(baseUrl)
.query({
...baseParams,
eventTypeId,
routingFormResponseId: 1,
})
.expect(200)
.expect((res) => {
expect(res.body.status).toEqual(SUCCESS_STATUS);
});
});
it("should reject routingFormResponseId=0 in non-dry-run mode", async () => {
return request(app.getHttpServer())
.get(baseUrl)
.query({
...baseParams,
eventTypeId,
routingFormResponseId: 0,
})
.expect(400)
.expect((res) => {
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
"routingFormResponseId must be a positive number"
);
});
});
it("should reject routingFormResponseId=-1 in non-dry-run mode", async () => {
return request(app.getHttpServer())
.get(baseUrl)
.query({
...baseParams,
eventTypeId,
routingFormResponseId: -1,
})
.expect(400)
.expect((res) => {
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
"routingFormResponseId must be a positive number"
);
});
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlotUid);
@@ -1,7 +1,7 @@
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import { SlotsService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots.service";
import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common";
import { Query, Body, Controller, Get, Delete, Post, Req, Res, BadRequestException } from "@nestjs/common";
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger";
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
@@ -157,6 +157,7 @@ export class SlotsController_2024_04_15 {
@Query() query: GetAvailableSlotsInput_2024_04_15,
@Req() req: ExpressRequest
): Promise<ApiResponse<{ slots: TimeSlots["slots"] | RangeSlots["slots"] }>> {
try {
const isTeamEvent =
query.isTeamEvent === undefined
? await this.slotsService.checkIfIsTeamEvent(query.eventTypeId)
@@ -171,19 +172,29 @@ export class SlotsController_2024_04_15 {
},
});
const { slots } = await this.slotsOutputService.getOutputSlots(
availableSlots,
query.duration,
query.eventTypeId,
query.slotFormat,
query.timeZone
);
const { slots } = await this.slotsOutputService.getOutputSlots(
availableSlots,
query.duration,
query.eventTypeId,
query.slotFormat,
query.timeZone
);
return {
data: {
slots,
},
status: SUCCESS_STATUS,
};
return {
data: {
slots,
},
status: SUCCESS_STATUS,
};
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Invalid time range given")) {
throw new BadRequestException(
"Invalid time range given - check the 'startTime' and 'endTime' query parameters."
);
}
}
throw error;
}
}
}
@@ -21,7 +21,9 @@ export class SlotsService_2024_04_15 {
let shouldReserveSlot = true;
if (eventType.seatsPerTimeSlot) {
const bookingWithAttendees = await this.slotsRepo.getBookingWithAttendees(input.bookingUid);
const bookingWithAttendees = input.bookingUid
? await this.slotsRepo.getBookingWithAttendees(input.bookingUid)
: undefined;
const bookingAttendeesLength = bookingWithAttendees?.attendees?.length;
if (bookingAttendeesLength) {
const seatsLeft = eventType.seatsPerTimeSlot - bookingAttendeesLength;
@@ -1501,6 +1501,429 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
});
describe("booking status", () => {
const startTime = "2050-09-12T11:00:00.000Z";
const testBookingUid = `booking-uid-${eventTypeId}-booking-status-test`;
describe("cant reserve", () => {
it("should not be able to reserve a slot if booking is accepted during that time", async () => {
const booking = await bookingsRepositoryFixture.create({
status: "ACCEPTED",
uid: testBookingUid,
title: "booking title",
startTime,
endTime: "2050-09-12T12:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: startTime,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422);
expect(reserveResponse.body.error.message).toEqual(
`Can't reserve a slot if the event is already booked.`
);
await bookingsRepositoryFixture.deleteById(booking.id);
});
it("should not be able to reserve a slot if booking is pending during that time", async () => {
const booking = await bookingsRepositoryFixture.create({
status: "PENDING",
uid: testBookingUid,
title: "booking title",
startTime,
endTime: "2050-09-12T12:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: startTime,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422);
expect(reserveResponse.body.error.message).toEqual(
`Can't reserve a slot if the event is already booked.`
);
await bookingsRepositoryFixture.deleteById(booking.id);
});
it("should not be able to reserve a slot if booking is awaiting host during that time", async () => {
const booking = await bookingsRepositoryFixture.create({
status: "AWAITING_HOST",
uid: testBookingUid,
title: "booking title",
startTime,
endTime: "2050-09-12T12:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: startTime,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422);
expect(reserveResponse.body.error.message).toEqual(
`Can't reserve a slot if the event is already booked.`
);
await bookingsRepositoryFixture.deleteById(booking.id);
});
});
describe("can reserve", () => {
it("should be able to reserve a slot if booking is cancelled during that time", async () => {
const booking = await bookingsRepositoryFixture.create({
status: "CANCELLED",
uid: testBookingUid,
title: "booking title",
startTime,
endTime: "2050-09-12T12:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: startTime,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
const reserveResponseBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponse.body;
expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS);
const responseReservedSlot: ReserveSlotOutputData_2024_09_04 = reserveResponseBody.data;
expect(responseReservedSlot.reservationUid).toBeDefined();
expect(responseReservedSlot.eventTypeId).toEqual(eventTypeId);
expect(responseReservedSlot.slotStart).toEqual(startTime);
expect(responseReservedSlot.slotDuration).toEqual(eventTypeLength);
expect(responseReservedSlot.slotEnd).toEqual(
DateTime.fromISO(startTime, { zone: "UTC" }).plus({ minutes: eventTypeLength }).toISO()
);
expect(responseReservedSlot.reservationDuration).toEqual(5);
await bookingsRepositoryFixture.deleteById(booking.id);
await selectedSlotsRepositoryFixture.deleteByUId(responseReservedSlot.reservationUid);
});
it("should be able to reserve a slot if booking is rejected during that time", async () => {
const booking = await bookingsRepositoryFixture.create({
status: "REJECTED",
uid: testBookingUid,
title: "booking title",
startTime,
endTime: "2050-09-12T12:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: startTime,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
const reserveResponseBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponse.body;
expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS);
const responseReservedSlot: ReserveSlotOutputData_2024_09_04 = reserveResponseBody.data;
expect(responseReservedSlot.reservationUid).toBeDefined();
expect(responseReservedSlot.eventTypeId).toEqual(eventTypeId);
expect(responseReservedSlot.slotStart).toEqual(startTime);
expect(responseReservedSlot.slotDuration).toEqual(eventTypeLength);
expect(responseReservedSlot.slotEnd).toEqual(
DateTime.fromISO(startTime, { zone: "UTC" }).plus({ minutes: eventTypeLength }).toISO()
);
expect(responseReservedSlot.reservationDuration).toEqual(5);
await bookingsRepositoryFixture.deleteById(booking.id);
});
});
});
describe("booking overlap", () => {
const T0901 = DateTime.fromISO("2055-01-01T10:00:00.000Z").minus({ minutes: 59 }).toISO();
const T0930 = "2055-01-01T09:30:00.000Z";
const T1000 = "2055-01-01T10:00:00.000Z";
const T1015 = "2055-01-01T10:15:00.000Z";
const T1029 = DateTime.fromISO("2055-01-01T10:30:00.000Z").minus({ minutes: 1 }).toISO();
const T1030 = "2055-01-01T10:30:00.000Z";
const T1045 = "2055-01-01T10:45:00.000Z";
const T1100 = "2055-01-01T11:00:00.000Z";
const T1130 = "2055-01-01T11:30:00.000Z";
const T1200 = "2055-01-01T12:00:00.000Z";
const getBookingPayload = (startTime: string, endTime: string) => ({
status: "ACCEPTED" as const,
uid: `test-booking-overlap-${randomString()}`,
title: "Overlap Test Booking",
startTime,
endTime,
eventType: { connect: { id: eventTypeId } },
metadata: {},
responses: { name: "Tester", email: `tester-overlap-${randomString()}@example.com`, guests: [] },
user: { connect: { id: user.id } },
});
const expectedErrorMessage = "Can't reserve a slot if the event is already booked.";
describe("slot overlaps booking", () => {
it("should fail if slot reservation (10:00-11:00) starts during an existing booking (09:30-10:30)", async () => {
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T0930, T1030));
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({ eventTypeId, slotStart: T1000 })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422);
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
await bookingsRepositoryFixture.deleteById(booking.id);
});
it("should fail if slot reservation (10:00-11:00) ends during an existing booking (10:30-11:30)", async () => {
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1030, T1130));
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({ eventTypeId, slotStart: T1000 })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422);
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
await bookingsRepositoryFixture.deleteById(booking.id);
});
it("should fail if slot reservation (10:15-11:15) is entirely contained within an existing booking (10:00-11:30)", async () => {
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1130));
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({ eventTypeId, slotStart: T1015 })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422);
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
await bookingsRepositoryFixture.deleteById(booking.id);
});
it("should fail if slot reservation (10:00-11:00) entirely contains an existing booking (10:15-10:45)", async () => {
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1015, T1045));
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({ eventTypeId, slotStart: T1000 })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422);
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
await bookingsRepositoryFixture.deleteById(booking.id);
});
it("should fail if slot reservation (10:00-11:00) starts at the same time as an existing booking (10:00-11:00)", async () => {
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100));
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({ eventTypeId, slotStart: T1000 })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422);
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
await bookingsRepositoryFixture.deleteById(booking.id);
});
it("should fail if slot reservation (10:00-11:00) ends at the same time as an existing booking (09:30-11:00)", async () => {
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T0930, T1100));
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({ eventTypeId, slotStart: T1000 })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422);
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
await bookingsRepositoryFixture.deleteById(booking.id);
});
it("should fail if slot reservation (10:29-11:29) starts just before existing booking (09:30-10:30) ends", async () => {
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T0930, T1030));
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({ eventTypeId, slotStart: T1029 })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422);
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
await bookingsRepositoryFixture.deleteById(booking.id);
});
it("should fail if slot reservation (09:01-10:01) ends just after existing booking (10:00-11:00) starts", async () => {
const booking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100));
const slotStartForEarlyOverlap = T0901;
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({ eventTypeId, slotStart: slotStartForEarlyOverlap })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422);
expect(reserveResponse.body.error.message).toEqual(expectedErrorMessage);
await bookingsRepositoryFixture.deleteById(booking.id);
});
});
describe("slot does not overlap booking", () => {
it("should succeed if slot reservation (09:00-10:00) is immediately before an existing booking (10:00-11:00)", async () => {
const existingBooking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100));
const slotStartForNoOverlap = "2055-01-01T09:00:00.000Z";
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({ eventTypeId, slotStart: slotStartForNoOverlap })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
expect(reserveResponse.body.data.reservationUid).toBeDefined();
await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
await bookingsRepositoryFixture.deleteById(existingBooking.id);
});
it("should succeed if slot reservation (08:00-09:00) is before an existing booking (10:00-11:00)", async () => {
const existingBooking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100));
const slotStartForNoOverlap = "2055-01-01T08:00:00.000Z";
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({ eventTypeId, slotStart: slotStartForNoOverlap })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
expect(reserveResponse.body.data.reservationUid).toBeDefined();
await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
await bookingsRepositoryFixture.deleteById(existingBooking.id);
});
it("should succeed if slot reservation (11:00-12:00) is immediately after an existing booking (10:00-11:00)", async () => {
const existingBooking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100));
const slotStartForNoOverlap = T1100;
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({ eventTypeId, slotStart: slotStartForNoOverlap })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
expect(reserveResponse.body.data.reservationUid).toBeDefined();
await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
await bookingsRepositoryFixture.deleteById(existingBooking.id);
});
it("should succeed if slot reservation (12:00-13:00) is after an existing booking (10:00-11:00)", async () => {
const existingBooking = await bookingsRepositoryFixture.create(getBookingPayload(T1000, T1100));
const slotStartForNoOverlap = T1200;
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({ eventTypeId, slotStart: slotStartForNoOverlap })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
expect(reserveResponse.body.data.reservationUid).toBeDefined();
await selectedSlotsRepositoryFixture.deleteByUId(reserveResponse.body.data.reservationUid);
await bookingsRepositoryFixture.deleteById(existingBooking.id);
});
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(oooTestUserEmail);
@@ -82,7 +82,7 @@ export class SlotsController_2024_09_04 {
Optional parameters are:
- timeZone: Time zone in which the available slots should be returned. Defaults to UTC.
- duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.
- slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
- format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
`,
})
@ApiQuery({
@@ -99,7 +99,7 @@ export class SlotsController_2024_09_04 {
example: "60",
})
@ApiQuery({
name: "slotFormat",
name: "format",
required: false,
description:
"Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.",
@@ -19,7 +19,7 @@ import { z } from "zod";
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
import { GetSlotsInput_2024_09_04, ReserveSlotInput_2024_09_04 } from "@calcom/platform-types";
import { EventType } from "@calcom/prisma/client";
import { Booking, EventType } from "@calcom/prisma/client";
const eventTypeMetadataSchema = z
.object({
@@ -42,24 +42,33 @@ export class SlotsService_2024_09_04 {
) {}
async getAvailableSlots(query: GetSlotsInput_2024_09_04) {
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
try {
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
const availableSlots: TimeSlots = await getAvailableSlots({
input: {
...queryTransformed,
},
ctx: {},
});
const formatted = await this.slotsOutputService.getAvailableSlots(
availableSlots,
queryTransformed.eventTypeId,
queryTransformed.duration,
query.format,
queryTransformed.timeZone
);
const availableSlots: TimeSlots = await getAvailableSlots({
input: {
...queryTransformed,
},
ctx: {},
});
const formatted = await this.slotsOutputService.getAvailableSlots(
availableSlots,
queryTransformed.eventTypeId,
queryTransformed.duration,
query.format,
queryTransformed.timeZone
);
return formatted;
return formatted;
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Invalid time range given")) {
throw new BadRequestException(
"Invalid time range given - check the 'start' and 'end' query parameters."
);
}
}
throw error;
}
}
async reserveSlot(input: ReserveSlotInput_2024_09_04, authUserId?: number) {
@@ -100,9 +109,10 @@ export class SlotsService_2024_09_04 {
throw new BadRequestException("Invalid end date");
}
const booking = await this.slotsRepository.getBookingWithAttendeesByEventTypeIdAndStart(
const booking = await this.slotsRepository.findActiveOverlappingBooking(
input.eventTypeId,
startDate.toJSDate()
startDate.toJSDate(),
endDate.toJSDate()
);
if (eventType.seatsPerTimeSlot) {
@@ -249,9 +259,10 @@ export class SlotsService_2024_09_04 {
throw new BadRequestException("Invalid end date");
}
const booking = await this.slotsRepository.getBookingWithAttendeesByEventTypeIdAndStart(
const booking = await this.slotsRepository.findActiveOverlappingBooking(
input.eventTypeId,
startDate.toJSDate()
startDate.toJSDate(),
endDate.toJSDate()
);
if (eventType.seatsPerTimeSlot) {
@@ -12,10 +12,23 @@ export class SlotsRepository_2024_09_04 {
return this.dbRead.prisma.selectedSlots.findFirst({ where: { uid } });
}
async getBookingWithAttendeesByEventTypeIdAndStart(eventTypeId: number, startTime: Date) {
async findActiveOverlappingBooking(eventTypeId: number, slotStartTime: Date, slotEndTime: Date) {
return this.dbRead.prisma.booking.findFirst({
where: { eventTypeId, startTime },
select: { attendees: true },
where: {
eventTypeId,
startTime: {
// note(Lauris): booking starts before the potential slot reservation ends.
lt: slotEndTime,
},
endTime: {
// note(Lauris): booking ends after the potential slot reservation begins.
gt: slotStartTime,
},
status: {
in: ["ACCEPTED", "PENDING", "AWAITING_HOST"],
},
},
select: { attendees: true, status: true },
});
}
@@ -85,11 +85,13 @@ describe("Organizations Event Types Endpoints", () => {
teamMember1 = await userRepositoryFixture.create({
email: teammate1Email,
username: teammate1Email,
name: "alice",
});
teamMember2 = await userRepositoryFixture.create({
email: teammate2Email,
username: teammate2Email,
name: "bob",
});
falseTestUser = await userRepositoryFixture.create({
@@ -403,6 +405,21 @@ describe("Organizations Event Types Endpoints", () => {
const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id);
expect(responseTeamEvent).toBeDefined();
expect(responseTeamEvent?.hosts).toEqual([
{
userId: teamMember1.id,
name: teamMember1.name,
username: teamMember1.username,
avatarUrl: teamMember1.avatarUrl,
},
{
userId: teamMember2.id,
name: teamMember2.name,
username: teamMember2.username,
avatarUrl: teamMember2.avatarUrl,
},
]);
if (!responseTeamEvent) {
throw new Error("Team event not found");
}
@@ -5,6 +5,7 @@ import { Injectable } from "@nestjs/common";
import { Logger } from "@nestjs/common";
import { PlatformAuthorizationToken } from "@prisma/client";
import { DateTime } from "luxon";
import { v4 as uuidv4 } from "uuid";
@Injectable()
export class TokensRepository {
@@ -50,31 +51,18 @@ export class TokensRepository {
});
}
async createOAuthTokens(clientId: string, ownerId: number, deleteOld?: boolean) {
if (deleteOld) {
try {
await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.accessToken.deleteMany({
where: { client: { id: clientId }, userId: ownerId },
}),
this.dbWrite.prisma.refreshToken.deleteMany({
where: {
client: { id: clientId },
userId: ownerId,
},
}),
]);
} catch (err) {
this.logger.error("createOAuthTokens - Failed to delete old tokens", err);
}
}
async createOAuthTokens(clientId: string, ownerId: number) {
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.accessToken.create({
data: {
secret: this.jwtService.signAccessToken({ clientId, ownerId, expiresAt: accessExpiry.valueOf() }),
secret: this.jwtService.signAccessToken({
clientId,
ownerId,
expiresAt: accessExpiry.valueOf(),
jti: uuidv4(),
}),
expiresAt: accessExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: ownerId } },
@@ -82,7 +70,12 @@ export class TokensRepository {
}),
this.dbWrite.prisma.refreshToken.create({
data: {
secret: this.jwtService.signRefreshToken({ clientId, ownerId, expiresAt: refreshExpiry.valueOf() }),
secret: this.jwtService.signRefreshToken({
clientId,
ownerId,
expiresAt: refreshExpiry.valueOf(),
jti: uuidv4(),
}),
expiresAt: refreshExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: ownerId } },
@@ -98,6 +91,57 @@ export class TokensRepository {
};
}
async forceRefreshOAuthTokens(clientId: string, ownerId: number) {
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
const [_deletedAccessToken, _deletedRefreshToken, accessToken, refreshToken] =
await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.accessToken.deleteMany({
where: { client: { id: clientId }, userId: ownerId },
}),
this.dbWrite.prisma.refreshToken.deleteMany({
where: {
client: { id: clientId },
userId: ownerId,
},
}),
this.dbWrite.prisma.accessToken.create({
data: {
secret: this.jwtService.signAccessToken({
clientId,
ownerId,
expiresAt: accessExpiry.valueOf(),
jti: uuidv4(),
}),
expiresAt: accessExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: ownerId } },
},
}),
this.dbWrite.prisma.refreshToken.create({
data: {
secret: this.jwtService.signRefreshToken({
clientId,
ownerId,
expiresAt: refreshExpiry.valueOf(),
jti: uuidv4(),
}),
expiresAt: refreshExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: ownerId } },
},
}),
]);
return {
accessToken: accessToken.secret,
accessTokenExpiresAt: accessToken.expiresAt,
refreshToken: refreshToken.secret,
refreshTokenExpiresAt: refreshToken.expiresAt,
};
}
async getAccessTokenExpiryDate(accessTokenSecret: string) {
const accessToken = await this.dbRead.prisma.accessToken.findFirst({
where: {
@@ -140,6 +184,7 @@ export class TokensRepository {
ownerId: tokenUserId,
expiresAt: accessExpiry.valueOf(),
userId: tokenUserId,
jti: uuidv4(),
}),
expiresAt: accessExpiry,
client: { connect: { id: clientId } },
@@ -155,6 +200,7 @@ export class TokensRepository {
ownerId: tokenUserId,
expiresAt: refreshExpiry.valueOf(),
userId: tokenUserId,
jti: uuidv4(),
}),
expiresAt: refreshExpiry,
client: { connect: { id: clientId } },
+334 -132
View File
@@ -446,9 +446,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -456,9 +459,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -654,9 +659,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -664,9 +672,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -1526,6 +1536,16 @@
"type": "string"
}
},
{
"name": "bookingUid",
"required": false,
"in": "query",
"description": "Filter bookings by the booking Uid.",
"example": "2NtaeaVcKfpmSZ4CthFdfk",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
@@ -1928,9 +1948,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -1938,9 +1961,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -2629,9 +2654,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -2639,9 +2667,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -2707,9 +2737,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -2717,9 +2750,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -2851,9 +2886,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -2861,9 +2899,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -4185,9 +4225,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -4195,9 +4238,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -4271,9 +4316,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -4281,9 +4329,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -5442,6 +5492,16 @@
"type": "string"
}
},
{
"name": "bookingUid",
"required": false,
"in": "query",
"description": "Filter bookings by the booking Uid.",
"example": "2NtaeaVcKfpmSZ4CthFdfk",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
@@ -5675,9 +5735,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -5685,9 +5748,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -5951,9 +6016,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -5961,9 +6029,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -6388,9 +6458,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -6398,9 +6471,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -6819,6 +6894,16 @@
"type": "string"
}
},
{
"name": "bookingUid",
"required": false,
"in": "query",
"description": "Filter bookings by the booking Uid.",
"example": "2NtaeaVcKfpmSZ4CthFdfk",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
@@ -7251,7 +7336,7 @@
"post": {
"operationId": "BookingsController_2024_08_13_reassignBooking",
"summary": "Reassign a booking to auto-selected host",
"description": "The provided authorization header refers to the owner of the booking.",
"description": "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
"parameters": [
{
"name": "cal-api-version",
@@ -7302,7 +7387,7 @@
"post": {
"operationId": "BookingsController_2024_08_13_reassignBookingToUser",
"summary": "Reassign a booking to a specific host",
"description": "The provided authorization header refers to the owner of the booking.",
"description": "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
"parameters": [
{
"name": "cal-api-version",
@@ -8741,9 +8826,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -8751,9 +8839,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -9012,6 +9102,31 @@
"schema": {
"type": "number"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
],
"responses": {
@@ -9670,9 +9785,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -9680,9 +9798,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -9730,9 +9850,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -9740,9 +9863,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -10506,7 +10631,7 @@
"get": {
"operationId": "SlotsController_2024_09_04_getAvailableSlots",
"summary": "Find out when is an event type ready to be booked.",
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
"parameters": [
{
"name": "cal-api-version",
@@ -10583,7 +10708,7 @@
"schema": {}
},
{
"name": "slotFormat",
"name": "format",
"required": false,
"in": "query",
"description": "Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.",
@@ -11573,9 +11698,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -11583,9 +11711,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -11962,9 +12092,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -11972,9 +12105,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -12022,9 +12157,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -12032,9 +12170,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -12342,9 +12482,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -12352,9 +12495,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -12394,9 +12539,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -12404,9 +12552,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -12576,9 +12726,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -12586,9 +12739,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -14546,6 +14701,22 @@
"slug"
]
},
"LocationDefaultFieldInput_2024_06_14": {
"type": "object",
"properties": {
"slug": {
"type": "string",
"example": "location",
"description": "only allowed value for type is `location`. This booking field is displayed only when event type has 2 or more locations in order to allow person doing the booking pick the location."
},
"label": {
"type": "string"
}
},
"required": [
"slug"
]
},
"NotesDefaultFieldInput_2024_06_14": {
"type": "object",
"properties": {
@@ -14759,6 +14930,9 @@
{
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
},
@@ -15010,6 +15184,7 @@
},
"address": {
"type": "string",
"minLength": 1,
"example": "123 Example St, City, Country"
},
"public": {
@@ -15350,6 +15525,9 @@
"hidden": {
"type": "boolean",
"description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both."
},
"label": {
"type": "string"
}
},
"required": [
@@ -15366,11 +15544,11 @@
"slug": {
"type": "string",
"enum": [
"name",
"email",
"title",
"location",
"notes",
"guests"
"guests",
"rescheduleReason"
],
"example": "rescheduleReason",
"description": "only allowed value for type is `rescheduleReason`",
@@ -15416,11 +15594,11 @@
"slug": {
"type": "string",
"enum": [
"name",
"email",
"title",
"location",
"notes",
"guests"
"guests",
"rescheduleReason"
],
"example": "title",
"description": "only allowed value for type is `title`",
@@ -15466,11 +15644,11 @@
"slug": {
"type": "string",
"enum": [
"name",
"email",
"title",
"location",
"notes",
"guests"
"guests",
"rescheduleReason"
],
"example": "notes",
"description": "only allowed value for type is `notes`",
@@ -15516,11 +15694,11 @@
"slug": {
"type": "string",
"enum": [
"name",
"email",
"title",
"location",
"notes",
"guests"
"guests",
"rescheduleReason"
],
"example": "guests",
"description": "only allowed value for type is `guests`",
@@ -16749,6 +16927,9 @@
{
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
},
@@ -18563,6 +18744,9 @@
{
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
},
@@ -18876,6 +19060,10 @@
"type": "string",
"example": "John Doe"
},
"username": {
"type": "string",
"example": "john-doe"
},
"avatarUrl": {
"type": "string",
"nullable": true,
@@ -18884,7 +19072,8 @@
},
"required": [
"userId",
"name"
"name",
"username"
]
},
"EventTypeTeam": {
@@ -19421,6 +19610,9 @@
{
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
},
@@ -21936,6 +22128,66 @@
"data"
]
},
"PaginationMetaDto": {
"type": "object",
"properties": {
"totalItems": {
"type": "number",
"description": "The total number of items available across all pages, matching the query criteria.",
"example": 123,
"minimum": 0
},
"remainingItems": {
"type": "number",
"description": "The number of items remaining to be fetched *after* the current page. Calculated as: `totalItems - (skip + itemsPerPage)`.",
"example": 103,
"minimum": 0
},
"returnedItems": {
"type": "number",
"description": "The number of items returned in the current page.",
"example": 10
},
"itemsPerPage": {
"type": "number",
"description": "The maximum number of items requested per page.",
"example": 10,
"minimum": 1
},
"currentPage": {
"type": "number",
"description": "The current page number being returned.",
"example": 2,
"minimum": 1
},
"totalPages": {
"type": "number",
"description": "The total number of pages available.",
"example": 13,
"minimum": 0
},
"hasNextPage": {
"type": "boolean",
"description": "Indicates if there is a subsequent page available after the current one.",
"example": true
},
"hasPreviousPage": {
"type": "boolean",
"description": "Indicates if there is a preceding page available before the current one.",
"example": true
}
},
"required": [
"totalItems",
"remainingItems",
"returnedItems",
"itemsPerPage",
"currentPage",
"totalPages",
"hasNextPage",
"hasPreviousPage"
]
},
"GetManagedOrganizationsOutput": {
"type": "object",
"properties": {
@@ -21952,11 +22204,15 @@
"items": {
"$ref": "#/components/schemas/ManagedOrganizationOutput"
}
},
"pagination": {
"$ref": "#/components/schemas/PaginationMetaDto"
}
},
"required": [
"status",
"data"
"data",
"pagination"
]
},
"UpdateOrganizationInput": {
@@ -24143,60 +24399,6 @@
"data"
]
},
"PaginationMetaDto": {
"type": "object",
"properties": {
"totalItems": {
"type": "number",
"minimum": 0,
"description": "The total number of items available across all pages, matching the query criteria.",
"example": 123
},
"remainingItems": {
"type": "number",
"minimum": 0,
"description": "The number of items remaining to be fetched *after* the current page. Calculated as: `totalItems - (skip + itemsPerPage)`.",
"example": 103
},
"itemsPerPage": {
"type": "number",
"minimum": 1,
"description": "The maximum number of items requested per page.",
"example": 10
},
"currentPage": {
"type": "number",
"minimum": 1,
"description": "The current page number being returned.",
"example": 2
},
"totalPages": {
"type": "number",
"minimum": 0,
"description": "The total number of pages available.",
"example": 13
},
"hasNextPage": {
"type": "boolean",
"description": "Indicates if there is a subsequent page available after the current one.",
"example": true
},
"hasPreviousPage": {
"type": "boolean",
"description": "Indicates if there is a preceding page available before the current one.",
"example": true
}
},
"required": [
"totalItems",
"remainingItems",
"itemsPerPage",
"currentPage",
"totalPages",
"hasNextPage",
"hasPreviousPage"
]
},
"GetBookingsOutput_2024_08_13": {
"type": "object",
"properties": {
@@ -0,0 +1,17 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
export class WorkflowReminderRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async getByBookingUid(uid: string) {
return this.prismaReadClient.workflowReminder.findFirst({ where: { bookingUid: uid } });
}
}
@@ -0,0 +1,23 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@calcom/prisma/client";
export class WorkflowRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async create(data: Prisma.WorkflowCreateInput) {
return this.prismaWriteClient.workflow.create({ data });
}
async delete(id: number) {
return this.prismaWriteClient.workflow.delete({ where: { id } });
}
}
@@ -1,7 +1,10 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import type { PageProps as ServerPageProps } from "app/_types";
import { cookies, headers } from "next/headers";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps";
@@ -13,6 +16,18 @@ const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
const locale = props.eventData?.interfaceLanguage;
if (locale) {
const ns = "common";
const translations = await loadTranslations(locale, ns);
return (
<CustomI18nProvider translations={translations} locale={locale} ns={ns}>
<TypePage {...props} />
</CustomI18nProvider>
);
}
return <TypePage {...props} />;
};
@@ -1,9 +1,11 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { headers, cookies } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
@@ -55,6 +57,17 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(legacyCtx);
const locale = props.eventData?.interfaceLanguage;
if (locale) {
const ns = "common";
const translations = await loadTranslations(locale, ns);
return (
<CustomI18nProvider translations={translations} locale={locale} ns={ns}>
<LegacyPage {...props} />
</CustomI18nProvider>
);
}
return <LegacyPage {...props} />;
};
@@ -1,7 +1,10 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import type { PageProps as ServerPageProps } from "app/_types";
import { cookies, headers } from "next/headers";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
@@ -17,8 +20,32 @@ export type ClientPageProps = UserTypePageProps | TeamTypePageProps;
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
if ((props as TeamTypePageProps)?.teamId) return <TeamTypePage {...(props as TeamTypePageProps)} />;
return <UserTypePage {...(props as UserTypePageProps)} />;
const eventLocale = props.eventData?.interfaceLanguage;
const ns = "common";
let translations;
if (eventLocale) {
const ns = "common";
translations = await loadTranslations(eventLocale, ns);
}
if ((props as TeamTypePageProps)?.teamId) {
return eventLocale ? (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<TeamTypePage {...(props as TeamTypePageProps)} />
</CustomI18nProvider>
) : (
<TeamTypePage {...(props as TeamTypePageProps)} />
);
}
return eventLocale ? (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<UserTypePage {...(props as UserTypePageProps)} />
</CustomI18nProvider>
) : (
<UserTypePage {...(props as UserTypePageProps)} />
);
};
export default ServerPage;
@@ -1,9 +1,11 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
@@ -63,10 +65,32 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
const props = await getData(
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
);
if ((props as TeamTypePageProps)?.teamId) {
return <TeamTypePage {...(props as TeamTypePageProps)} />;
const eventLocale = props.eventData?.interfaceLanguage;
const ns = "common";
let translations;
if (eventLocale) {
const ns = "common";
translations = await loadTranslations(eventLocale, ns);
}
return <UserTypePage {...(props as UserTypePageProps)} />;
if ((props as TeamTypePageProps)?.teamId) {
return eventLocale ? (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<TeamTypePage {...(props as TeamTypePageProps)} />
</CustomI18nProvider>
) : (
<TeamTypePage {...(props as TeamTypePageProps)} />
);
}
return eventLocale ? (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<UserTypePage {...(props as UserTypePageProps)} />
</CustomI18nProvider>
) : (
<UserTypePage {...(props as UserTypePageProps)} />
);
};
export default ServerPage;
@@ -1,7 +1,10 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import type { PageProps as ServerPageProps } from "app/_types";
import { cookies, headers } from "next/headers";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
@@ -12,6 +15,18 @@ const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
const eventLocale = props.eventData?.interfaceLanguage;
if (eventLocale) {
const ns = "common";
const translations = await loadTranslations(eventLocale, ns);
return (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<TypePage {...props} />
</CustomI18nProvider>
);
}
return <TypePage {...props} />;
};
@@ -1,9 +1,11 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
@@ -54,6 +56,18 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
const props = await getData(
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
);
const eventLocale = props.eventData?.interfaceLanguage;
if (eventLocale) {
const ns = "common";
const translations = await loadTranslations(eventLocale, ns);
return (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<LegacyPage {...props} />
</CustomI18nProvider>
);
}
return <LegacyPage {...props} />;
};
export default ServerPage;
@@ -1,6 +1,8 @@
import { createRouterCaller } from "app/_trpc/context";
import type { PageProps } from "app/_types";
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
@@ -21,16 +23,53 @@ export const generateMetadata = async () => {
);
};
const getCachedMe = unstable_cache(
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
const meCaller = await createRouterCaller(meRouter, await getTRPCContext(headers, cookies));
return await meCaller.get();
},
["viewer.me.get"],
{ revalidate: 3600 } // Cache for 1 hour
);
const getCachedAvailabilities = unstable_cache(
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
const availabilityCaller = await createRouterCaller(
availabilityRouter,
await getTRPCContext(headers, cookies)
);
return await availabilityCaller.list();
},
["viewer.availability.list"],
{ revalidate: 3600 } // Cache for 1 hour
);
const Page = async ({ searchParams: _searchParams }: PageProps) => {
const searchParams = await _searchParams;
const t = await getTranslate();
const _headers = await headers();
const _cookies = await cookies();
const [meCaller, availabilityCaller] = await Promise.all([
createRouterCaller(meRouter),
createRouterCaller(availabilityRouter),
const [me, cachedAvailabilities] = await Promise.all([
getCachedMe(_headers, _cookies),
getCachedAvailabilities(_headers, _cookies),
]);
const [me, availabilities] = await Promise.all([meCaller.get(), availabilityCaller.list()]);
// Transform the data to ensure startTime, endTime, and date are Date objects
// This is because the data is cached and as a result the data is converted to a string
const availabilities = {
...cachedAvailabilities,
schedules: cachedAvailabilities.schedules.map((schedule) => ({
...schedule,
availability: schedule.availability.map((avail) => ({
...avail,
startTime: new Date(avail.startTime),
endTime: new Date(avail.endTime),
date: avail.date ? new Date(avail.date) : null,
})),
})),
};
const organizationId = me.organization?.id ?? me.profiles[0]?.organizationId;
const isOrgPrivate = organizationId
? await OrganizationRepository.checkIfPrivate({
@@ -1,7 +1,8 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import { createRouterCaller } from "app/_trpc/context";
import type { PageProps } from "app/_types";
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
@@ -23,9 +24,40 @@ export const generateMetadata = async () =>
"/event-types"
);
const getCachedMe = unstable_cache(
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
const meCaller = await createRouterCaller(meRouter, await getTRPCContext(headers, cookies));
return await meCaller.get();
},
["viewer.me.get"],
{ revalidate: 3600 } // seconds
);
const getCachedEventGroups = unstable_cache(
async (
headers: ReadonlyHeaders,
cookies: ReadonlyRequestCookies,
filters?: {
teamIds?: number[] | undefined;
userIds?: number[] | undefined;
upIds?: string[] | undefined;
}
) => {
const eventTypesCaller = await createRouterCaller(
eventTypesRouter,
await getTRPCContext(headers, cookies)
);
return await eventTypesCaller.getUserEventGroups({ filters });
},
["viewer.eventTypes.getUserEventGroups"],
{ revalidate: 3600 } // seconds
);
const Page = async ({ params, searchParams }: PageProps) => {
const _searchParams = await searchParams;
const context = buildLegacyCtx(await headers(), await cookies(), await params, _searchParams);
const _headers = await headers();
const _cookies = await cookies();
const context = buildLegacyCtx(_headers, _cookies, await params, _searchParams);
const session = await getServerSession({ req: context.req });
if (!session?.user?.id) {
@@ -34,15 +66,9 @@ const Page = async ({ params, searchParams }: PageProps) => {
const t = await getTranslate();
const filters = getTeamsFiltersFromQuery(_searchParams);
const [meCaller, eventTypesCaller] = await Promise.all([
createRouterCaller(meRouter),
createRouterCaller(eventTypesRouter),
]);
const [me, userEventGroupsData] = await Promise.all([
meCaller.get(),
eventTypesCaller.getUserEventGroups({ filters }),
getCachedMe(_headers, _cookies),
getCachedEventGroups(_headers, _cookies, filters),
]);
return (
@@ -56,4 +82,3 @@ const Page = async ({ params, searchParams }: PageProps) => {
};
export default Page;
export const revalidate = 3600; // 1 hour in seconds
@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { revalidateTag } from "next/cache";
export async function revalidateTeamsList() {
revalidatePath("/teams");
revalidateTag("viewer.teams.list");
}
@@ -30,7 +30,7 @@ const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => {
}
const t = await getTranslate();
const { Main, CTA } = await ServerTeamsListing({ searchParams });
const { Main, CTA } = await ServerTeamsListing({ searchParams, session });
return (
<ShellMainAppDir CTA={CTA} heading={t("teams")} subtitle={t("create_manage_teams_collaborative")}>
@@ -1,44 +1,51 @@
import { createRouterCaller } from "app/_trpc/context";
import type { SearchParams } from "app/_types";
import type { Session } from "next-auth";
import { unstable_cache } from "next/cache";
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
import { CreationSource } from "@calcom/prisma/enums";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { TRPCError } from "@trpc/server";
import { TeamsCTA } from "./CTA";
export const ServerTeamsListing = async ({ searchParams }: { searchParams: SearchParams }) => {
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
const getCachedTeams = unstable_cache(
async (userId: number) => {
return await TeamRepository.findTeamsByUserId({
userId,
includeOrgs: true,
});
},
undefined,
{ revalidate: 3600, tags: ["viewer.teams.list"] } // Cache for 1 hour
);
const [teamsCaller, meCaller] = await Promise.all([
createRouterCaller(viewerTeamsRouter),
createRouterCaller(meRouter),
]);
export const ServerTeamsListing = async ({
searchParams,
session,
}: {
searchParams: SearchParams;
session: Session;
}) => {
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
const userId = session.user.id;
let teamNameFromInvite,
errorMsgFromInvite = null;
if (token) {
try {
teamNameFromInvite = await teamsCaller.inviteMemberByToken({
token,
creationSource: CreationSource.WEBAPP,
});
teamNameFromInvite = await TeamRepository.inviteMemberByToken(token, userId);
} catch (e) {
errorMsgFromInvite = "Error while fetching teams";
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
}
}
const [user, teams] = await Promise.all([
meCaller.get(),
teamsCaller.list({
includeOrgs: true,
}),
]);
const meCaller = await createRouterCaller(meRouter);
const [user, teams] = await Promise.all([meCaller.get(), getCachedTeams(userId)]);
return {
Main: (
@@ -0,0 +1,7 @@
import type { ReactNode } from "react";
import FormProvider from "./FormProvider";
export default async function Layout({ children }: { children: ReactNode }) {
return <FormProvider>{children}</FormProvider>;
}
@@ -5,29 +5,23 @@ import { cookies, headers } from "next/headers";
import { routingFormsComponents } from "@calcom/app-store/routing-forms/pages/app-routing.client-config";
import type { routingServerSidePropsConfig } from "@calcom/app-store/routing-forms/pages/app-routing.server-config";
import Shell from "@calcom/features/shell/Shell";
import { getServerSideProps } from "@lib/apps/routing-forms/[...pages]/getServerSideProps";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import FormProvider from "./FormProvider";
const normalizePages = (pages: string[] | string | undefined) => {
const normalizedPages = Array.isArray(pages) ? pages : pages?.split("/") ?? [];
return {
mainPage: normalizedPages[0] ?? "forms",
subPages: normalizedPages.slice(1),
};
return normalizedPages[0];
};
export const generateMetadata = async ({ params }: { params: Promise<{ pages: string[] }> }) => {
const { mainPage } = normalizePages((await params).pages);
const mainPage = normalizePages((await params).pages);
return await _generateMetadata(
(t) => (mainPage === "routing-link" ? `Form | Cal.com Forms` : `${t("routing_forms")} | Cal.com Forms`),
() => "",
undefined,
undefined,
`/apps/routing-forms/${(await params).pages.join("/")}`
`/routing/${(await params).pages.join("/")}`
);
};
@@ -38,25 +32,11 @@ const getData = withAppDirSsr<GetServerSidePropsResult>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
const { mainPage } = normalizePages((await params).pages);
const mainPage = normalizePages((await params).pages);
const Component = await routingFormsComponents[mainPage as keyof typeof routingFormsComponents]();
const FinalComponent = () => (
<FormProvider>
<Component {...(props as any)} />
</FormProvider>
);
// TODO: we should really be using layout for this.
if (mainPage === "forms") {
return (
<Shell withoutMain>
<FinalComponent />
</Shell>
);
}
return <FinalComponent />;
return <Component {...(props as any)} />;
};
export default ServerPage;
@@ -0,0 +1,21 @@
import { _generateMetadata } from "app/_utils";
import Forms from "@calcom/app-store/routing-forms/pages/forms/[...appPages]";
export const generateMetadata = async ({ params }: { params: Promise<{ pages: string[] }> }) => {
const pages = (await params).pages;
return await _generateMetadata(
(t) => `${t("routing_forms")} | Cal.com Forms`,
() => "",
undefined,
undefined,
`/routing/forms/${pages?.length > 0 ? pages.join("/") : ""}`
);
};
const ServerPage = async () => {
return <Forms appUrl="/routing" />;
};
export default ServerPage;
@@ -0,0 +1,23 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import Shell from "@calcom/features/shell/Shell";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import FormProvider from "../[...pages]/FormProvider";
export default async function Layout({ children }: { children: ReactNode }) {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
redirect("/auth/login");
}
return (
<Shell withoutMain={true}>
<FormProvider>{children}</FormProvider>
</Shell>
);
}
@@ -1,7 +1,10 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import type { PageProps as ServerPageProps } from "app/_types";
import { cookies, headers } from "next/headers";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import OldPage from "~/bookings/views/bookings-single-view";
@@ -15,6 +18,18 @@ const getEmbedData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getEmbedData(context);
const eventLocale = props.eventType?.interfaceLanguage;
if (eventLocale) {
const ns = "common";
const translations = await loadTranslations(eventLocale, ns);
return (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<OldPage {...props} />
</CustomI18nProvider>
);
}
return <OldPage {...props} />;
};
@@ -1,9 +1,11 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as _PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { BookingStatus } from "@calcom/prisma/enums";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
@@ -36,6 +38,18 @@ const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: _PageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
const eventLocale = props.eventType?.interfaceLanguage;
if (eventLocale) {
const ns = "common";
const translations = await loadTranslations(eventLocale, ns);
return (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<OldPage {...props} />
</CustomI18nProvider>
);
}
return <OldPage {...props} />;
};
export default ServerPage;
@@ -1,6 +1,7 @@
import { createRouterCaller } from "app/_trpc/context";
import type { PageProps } from "app/_types";
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
@@ -30,6 +31,15 @@ export const generateMetadata = async () => {
);
};
const getCachedEventType = unstable_cache(
async (eventTypeId: number, headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
const caller = await createRouterCaller(eventTypesRouter, await getTRPCContext(headers, cookies));
return await caller.get({ id: eventTypeId });
},
["viewer.eventTypes.get"],
{ revalidate: 3600 } // Cache for 1 hour
);
const ServerPage = async ({ params }: PageProps) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
@@ -41,8 +51,10 @@ const ServerPage = async ({ params }: PageProps) => {
throw new Error("Invalid Event Type id");
}
const eventTypeId = parsed.data.type;
const caller = await createRouterCaller(eventTypesRouter);
const data = await caller.get({ id: eventTypeId });
const _headers = await headers();
const _cookies = await cookies();
const data = await getCachedEventType(eventTypeId, _headers, _cookies);
if (!data?.eventType) {
throw new Error("This event type does not exist");
}
@@ -1,12 +1,22 @@
import { getTranslate } from "app/_utils";
import { notFound } from "next/navigation";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import Shell from "@calcom/features/shell/Shell";
import UpgradeTipWrapper from "./UpgradeTipWrapper";
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
}
const t = await getTranslate();
return (
<>
<div>
@@ -1,7 +1,4 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsPage from "~/insights/insights-view";
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
}
return <InsightsPage />;
}
@@ -1,7 +1,4 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view";
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
}
return <InsightsVirtualQueuesPage />;
}
@@ -1,7 +1,4 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsRoutingPage from "~/insights/insights-routing-view";
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
}
return <InsightsRoutingPage />;
}
@@ -0,0 +1,19 @@
import { getTranslate } from "app/_utils";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import Shell from "@calcom/features/shell/Shell";
export default async function Layout({ children }: { children: React.ReactNode }) {
const t = await getTranslate();
return (
<Shell
withoutMain={false}
heading={t("organization_members")}
subtitle={t("organization_description")}
headerClassName="hidden md:block"
actions={<div className={`mb-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
{children}
</Shell>
);
}
@@ -0,0 +1,5 @@
import { UserListTableSkeleton } from "@calcom/features/users/components/UserTable/UserListTableSkeleton";
export default function Loading() {
return <UserListTableSkeleton />;
}
@@ -1,7 +1,5 @@
import { _generateMetadata } from "app/_utils";
import MembersPage from "~/members/members-view";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
await _generateMetadata(
(t) => t("organization_members"),
@@ -11,8 +9,4 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
`/settings/organizations/${(await params).id}/members`
);
const ServerPageWrapper = () => {
return <MembersPage />;
};
export default ServerPageWrapper;
export { default } from "../../members/page";
@@ -0,0 +1,7 @@
"use server";
import { revalidateTag } from "next/cache";
export async function revalidateAttributesList() {
revalidateTag("viewer.attributes.list");
}
@@ -0,0 +1,20 @@
import SettingsLayoutAppDirClient from "app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient";
import { getTranslate } from "app/_utils";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export default async function Layout({ children }: { children: React.ReactNode }) {
const t = await getTranslate();
return (
<SettingsLayoutAppDirClient containerClassName="lg:max-w-screen-2xl">
<SettingsHeader
title={t("organization_members")}
description={t("organization_description")}
ctaClassName={CTA_CONTAINER_CLASS_NAME}>
{children}
</SettingsHeader>
</SettingsLayoutAppDirClient>
);
}
@@ -0,0 +1,5 @@
import { UserListTableSkeleton } from "@calcom/features/users/components/UserTable/UserListTableSkeleton";
export default function Loading() {
return <UserListTableSkeleton />;
}

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