Compare commits

..
298 Commits
Author SHA1 Message Date
Keith Williams 55f1019908 chore: v4.6.3 (#17161)
release.yml / release (push) Canceled after 0s
2024-10-18 09:01:23 -03:00
Keith Williams 863fc695ec perf: Add index for invalid column in Credential (#17162)
* perf: Add index for invalid column in Credential

* Added missing file change
2024-10-18 11:42:54 +00:00
Keith Williams d931fb1f9c perf: Add indices to Membership (#17160) 2024-10-18 08:31:16 -03:00
Hariom Balhara fd054dfb8d fix: select_any_in operator - Broken only for attributes (#17148)
* Fix select any_in operator for attributes - Broken only for attributres

* Fix ts error
2024-10-18 10:19:25 +00:00
Hariom BalharaandMorgan 699e08b2ee fix: Possible perf improvement and logging for team members routing logic (#17154)
* chore: increase pagination managed users endpoint (#17147)

* Add server-timings and used concurrency 2 for teamMembers matching

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-10-18 09:44:50 +00:00
Alex van Andel e1228ca03c fix: Hide invite_new_member_description cost from users within an organisation (#17149) 2024-10-18 00:22:40 +01:00
Morgan 9ad69c20cf fix: event-types locations output schema apiv2 (#17150) 2024-10-17 21:07:31 +03:00
Somay Chauhan 9a5efd39ce feat: event team Assignment tab atom (#17145)
* feat: event team Assignment tab atom

* Update event-types-atom.service.ts

* Update event-types-atom.service.ts
2024-10-17 17:33:59 +00:00
Morgan 497b0804d3 feat: create event type atom (#17142)
* feat: create event type atom

* fixup! feat: create event type atom

* fixup! fixup! feat: create event type atom

* docs: base doc for create event type atom
2024-10-17 21:51:23 +05:30
Morgan 8b50f419e5 chore: increase pagination managed users endpoint (#17147) 2024-10-17 14:32:35 +00:00
Alex van Andel 9208827406 chore: Release v4.6.2 (#17146)
release.yml / release (push) Canceled after 0s
2024-10-17 15:22:59 +01:00
Udit Takkar 8891381d89 chore: add logs for debugging (#17111)
* chore: add logs

* fix: filter in prisma query

* test: add test for personal event types

* chore: remove OR condition
2024-10-17 13:43:38 +00:00
Hariom Balhara ab39f0c8b6 Add support for multiselect_some_in for attributes query (#17144) 2024-10-17 12:16:34 +00:00
Vadim Rusin cd2382e091 fix: Add error message for taken username (#17129) 2024-10-17 04:44:52 +00:00
Syed Ali Shahbaz 154c54a2aa fix: Update openapi-v1.json bookings post example (#17132)
* Update openapi-v1.json

* Update openapi-v1.json
2024-10-16 20:59:10 -03:00
sean-brydon e949a4c529 fix: pass from org link to all instances of event.public (#17136)
* pass from org link to all instances of event.public

* fix type error

* fix type error
2024-10-16 14:57:39 +01:00
64b321ac9c feat: added custom class name to booker and availability atom (#17105)
* added custom class name for booker atoms wrapper

* atomsWrapper

* added props to availability atom

* allowDelete
* allowSetToDefault
* added customClassNames -> hiddenSwitchClassname, deleteButtonClassname

* Update availability.tsx

* fixup! Merge branch 'main' into add-custom-classname-to-booker-atom-wrapper

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-10-16 12:04:33 +00:00
e90e060739 feat: Event availability platform wrapper (#17091)
* moved listSchedules to schedule repository

* Create EventAvailabilityTabPlatformWrapper.tsx

* Revert "moved listSchedules to schedule repository"

This reverts commit b5feacfabbadbf9d75eb205dfad8f70b7773a5cb.

* fix: active state of tabs items

* added event availability platform wrapper

* Update event-types.tsx

* better types

only pick the types we need and omit the rest

* fix: type-check failing

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-10-16 13:24:13 +03:00
Hariom Balhara 123074f4ba fix: Hotfix - Revert hideEventTypeDetails handling in Booker.tsx (#17130)
* Revert "fix: DatePicker not showing up in mobile mode when hideEventTypeDetails is true (#17037)"

This reverts commit 1f08bc5a4f.

* Revert "feat: Booker atom enable hiding event details (#16906)"

This reverts commit ba5b9e951a.

* Keep the playground test scenarios
2024-10-16 10:01:04 +00:00
MorganandKeith Williams 8115c86e37 chore: refactor create event -type forms (#17106)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-10-16 08:15:54 +00:00
Syed Ali Shahbaz 92a48b466c chore: developer docs updates (#17112)
* remove mention of data proxy

* fix self hosting broken app link

* fix booking doc

* deploy buttons and more options

* fix capitalization
2024-10-15 22:50:54 +00:00
Calcom BotandReplexica 7a4298dc00 feat: update translations (#17120)
Co-authored-by: Replexica <support@replexica.com>
2024-10-15 22:28:53 +00:00
Alex van Andel 3d379c65e8 Chore: release v4.6.1 (#17125)
release.yml / release (push) Canceled after 0s
2024-10-15 19:08:07 +00:00
Alex van Andel b1d509ba30 fix: organizationId called on possibly undefined profile array (#17123) 2024-10-15 18:56:23 +00:00
Alex van Andel 6360e73901 chore: Release v4.6.0 (#17121)
release.yml / release (push) Canceled after 0s
2024-10-15 17:53:06 +00:00
7e44e686e8 feat: attributes filter / refactor of user data table (#17014)
* WIP restored from .git cache

* fix exports

* sortable row model

* feat column visibility component

* wip filters with nuqs

* pull in unique values from table into filters

* correctly assign filters via v/f

* inital selection bar refactor

* data-table selection bar + optmistic update of delete

* dynamic link

* migrate member list table to new data-table

* total list shows filtered value > db valuie

* add filters for attributes

* type errors

* make content bigger on lg

* add mb-6 to teams user datatable to match spacing spec

* correctly render multi-badge

* fix: masss asignment optimistic UI

* fix type errors

* remove log

* fix toolbar type error

* chore: Remove debug artifact

* type errors

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

* use max-w-fit

* chore: Remove unused translation now we don't specify 'mass' in assign

* perf: fix: use the onBlur event to prevent focus loss whilst the list is rerendering

* Move the data-table exports together in the main barrel, then import

* fix exports that were lost in a merge

* fix exports that were lost in a merge

* fix groupteammapping/availbilityslider

* fix overflow problems

* add scrollbar-thin class

* fix type error

* user serverside values for faceted filters

* pass filters to serverside

* filter serverside

* fix team server side filter

* add loaded x of y

* attributes icon change

* correct implementation for text/input attr optimistic

* type check fixes

* fix platform checks

* fix types again

* fix types again

* fix types again

* add use client

* add use client

* fix-types

* fix: Add missing translation in EN

* fix e2e tests via testid

* fix e2e tests via testid

* fix: Member invite popup not popping up

* Update copyInviteLink to new-member-button testid

* Hopefully fix test ids this time

* fix: Use the right buttons on the right pages

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-10-15 17:45:11 +00:00
Udit Takkar 0b914bef79 fix: update meeting token if expired (#17096) 2024-10-15 10:41:18 -07:00
a48d190af3 feat: #15416 Expand round robin availability beyond default schedule (#15618)
* add feature to change host schedule

* update for type-check:ci

* Update SingleForm.tsx for linterr

* update as per design review

* update for rerendering

* migration files

* On Delete set null for schedule

if schedule is deleted, no need to delete the host

* updated schema

* added testcases

* checking permissions before

* updated for comments

* update for initial value

* updated and cleanup

* set initial value

* update after merge

* updated nit style

* update for console warning and added comments

* nit/chore

* chore

* update for typechecks

* update for scheduleId param after merge

* update after merge or new changes in metadata.config

* update after weights functionality

* updated description text and column name as per review suggestion

* resolved merge conflicts

* fix typecheck due to changes in getScheduleSchema

* update after merge having refactor

* update refactored EventTypeWebWrapper after merge

* updated props

* updated trpc err code and added log

* update due to refactor in EventAvailabilityTab

* perf: Optimise TS type + select only the data that's needed for a specific function

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-10-15 17:36:00 +01:00
Rajiv SahalandKeith Williams c21f0caecf chore: revamp platform docs (#17117)
* all atoms have standalone page

* restructure tabs for platfom pages

* remove unused page

* add missing icons

* cleanup intro page for platform

* update loom links

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-10-15 15:21:09 +00:00
Anik Dhabal Babu 1f42a152b0 fix: link is not rendering properly for SMS and WhatsApp workflows (#17116) 2024-10-15 15:11:29 +00:00
Hariom Balhara b8f0cc6fa1 fix: Add back missing Any in and Not in operators for Single Select (#17102)
* fix: Missing any_in and not_anhy_in opperators

* Add tests
2024-10-15 15:07:40 +00:00
Anik Dhabal Babu c6f84e6c4b fix: Issue chargin no show fee (#17066) 2024-10-15 15:05:53 +00:00
Morgan 9c3f4ec055 feat: allow delete event-type platform atoms (#17065)
* feat: allow delete event-type platform atoms

* fixup! feat: allow delete event-type platform atoms
2024-10-15 14:40:57 +00:00
Alex van Andel f0cfbede7e fix: Fix TS error + set isPending on Button (#17118) 2024-10-15 13:59:31 +00:00
Keith Williams e2a825a9a0 Revert "feat: update github workflows to install playwright in a separate job (#17070)" (#17114)
This reverts commit 0b3b268eed.
2024-10-15 08:23:52 -03:00
Keith Williams 963a2a0b76 fix: Playwright install (#17113) 2024-10-15 07:58:38 -03:00
Benny Joo 4037367f8c fix: theme flickering (#16903)
* fix: theme flickering

* finalise
2024-10-15 11:13:39 +01:00
Aditya GhadgeandKeith Williams 0b3b268eed feat: update github workflows to install playwright in a separate job (#17070)
* feat: update github workflows to install playwright in a separate job

* Update .github/workflows/pr.yml

* Update .github/workflows/pr.yml

* Update .github/workflows/pr.yml

* Update .github/workflows/pr.yml

* Update .github/workflows/pr.yml

* Update .github/workflows/pr.yml

* Update .github/workflows/pr.yml

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-10-15 10:09:38 +00:00
amrit 2acb5559dc refactor: improve variable logic (#17086)
* Improve app processing with async/await and dynamic imports, also fix eslint errors.

* refactor

* added back a comment
2024-10-15 06:35:26 -03:00
Keith Williams be1c6eb65c fix: PR update workflow duplicate job (#17108) 2024-10-15 05:30:22 -03:00
Keith Williams feefa26c90 chore: Add docs build (#17101)
* chore: Add Docs build to CI

* Added build-docs to flow

* Updated to run in the /docs folder

* Made web app build have consistent name

* Added a kill after the command runs

* Test the build

* Added it to the PR flow
2024-10-15 05:08:32 -03:00
Avishay Maor 2c25f88100 Update constants.ts - fix crashing by adding optional chaining for this value (#17103) 2024-10-15 07:32:27 +00:00
2be9fc8bc0 fix: email-address with apostrophe not accepted (#15535)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-10-15 00:21:26 +00:00
Peer Richelsen 5927b42560 Update mint.json (#17085) 2024-10-14 18:48:03 -03:00
Rajiv SahalandMorgan 5726d18027 feat: member management for platform (#16803)
* init members page for platform

* add missing icon

* add members tab to sidebar

* use data from useMe query instead of useSession

* return user profiles since platform users are stored there

* small refactor

* fixup

* remove unused logs from console

* fixup

* resolve merge conflicts

* add missing tabs for members and billing

* update schema

* fixup

* memoise selection options for org and platform

* update onSubmit handler

* resolve merge conflicts

* add members tab

* improve team billing view when user not subscribed

* update members tab

* add function to find if user is admin of team

* update invite members handler to handle platform user as well

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-10-14 21:19:02 +03:00
Anik Dhabal Babu 3312d9cfc6 fix: 500 status code visible when cancelling event (#16957)
* fix: 500 status code visible when cancelling event

* update
2024-10-14 12:58:28 -04:00
Steve Xu c140b01548 fix: Memory leak with cal embed modal box (#17016) 2024-10-14 12:42:57 -04:00
Lauris Skraucis c2085c6be9 refactor: v2 recurring bookings allow specifying recurrence count (#16989)
* fix: CreateBookingInputPipe

* refactor: allow specifying recurrence count

* Update input.service.ts
2024-10-14 17:59:53 +03:00
sean-brydon b8b9d04897 up stripe trails (#17092) 2024-10-14 13:01:01 +00:00
Udit TakkarandSyed Ali Shahbaz 4497c4ce0b perf: improve get bookings performance (#16303)
* refactor: improve get bookings performance

* feat: use map to optimise

* chore: save

* chore: add authorization

* chore: remove condition

* chore: improvements

* chore

* chore: remove return

* chore: use Promise.all

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2024-10-14 17:57:58 +05:30
Anik Dhabal Babu cd06141816 fix: improved code for meeting link is not updated and broken ui in edit location dialog issue (#16013)
* improvise code of issue#15497

* Update editLocation.handler.ts

* update

* Update EditLocationDialog.tsx

* Update editLocation.handler.ts
2024-10-14 08:56:17 +00:00
Rajiv Sahal 013646bf2c fix: atoms tooltip (#17073)
* add tooltip classname prop for styling

* fix incorrect styling

* update props for connect buttons
2024-10-14 09:37:19 +03:00
Anik Dhabal BabuandAlex van Andel 12bbbe3617 fix: Daterangepicker is not opening in some browsers (#17087)
* fix: daterangepicker issue

* update

* fix: useTeamPlan isn't relevant on self-hosted

* chore: Revert UpgradeTeamsBadge fixes, unrelevant to this PR

* fix: Fix button width for other languages

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-10-14 06:46:37 +05:30
Peer Richelsen 2c7c9fd716 feat: telli.so app (#17083) 2024-10-12 17:34:23 +00:00
Keith Williams 11fc2a738a fix: Docs build issue (#17082) 2024-10-12 16:53:43 +00:00
Calcom BotandReplexica 44ab25b4a0 feat: update translations (#17076)
Co-authored-by: Replexica <support@replexica.com>
2024-10-12 13:30:54 -03:00
Ezhil Shanmugham 4a2c0cb164 fix: lock on user view (#17077)
* fix: lock on user view

* fix: lock on user view
2024-10-12 13:54:12 +00:00
Joe Au-YeungandUdit Takkar b1d9e2b84c feat: Salesforce choose how to save attendees & other options (#17009)
* Refactor get SF user and create event

* Add new Salesforce options

* Pass app options to CrmService

* Add record enum

* Add creating new lead and contact under an account

* Handle if the contact already exists but not connected to account

* Create a lead if an account doesn't exist

* Merge fix

* Refactor - add generateCreateRecordBody

* Type fix

* Clean up

* Add getAppOptions to other CRM services

* Type fixes

* Fix typo

* feat: Salesforce round robin skip - choose which entity to search on (#17038)

* Add option to search which entity for the owner

* Add logic to search entity for skipping RR assignment

* Type fixes

* Disable license

* Skip license check in component

* Revert license changes

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-10-12 10:02:27 +00:00
Carina WollendorferandCarinaWolli 47b9e8a8ac fix: attendee name workflow variables in spanish (#17068)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-10-11 09:12:23 -07:00
Benny JooandAlex van Andel 42265021fe chore: settings/my-account - remove pages router (#17056)
* remove pages router and use app router

* fix page

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-10-11 16:30:03 +01:00
Anik Dhabal BabuandUdit Takkar 0b0fee3985 fix: phone number input field is missing from mail (#17035)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-10-11 11:17:18 +00:00
Benny JooandAlex van Andel 5718cc5e17 chore: remove react-query, update next.js to v13.5.6 (#17057)
* chore: remove react-query as unneeded

* chore: Re-gen lockfile

* chore: Bump next.js to v13.5.6

* yarn.lock regen after apps/console next version bump

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-10-11 10:06:20 +00:00
Benny Joo 361889ded7 add node-gyp as dev dependency (#17058) 2024-10-11 10:28:37 +01:00
Rajiv Sahal 2260ddcebc add missing tab for billing in navigation (#17060) 2024-10-11 12:42:32 +05:30
Keith Williams 80c3b91077 chore: Update CI to not run all checks for docs/translations (#17054) 2024-10-11 09:44:13 +05:30
Calcom BotandReplexica a11cf130bf feat: update translations (#17051)
Co-authored-by: Replexica <support@replexica.com>
2024-10-10 20:46:33 +00:00
Omar López f9d6ea2a00 chore: v4.5.11 (#17053)
release.yml / release (push) Canceled after 0s
2024-10-10 13:23:28 -07:00
Udit Takkar 50f6aa5f58 perf: refactor and replacing viewer.teams.getMinimal with viewer.teams.get (#16552) 2024-10-10 12:51:07 -07:00
Keith Williams 132746af74 perf: Use UNION to get public user event types (#17052) 2024-10-10 19:39:32 +00:00
395381ddcc feat: automatic no show (#16727)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2024-10-10 10:57:04 -07:00
Keith Williams ef88effd9f perf: Add index to EventType for parentId (#17049) 2024-10-10 17:46:12 +00:00
Calcom BotandReplexica 673a2be7c7 feat: update translations (#17042)
Co-authored-by: Replexica <support@replexica.com>
2024-10-10 17:19:27 +00:00
Shrey TannaandAlex van Andel b1cd3e940e feat: Add Eastern and Eastern Pacific time zones #16880 (#16974)
* add eastern and eastern pacific timezones

* fixed issue while running tests.

* fix: Find Braz..il instead of Brasil

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-10-10 16:26:17 +00:00
Alex van Andel 711bec8d71 chore: Add typings for DOMPurify (#17047) 2024-10-10 17:17:13 +01:00
Syed Ali Shahbaz 7529e331a3 Pick attendee id in response (#17041) 2024-10-10 15:40:58 +00:00
Peer RichelsenandAlex van Andel cc147d0797 chore: docs changes (#17031)
* Update introduction.mdx

* added new logo

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-10-10 15:24:21 +00:00
c8e8800647 chore: Update EWS package (#16950)
* Update ews package

* Hide exchange version if using NTLM

* Remove instance of gzip

* Update yarn.lock file

* Updated yarn.lock

* Update yarn.lock

* Update yarn.lock

* Update yarn.lock (again)

* chore: Increase readability of [number] indexes by using find

* chore: Async not needed

* fix: Move 'use client' to appropriate file

* refactor

* install deasync directly as dev dependency

* fix type error

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2024-10-10 15:48:58 +01:00
99caae8665 feat: advanced tab platform wrapper (#16941)
* fixed error in advanced tab atom build

-- used `DOMPurify` instead of `sanitize-html`

* hide BookerLayoutSelector for platform and removePlatformClientIdFromEmail

* fixup! Merge branch 'main' into event-advanced-platform-wrapper

* undo linting changes

* undo linting changes

* fix: active state of tabs items

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-10-10 17:09:03 +03:00
Hariom Balhara 1f08bc5a4f fix: DatePicker not showing up in mobile mode when hideEventTypeDetails is true (#17037) 2024-10-10 18:09:53 +05:30
Morgan 5594e5c713 feat: google meet conferencing api v2 (#17023)
* feat: google meet conferencing api v2

* feat: add set default google meet conferencing app

* fixup! feat: add set default google meet conferencing app
2024-10-10 09:28:53 +00:00
Rajiv SahalandMorgan 34afc7b674 feat: additional calendar selector for calendar settings atom (#16874)
* update translations

* fixup

* add onSuccess prop

* add additional calendar selector prop to add multiple calendars

* update props for calendar settings atom

* pass caledar redirect urls for google and apple calendar redirects

* fix merge conflicts

* add onSuccess prop to connect atoms

* fixup

* fixup

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-10-10 09:02:40 +00:00
Somay Chauhan 513a58c91c feat: event type tabs platform wrapper (#16891)
* added event-type platform wrappers

* added weekly

* Update RecurringEventController.tsx
2024-10-10 10:55:13 +03:00
Syed Ali Shahbaz 375b9190a3 add slot endpoint (#17034) 2024-10-09 20:59:10 +00:00
Anik Dhabal Babu 83d7bf322a fix: add missing options for team memberlist table (#17017)
* fix: missing option for team memberlist table

* update
2024-10-09 18:23:45 +01:00
06429e27fb test: Add Attributes Routing e2e and some more unit tests (#17032)
* Initial commit

* routingForm to Booking a particular team member working

* Happy path working

* Fixes

* Fix router query params forwarding

* Add basicConfig within app

* Tests

* More tests

* Update packages/app-store/routing-forms/components/SingleForm.tsx

Co-authored-by: Omar López <zomars@me.com>

* WIP

* Fix lint issues

* Fix tests -TS and add new logic

* Add getEventTypeRedirectUrl tests

* Remove booking title changes code

* More changes

* Add tests for getRoutedUsers

* More tests

* Update testUtils.ts

---------

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-10-09 22:47:16 +05:30
Benny Joo 90bcd711a9 fix: padding in TeamPill (and hence TeamRole) (#17030) 2024-10-09 15:21:26 +00:00
Keith Williams 8d61b452b1 chore: Removed development only docs tags (#17028) 2024-10-09 11:58:58 -03:00
Hariom BalharaandOmar López 03d0b98155 feat: Skip contact owner even if it matches attribute routing (#17015)
Co-authored-by: Omar López <zomars@me.com>
2024-10-09 07:30:12 -07:00
de503add83 feat: add nextcloud talk app (#16455)
* Initial draft of nextcloud app

* Add video adapter for creating a new conversation via API

* Make links dynamic, add nc user and pwd for auth, fix link creation

* Add nextcloudTalk app to yarn.lock

* Fix screenshot name

* Add OAuth2 handler

* Fix type for booking reference and fix access token header param, (for creating and deleting a meeting)

* Remove comment

* Add README.md file

* Fix redirect URL for authenticating Nextcloud user

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-10-09 17:52:30 +05:30
Keith Williams 1a4cf5f91d fix: URLs to images in docs (#17022) 2024-10-09 11:48:26 +00:00
Calcom BotandReplexica ed53645783 feat: update translations (#17021)
Co-authored-by: Replexica <support@replexica.com>
2024-10-09 10:37:44 +00:00
sean-brydon c7b1fbdf67 feat: mass assign attributes (#16995)
* UI popover on user table

* fix multi select state

* mass assignment

* add i18n
2024-10-09 12:02:48 +02:00
sateshcharan 6d7f7108e5 fix schema.prisma path (#17018) 2024-10-09 08:55:30 +00:00
Hariom Balhara d9aab9088d feat: Support headless router as well in embed (#17012)
* Support /router/embed path and add Headless router usecase in playground

* Fix broken seeded-form testing wrong thing
2024-10-09 13:24:45 +05:30
Hariom Balhara c80daf43b8 Fix prettier error and EMailField import error in website (#17011) 2024-10-09 08:30:29 +02:00
Omar López 2bdf65ce8c fix: billing webhook secret (#17005)
* fix: billing webhook secret

* Update __handler.test.ts
2024-10-08 21:26:39 +00:00
Keith Williams 343e2ac9d7 chore: v4.5.10 (#17001)
release.yml / release (push) Canceled after 0s
2024-10-08 16:59:25 -03:00
Benny Joo 670b82fd23 fix: add fallback to data needed in SettingsLayout (#17004) 2024-10-08 15:51:38 -04:00
Keith Williams e6f81d09c8 chore: Update PR template to account for new docs (#17003) 2024-10-08 15:35:23 -03:00
Benny Joo 8f91ffe692 Revert "remove pages router and use app router (#16793)" (#17002)
This reverts commit 664a92a1d0.
2024-10-08 15:00:43 -03:00
Benny Joo f5dbdae4a9 fix: Close OOO Create Modal on backdrop click (#17000) 2024-10-08 17:42:28 +00:00
Calcom BotandReplexica e99f6048d6 feat: update translations (#16999)
Co-authored-by: Replexica <support@replexica.com>
2024-10-08 23:08:47 +05:30
654a204878 feat: Attribute based routing from Team Routing Form to Team Event(with assigned users matching the attribute logic) (#16823)
* Initial commit

* routingForm to Booking a particular team member working

* Happy path working

* Fixes

* Fix router query params forwarding

* Add basicConfig within app

* Tests

* More tests

* Update packages/app-store/routing-forms/components/SingleForm.tsx

Co-authored-by: Omar López <zomars@me.com>

---------

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2024-10-08 21:12:32 +05:30
Syed Ali Shahbaz 7928933852 fixes (#16990) 2024-10-08 15:36:16 +00:00
Lauris SkraucisandMorgan Vernay c4cdba1015 refactor: throttler rate limits for oauth client & access token (#16962)
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-10-08 17:34:41 +03:00
Benny Joo 5ed36a7fe1 chore: remove react code in getLayout (#16997)
* chore: no need to pass title, description in getLayout

* acutally, we do
2024-10-08 14:22:26 +00:00
Joe Au-YeungandBenny Joo e77a2f6963 fix: Salesforce assign event to organizer (#16971)
* Refactor get SF user and create event

* address code nit

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2024-10-08 14:06:51 +00:00
Lauris Skraucis a10d3599e2 refactor: platform bookings hooks & bookings API (#16963)
* chore: delete unused bookings hook

* refactor: group bookings hooks in folder

* refactor: rename useGetBooking, useGetBookings to useBooking, useBookings

* refactor: booking inputs and outputs

* chore: make bookings controller work with new inputs and outputs

* make examples app work

* refactor: deprecate fields instead of removing them

* readd eventtypeid

* swagger regenerate

* make recurring booking backwards compatible

* fix test

* update changelog

* swagger update

* fix import

* chore: update lock file
2024-10-08 14:05:28 +02:00
Alex van Andel d99f171597 perf: Only render the TimezoneChangeDialog when needed (#16986)
* perf: Only render the TimezoneChangeDialog when needed

* fix: Import error

* Minor style fixes
2024-10-08 10:41:53 +01:00
Syed Ali ShahbazandKeith Williams da55306a5a chore: add dev docs on mintlify (#16935)
* --init

* progress...

* add more docs

* bunch of changes and added back platform

* development guides added

* more guides in develop with cal

* self hosted WIP

* deployments

* fix deployment steps

* self-hosted guides

* ..

* Removed the development only sections

* Removed 'with Cal.com'

* use steps component for steps

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-10-08 02:32:16 -03:00
Benny Joo 66378160df chore: remove log (#16981) 2024-10-07 22:10:25 +00:00
Anik Dhabal Babu 8aee8d9d3b fix: SMS reminders are being sent out with an HTML code (#16966) 2024-10-07 17:51:04 -04:00
Calcom BotandReplexica 79e33a4d26 feat: update translations (#16972)
Co-authored-by: Replexica <support@replexica.com>
2024-10-07 15:37:32 -03:00
Carina WollendorferandCarinaWolli b5fa43f086 feat: checkbox to count managed events for team-wide limits (#16923)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-10-07 11:36:50 -04:00
Joe Au-Yeung 0d757039f7 chore: Fixing ICS file in workflows & ICS calendar descriptions [CAL-4409] (#16731)
* Remove title and subtitle from ICS calendar description

* Remove unused method

* Use generateIcsString function for workflows

* Type fixes

* Refactor typing of CalEventParser

* WIP

* Type fixes

* Fix test

* Fix tests

* type fixes

* Remove duplicate code before `scheduleEmailReminder`
2024-10-07 10:50:50 -04:00
Lauris Skraucis 44f6f2e320 refactor: v2 throttler guard (#16922)
* chore: add logs

* refactor: replace deprecated redis.setex with redis.set

* chore: log response headers
2024-10-07 11:02:41 +00:00
Benny Joo d8eb1df61d fix: add suppressHydrationWarning to app router layout (#16960) 2024-10-07 12:40:09 +02:00
Lauris Skraucis ef7cac6c50 fix: booker atom phone booking field (#16945)
* fix: booker atom phone booking field

* chore: show usual booker in examples app

* refactor: import styles file manually
2024-10-07 12:39:54 +02:00
Benny Joo 21dd44733f fix: make theme logic safe and add better e2e tests (#16959)
* fix: improve theme logic

* remove null from ThemeLabelProps value

* add better e2e tests
2024-10-07 11:23:30 +01:00
Lauris Skraucis b0bfec7247 feat: Booker atom read only fields (#16904)
* refactor: include user-readonly in BookingFields

* feat: add read only name and email booking fields

* feat: Booker atom prop for read only name and email

* refactor: simplify props type
2024-10-07 15:03:07 +05:30
Lauris Skraucis ba5b9e951a feat: Booker atom enable hiding event details (#16906)
* refactor: Booker component hideEventTypeDetails hides sidebar

* feat: Booker atom hideEventTypeDetails prop

* fix: redundant border appearing if event details hidden

* Revert "fix: redundant border appearing if event details hidden"

This reverts commit 63122a906a8e41b54074387e1a6a4f0e4489a8a9.

* fix: border
2024-10-07 09:52:47 +02:00
Lauris Skraucis 5e3865b645 docs: v2 mintlify update (#16961) 2024-10-07 06:54:36 +00:00
Rajiv Sahal a02240b579 fix: add missing ApiOperation tag v2 (#16947)
* add missing api operation tag

* update openapi spec for docs
2024-10-06 20:04:25 -03:00
Benny Joo 376caa412d fix: DOM attribute warning in event-types edit page (#16953) 2024-10-05 03:39:11 +00:00
Benny Joo 8722745185 chore: improve readability of some tests (#16954) 2024-10-05 08:49:29 +05:30
+12 a4a3eb5ea9 refactor: Billing service (#15366)
* WIP: Billing service

* WIP

* Update __handler.ts

* Update index.ts

* WIP

* WIP

Signed-off-by: zomars <zomars@me.com>

* WIP

Signed-off-by: zomars <zomars@me.com>

* Update delete.handler.ts

* Update delete.handler.ts

* Update internal-team-billing.ts

* WIP

* Type fixes

Signed-off-by: zomars <zomars@me.com>

* WIP

Signed-off-by: zomars <zomars@me.com>

* Discard changes to apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.ts

* Discard changes to packages/app-store/skype/config.json

* style: update text area (#15934)

* chore: update text-editor input

* chore: update text-editor input

* final update

* Update Editor.tsx

---------

Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>

* fix: #15697 Assignment warning modal poping up ,even when hosts are added . (#15699)

* removed reduntand check on assignedusers

* update for managed eventtype

* formatted

* comments

* Updated comments

* extracted checkForEmptyAssignment and added tests

* updated comments

* updated to show dialog also when not yet saved

* update translations

* updated comments

* updated translation

* updated types

* chore: update naming and comments

* Delete apps/web/lib/checkForEmptyAssignment.ts

deleted since file was renamed

* Delete apps/web/test/lib/checkForEmptyAssignment.test.ts

deleted as the file is renamed to be more descriptive

* update to do teamcheck before

* chore: revert name to CheckForEmptyAssignment

* updated check conditions

* updated useEffect dependencies

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>

* fix: getting an error when a team member tries to leave (#15925)

a team

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>

* feat: add transitions to more components (#15916)

* add transitions to more components

* chore: Remove duplicate class

* fix: Change minimum number of teams required for org self-serve (#15955)

* fix: Change minimum number of teams required for org self-serve

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Use a new constant for displaying org helper text dialogue

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* Update packages/lib/constants.ts

* Update turbo.json

---------

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* feat: pwa ready and add shortcut for quick access (#15957)

* chore: Update site.webmanifest with additional display properties

* chore: Update site.webmanifest with additional display properties

* Update apps/web/public/site.webmanifest

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* i18n: update translations for "by proceeding, ..." (#15921)

* feat: Add title attribute to iframe in embed.ts (#15944)

* feat: Add title attribute to iframe in embed.ts

* Update packages/embeds/embed-core/src/embed.ts

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* fix: Assignment reminder modal appears when deleting a team event type (#15544)

* fix: Assignment reminder modal appears when deleting a team event type

* update

* Update EventTypeSingleLayout.tsx

* Update event-types-single-view.tsx

* update

* Early return https://handbook.cal.com/engineering/best-practices/prefer-early-returns

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>

* fix: Add CORP and COEP headers to embedded pages (#15275)

* Add CORP headers

* Improve test

* Add unit tests as well

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* feat: No-show bookings - do not count towards a member's round robin assignment (#15323)

* feat: No-show bookings - do not count towards a member's round robin assignment

* filter no show organizer

* remove logs

* fix: filter bookings where host attendee is no show

* fix: use not condition for noShow

* chore: test

* add default false for noShowHost

* Revert "chore: test"

This reverts commit c914a8fc4a14ca9c7d8c4a08ff8b0f463789171c.

* chore: integration test

* fix: noShowHost not working for null

* Update packages/lib/server/getLuckyUser.ts

* chore: update test description

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: gh0st91848 <gh0st91848@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* refactor: v1 orgs unlimited teams (#15908)

* refactor: v1 orgs unlimited teams

* fix: response message ownerId

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* fix: refresh connect atoms on token change (#15933)

* added dropdown

* change comment

* fix: added types

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* fix: Next.js bundle analysis (#15903)

* fix: Next.js bundle analysis

* Added the main checkout back

* fix: type in webhook payload (#15912)

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

* fix: auto accept (#15975)

* fix: auto accept

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

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* fix: Broken GitHub workflows (#15979)

* test: Delete credential endpoint (#15322)

* Init

* Add mock trpc context

* Intro mockDatabaseClient

* Introduce mockDatabaseClient class

* Add delete video app test

* Add calendar test

* Remove unused func from bookingScenario

* Remove console.log

* Add app repository

* Add createMany method to event type repository

* Remove instance of MockDatabaseClient from video test

* Add destination calendar repository

* Remove instances of MockDatabaseClient

* abstract logic to own handler

* Remove dev dependency

* Clean up

* Type fix

* Pull yarn.lock from main

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>

* fix: #15967 Show better error when wrong variable is used in custom event name (#15974)

* update to show exact err msg with custom event name

* updated tests for validateCustomEventName

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>

* WIP

Signed-off-by: zomars <zomars@me.com>

* Update yarn.lock

* Update publish.handler.ts

* Update internal-team-billing.test.ts

* rename create to init

* remove sync services

* wip tests for delete team

* wip tets

* wip tests

* tests/internal-test

* remove return that is inaccessable

* remove team calls to use repository

* remove redudant file

* update team repo

* add stripe mocks and test

* fix constants

* tests to main stripe handler

* remove logs

* fix constants

* Update packages/features/ee/billing/api/webhook/_customer.subscription.deleted.team-plan.ts

Co-authored-by: Omar López <zomars@me.com>

* move workflow logic to a service

* use static method > creating new class

* fix: remove http code deep in repo + tests

* Feedback

* Update index.ts

* Type fix

* Lazy load fixes

---------

Signed-off-by: zomars <zomars@me.com>
Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: Rohan Advani <122167726+RNAdvani@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Vijay <vijayraghav22@gmail.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Hichem Fantar <hichemfantar2049@gmail.com>
Co-authored-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Johannes Maendle <141119154+jomaendle2@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: gh0st91848 <gh0st91848@gmail.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: sean <sean@brydon.io>
2024-10-04 13:44:50 -07:00
Benny Joo b560475d89 fix: flaky one in local e2e test (#16949) 2024-10-04 20:36:52 +00:00
Calcom BotandReplexica 4872daef60 feat: update translations (#16943)
Co-authored-by: Replexica <support@replexica.com>
2024-10-04 20:15:18 +00:00
Anik Dhabal Babu dfa92f5cb6 fix: currency conversion error for paypal (#16917)
* fix: currency conversion error for paypal

* type-error
2024-10-05 01:11:09 +05:30
Benny Joo babb1a3bf7 fix annoying warning due to key spread (#16948) 2024-10-04 19:39:20 +00:00
Benny Joo 7b5a2726ab fix: translations for insights, routing-forms, apps store, etc (#16933)
* remove outdated translations for insights

* use i18n strings for insights

* use i18n strings for routing forms

* use i18n strings for apps store

* fix more pages

* refactor

* fix
2024-10-04 09:45:51 -03:00
sean-brydon 7d4653296e add trial days to stripe teams checkout (#16940) 2024-10-04 12:42:51 +00:00
Hariom Balhara 644fb38f87 test: more Embed Page Params filtering tests (#16937)
* Add rescheduleUid as config param scenario

* Restructure test buttons

* Use correct namespace as per rename
2024-10-04 12:01:59 +01:00
Ash Davis 743b155a88 fix: rescheduledBy to carry into success url redirect (#16931) 2024-10-04 10:59:52 +01:00
Benny JooandAlex van Andel 664a92a1d0 remove pages router and use app router (#16793)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-10-04 10:28:21 +01:00
Hariom Balhara 8ae71f6160 fix: Certain query params being filtered out from embed (#16936) 2024-10-04 12:59:45 +05:30
Omar López ab1eab9c54 chore: v4.5.9 (#16926)
release.yml / release (push) Canceled after 0s
2024-10-03 12:26:35 -07:00
MuhammadandAnik Dhabal Babu a64b7752c9 fix: Tooltip must be used within TooltipProvider (#16918)
* fixes #16833

* Update index.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-10-03 15:37:11 +00:00
Keith WilliamsandSyed Ali Shahbaz be728e9c01 feat: add Mintlify docs (#16787)
* feat: Add Mintlify docs

* Fixed the mint.json for v2

* Fixed versioning issue

* Add titles to ICS feed endpoints

* Added errors to v1

* Added rate-limit to API v1

* feat: added v1-v2 differences and v1 auth

* Trying to eliminate reasons for v1 not showing on publication

* Used the swagger openapi validator

* remove deprecated v1 endpoints

* Moved api docs into new folders without api-reference

* Renamed api to api-reference

* removed the versioning

* Got rid of anchor warnings

* Fixed naming

* Just as a test - using the same v2 openapi spec for v1 reference

* Removed 'externalDocs' from v1

* Removed securitySchemes

* Updated the diff between v1/v2

* Added v1 in front of /attendees to test

* Moved paths up and removed externalDocs

* Testing - Added content to the response

* Reduced paths to 1

* Testing: anything at this point

* Fixed validation

* Moved the other .mdx files to see if that's disrupting something

* Trying with a tab

* Empty tags array

* Added back servers field

* Removed all other schemas

* Removed v1 for now

* Added platform docs

* Language fixes

* Added v1 back

* add API V1 response for attendees

* remove unnecessary endpoint docs in API V1

* Removing platform and API for now

* Updated v2 openapi doc

---------

Co-authored-by: Syed Ali Shahbaz <alishahbaz7@gmail.com>
2024-10-03 15:26:01 +04:00
Benny Joo 2146c04444 fix: i18n for app router (#16909)
* fix: translations for app router

* fix build error
2024-10-03 00:32:33 +01:00
Calcom BotandReplexica 0b94473c7f feat: update translations (#16911)
Co-authored-by: Replexica <support@replexica.com>
2024-10-02 17:44:36 -04:00
2a1dc619ee feat: Multiple Private links per Event Type (#15896)
* feat: Change hashedLink field to array

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Implement core single-use links logic

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Get private link from list of hashedLinks on event list page

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix defalut single-use links form field value

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix some type errors while compiling

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Use onChange callback instead of setting form values directly

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Add e2e tests for single-use links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Refactor single-use-links admin interface

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix settings toggle bugs

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Remove redundant inline comments

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Remove debug statements

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Remove single-use links e2e tests

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Remove private-link feature and override with single-use-links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix type errors

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Remove redundant migrations

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Add managedEvents restriction

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix single-use-links for org

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix recommended UI changes

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix duration not respected for private links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix team event page title

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Add logic to copy each single-use-links from event listing page

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Add single-use links support for managed event types

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix some type errors

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix type errors

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Disable adding new single-use links when field is locked

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Address feedbacks

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Hide add new link button for locked single-use link field

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Use empty string for single-use link fallback value condition

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix org single-use links in listing page

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Auto toggle unlock button for managed event's single-use links option

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* Revert "feat: Auto toggle unlock button for managed event's single-use links option"

This reverts commit 5a2ed6f0bf428d52de153335499f02f42ccdf18f.

* chore: Disable parent toggle for single-use-links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Remove tooltip and give info message below field

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* add tooltip

* tooltip message

* fix: Fix typo

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix typescript type errors

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix few unit tests

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Remove redundant query

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* merge conflict fix

* More merge conflict fix and refactor

* fix tests, code refactor

* fix: type check

* fix: duration config and keep links when updating event type

* fix: e2e

* refactor: Replace single-use-links with multiple-private-links

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

---------

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Amit91848 <samit91848@gmail.com>
2024-10-03 00:58:54 +05:30
Benny JooandKeith Williams 48a2fc2f67 fix: translations for sidebar (#16910)
* fix: translations for sidebar

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

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-10-02 18:21:25 +00:00
4553455098 chore: refactor shell to individual files (#16893)
* banners

* useAuthHooks

* fixes to redirect and banner

* extract useIntercom to custom hook

* use app theme

* extract user-dropdown to new component

* Navigation Item

* navigation and profile dropdown

* Fix import

* navigation and sidebar

* fix type errors

* fix banners being an async call

* fix types

* fix banner prop type

* fix mobile nav item import

* fix banner types

* (revert) layout banner render method to fix type error

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-10-02 15:35:56 +01:00
b4f1b5aebe refactor: handleNewBooking #4 (#15673)
* refactor: handleNewBooking #3

* refactor: create booking factor

* refactor: handleNewBooking

* refactor: seats and rescheduleUId

* chore: remove comment

* fix: type err

* chore: add missing statement

* chore: use less params and other improvements

* chore: name

* chore: improvement

* fix: type err

* Typo fix

* chore: fix type err

* refactor: more readable

* refactor: improve code

* fix: conflicts

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2024-10-02 10:26:25 -04:00
a99e285d76 fix: fix ui overflow on phone for calendar settings (#16661)
* fix: fix ui overflow on phone for calendar settings

* fix: overflow issue with long emails

* Update CalendarSwitch.tsx

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-10-02 12:24:25 +00:00
Lauris Skraucis bdea436f8b docs: v2 refresh tokens and slots endpoints swagger (#16788)
* docs: oauth refresh endpoint

* docs: slots reserve and delete

* docs: get slots

* swagger

* fix: reserving slot

* Revert "fix: reserving slot"

This reverts commit 8a3ce8464d5dbf67e58da6d3b1aeb51df97240cb.

* Update oauth-flow.controller.ts
2024-10-02 08:44:05 +00:00
Alex van Andel 182d0fa228 fix: Better defaulting to NEXT_PUBLIC_LOGGER_LEVEL=4 (#16900)
* fix: Better defaulting to NEXT_PUBLIC_LOGGER_LEVEL=4

* chore: Minor fixes to prettyLogTemplate (as it wasnt very pretty)
2024-10-01 17:38:02 +00:00
Alex van Andel 0d7f560c43 chore: improve instrumentation deps (#16899)
* Remove Sentry from direct import in instrumentation file

* chore: Exclude Sentry configs when not needed
2024-10-01 17:52:13 +01:00
Alex van Andel 1377f1a214 chore: Release v4.5.8 (#16896)
release.yml / release (push) Canceled after 0s
2024-10-01 15:38:59 +01:00
Lauris Skraucis e8dfa50119 fix: booker atom recurring event (#16719)
* fix: BookerEmbed prop type

* fix: booker atom not displaying recurring event

* fix: count interpolation in i18n
2024-10-01 14:00:04 +00:00
706e8e4c1f feat: added workflows to all actions (#16691)
* feat: added workflows

* moved the condition from Editor to excludedToolbarItems

* revert editor changes

* Update WorkflowStepContainer.tsx

* update

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-10-01 18:50:17 +05:30
Alex van Andel a95729f4a1 chore: Couple fixes to improve error reporting (#16895)
* chore: Couple fixes to improve error reporting

* tweak: Fix error message

* refactor: Use findUniqueOrThrow instead of guard

* fix: Broken test, missing ID - suspect it passes because only one record exists

* chore: Minor refactor to split ZodEffect from ZodType

* fix: Satify typescript
2024-10-01 09:18:51 -03:00
Lauris Skraucis 4b6a389212 feat: v2 rate limiting (#16882)
* refactor: pass redis storage to throttler guard

* chore: upgrade throttler to latest

* feat: ApiKey RateLimit table

* chore: upgrade redis storage throttler

* feat: rate limit by api key

* refactor: on delete api key cascade rate limit

* fix: permissions guard work with oauth credentials

* chore: set rate limit in env

* tests: throttler

* feat: include rate limit name in response

* fix: correctly handle multiple rate limits

* chore: remove unused import

* delete migrations

* chore: prisma migration

* doc

* dummy

* fix: permissions guard unit test

* refactor: remove route specific @Throttles

* fix: permissions guard
2024-10-01 12:01:56 +00:00
Morgan 9d3be8de03 chore: improve event-type atom desc, data fetch (#16890) 2024-10-01 10:38:37 +00:00
Benny Joo b283f1f8cd fix: theme is not configured in app router (#16889)
* fix: theme is not configured in app router

* fix type error

* remove logs

* use testBothFutureAndLegacyRoutes

* add APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED
2024-10-01 04:39:37 -04:00
5ff52e1239 chore: add saml signup test / improve signup e2e test (#16849)
* add comment to signup-view

* add a basic test for saml button in signup

* add a test for cookie consent checkbox

* refactor and remove duplicates

* rename data-testid for saml button

* add test for saml

* remove code not needed

* better naming

* improve

* address code nit

* skip saml test when needed

* fix flag

* improve

* improve more

* refactor

* refactor

* refactor

* fix and add 1 more test

* remove tag

* remove unneeded code

* fix

* fix test

* it needs to be parallel mode

* reduce code

* simplify

* fix

* fix flake

* fix another flake

* add comments + refactor

---------

Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-10-01 09:34:20 +01:00
Calcom BotandReplexica 2b1cb9f878 feat: update translations (#16886)
Co-authored-by: Replexica <support@replexica.com>
2024-10-01 06:23:01 +00:00
Benny Joo 74a46e8ba3 chore: (1) settings/security - remove pages router and use app router (#16795)
* remove pages router and use app router

* add settings layout

* remove env vars
2024-09-30 23:27:58 +01:00
Jevtić NenadandRajiv Sahal 5b5f7c1c48 fix: Fix conditional check for oAuth client id (#16809)
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2024-09-30 18:56:17 -03:00
Udit Takkar 9375efffe3 feat: prevent sending multiple instant meeting request (#16881) 2024-09-30 18:54:55 -03:00
sean-brydon da59c9de12 fix getEventTypeWorkflows (#16879) 2024-09-30 22:09:18 +01:00
2a734faf45 fix: undefined in meeting url workflow variable (#16771)
* make bookerUrl mandatory

* add missing bookerUrl

* add fallback

* fix roundRObinReassignment

* clean up

* allow bookerUrl to be undefined

* code clean up

* make bookerUrl mandatory

* fix type errors

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-09-30 15:25:51 -04:00
Calcom BotandReplexica 934cefecce feat: update translations (#16884)
Co-authored-by: Replexica <support@replexica.com>
2024-09-30 15:18:41 +00:00
Rajiv SahalandMorgan c88811aeee feat: stripe connect atom (#16190)
* update stripe service

* init endpoints for stripe connect atom

* update stripe controller

* update stripe service

* add generic function to create app credential

* update stripe controller

* update stripe service

* add handler to construct redirectUrl

* restructure files for better readibility

* better naming

* replace STRIPE_PRIVATE_KEY with STRIPE_API_KEY

* fix naming

* remove unused query param

* frontend for stripe connect atom

* custom hooks for stripe connect atom

* abstract response schema into separate file

* fixup

* input dto for stripe controller

* update stripe module

* update endpoints module to include stripe module

* updatte stripe module

* fixups and add check endpoint to stripe controller

* add method to check stripe account

* add helper fn to get on error return value

* update stripe connect atom

* custom hook to check user stripe credentials

* add stripe connect to atom exports

* update stripe connect styling

* add qs stringify package

* translations for stripe connect atom

* updaet typing

* update output dtos for stripe endpoints

* add error message

* fix merge conflicts

* add stripe connect atom to atom exports

* add query param for error rediect link

* add handler for onCheck success and error redirect link

* update index value

* fix merge conflicts

* fixup

* resolve merge conflicts

* war with merge conflicts

* update examples app

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-09-30 19:56:22 +05:30
Rajiv Sahal f46c3c65fb filter connected calendars before displaying it in tooltip (#16883) 2024-09-30 19:45:28 +05:30
Rajiv Sahal 1d203dc566 feat: frontend to handle multiple calendars for connect atoms (#16527)
* handle multiple calendars in connect atoms

* resolve merge conflicts

* fixup

* frontend for multiple calendars

* fixup

* remove comment

* add missing props

* add prop for multi calendar view

* add multi calendar support

* fixup
2024-09-30 11:33:18 +00:00
Rajiv Sahal 26a1c749f7 chore: refactor billing service v2 (#16805) 2024-09-30 13:56:21 +03:00
MorganandSomay Chauhan ad347cb59f feat: Event Type Atom base pr (#16831)
* feat: Event Type Atom base pr

* fixup! Merge branch 'main' into chore-event-type-atom-platform-wrapper-base-pr

* Update EventTypeWebWrapper.tsx

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-09-30 13:44:11 +03:00
Udit TakkarandSyed Ali Shahbaz ac300ff670 refactor: enable infinite scrolling for all (#16463)
* refactor: enable infinite scrolling for all

* chore: remove unused code

* fix: tests

* chore: type err

* chore: update email embed test

* chore: update tests

* chore: update tests

* chore: fix horizontal tabitem

* chore

* fix: conflicts

* chore: conflict

* fix: E2E tests

* chore: remove idle

* fix test

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2024-09-30 10:44:04 +01:00
c834b927b6 refactor: event advanced web wrapper (#16711)
* chore: EventTypeWebWrapper base pr

* fixup! chore: EventTypeWebWrapper base pr

* fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* wip

* fix: error

* Event type atom wrapper handles app dir

* fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* fixup! fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* remove console log

* review comments

* fixup! review comments

* refactor: event advanced web wrapper

* rename `connectedCalendars` -> `calendarsQueryData`

* Update BookerLayoutSelector.tsx

* moved useMeQuery up the parent tree

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-09-30 12:15:11 +03:00
Somay Chauhan 34ba27407b refactor: event availability web wrapper (#16808)
* refactor: event availability web wrapper

* useMeQuery moved up the parent component
2024-09-30 08:16:18 +00:00
Somay Chauhan 8431cd2ef8 refactor: event recurring web wrapper (#16764) 2024-09-30 08:11:13 +00:00
Benny Joo cd09a56491 fix hydration error from html mismatch (#16876) 2024-09-30 06:04:27 +05:30
Calcom BotandReplexica 5c05a422a8 feat: update translations (#16851)
Co-authored-by: Replexica <support@replexica.com>
2024-09-28 22:42:30 +00:00
Benny Joo e805c0f00a fix spacing in signup (#16850) 2024-09-27 18:43:30 +00:00
Priyansh 2fa7f7931d Fix TODO in AppCard.tsx (#16863) 2024-09-27 17:51:19 +00:00
sean-brydon 20729b3a4e chore: Move getRemindersFromRemovedTeams and deleteAllWorkflowReminders to workflow repo (#16861)
* wip

* move deleteAllWorkflowReminders to repo
2024-09-27 10:42:09 -04:00
Lauris Skraucis 4d9777af8b docs: v2 event-types (#16854)
* docs: optional get event-types query params

* docs: create event type input

* docs: update event type input

* docs: child inputs of create and update event type inputs

* docs: updated swagger

* refactor: add disabled option to booking window

* refactor: seats description

* swagger
2024-09-27 09:48:22 +00:00
Hariom Balhara 1534588b34 feat: Forward all the page params to embed (#16330)
* Forward all query params

* Add Unit tests

* Add example in playground
2024-09-27 15:14:56 +05:30
Morgan 7183ec7f1b fix: add teamId to platform urls (#16852) 2024-09-27 09:19:20 +03:00
Hariom BalharaandPeer Richelsen d836497d56 fix: Don't AutoScroll to embed when multiple-duration event's booking page is embedded (#16411)
* Fix and add test and eslint rule

* Fix eslint errors

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-09-27 09:57:04 +05:30
Syed Ali ShahbazandUdit Takkar 38fa0fae1d fix: insights UI/UX (#16610)
* remove old code remnant

* -

* WIP

* type change

* --

* typefix

* fix type

* fix type

* change type name

* add basewhere to eventsByStatus

* add basewhere to the rest

* fix rendering null popular event list item

* URL params for start and endtime to have YYYY-MM-DD format

* remove console log

* resolve suggestion

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-09-27 03:36:32 +00:00
Mohit 2872b263fd fix: <a href> is not clickable cause of z-index (#16842) 2024-09-27 02:38:31 +00:00
Keith Williams 92e53adc8e chore: v4.5.7 (#16848)
release.yml / release (push) Canceled after 0s
2024-09-27 08:19:58 +09:00
Calcom BotandReplexica 72cc4ab13e feat: update translations (#16843)
Co-authored-by: Replexica <support@replexica.com>
2024-09-26 22:41:09 +00:00
4e82ffe2ad feat: adding Bolna Voice AI integration for cal.com (#16828)
* fix: #11213, #11215

* Use default colour

* adding bolna app for cal.com

* cleanup by removing redundant changes

---------

Co-authored-by: Siddharth Movaliya <siddraj1992@gmail.com>
Co-authored-by: Sean Brydon <sean@cal.com>
2024-09-26 21:20:28 +00:00
Benny Joo 90f6c7e239 feat: New UI for SignUp / Login (#16802) 2024-09-26 15:27:56 -04:00
Keith Williams ffa58d1cdb chore: v4.5.6 (#16837)
release.yml / release (push) Canceled after 0s
2024-09-27 03:00:13 +09:00
Calcom BotandReplexica f834e93652 feat: update translations (#16839)
Co-authored-by: Replexica <support@replexica.com>
2024-09-26 17:59:54 +00:00
ae7fae7da4 feat: Add hideCalendarEventDetails option to event types (#16817)
* Add `hideCalendarEventDetails` to DB

Co-authored-by: Alex van Andel <emrysal@users.noreply.github.com>

* Add option to hide calendar event details

* Pass `hideCalendarEventDetails` to event object

* Add to API

* Add to CalendarEvent class

* Pass `hideCalendarEventDetails` to calendar services

* Adjust test

* fix: move `hideCalendarEventDetails` to `api/v2/event-types_2024_06_14` and remove from older versions

- Added `hideCalendarEventDetails` to `api/v2/event-types_2024_06_14`
- Removed `hideCalendarEventDetails` from `api/v1` and `api/v2/event-types_2024_04_15`

* fix: calEventRaw is undefined (use event instead)

* chore: Remove debug artifact

* fix: description id + update to copy

* fix: Attempt at fixing type error

* Add hideCalendarEventDetails to test builder

---------

Co-authored-by: Alex van Andel <emrysal@users.noreply.github.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-09-26 17:08:25 +00:00
Keith Williams b6c6e41603 chore: Allow redirecting /docs to external docs solution (#16836)
* chore: Allow redirecting /docs to external docs solution

* Added DOCS_URL to .env.example
2024-09-26 16:35:35 +00:00
Lauris Skraucis e4dc2d0877 fix: platform cancel booking webhook not triggered (#16827)
* fix: pass oauth id to webhooks upon cancel booking

* chore: v2 use latest libraries
2024-09-26 14:49:53 +00:00
Lauris Skraucis e43690f190 chore: bump slots rate limit (#16826) 2024-09-26 13:54:12 +00:00
Lauris Skraucis 5bbccad41d fix: selected calendars atom deselect calendar (#16825) 2024-09-26 08:26:33 +00:00
Calcom BotandReplexica bc53037f37 feat: update translations (#16813)
Co-authored-by: Replexica <support@replexica.com>
2024-09-25 23:23:31 +00:00
Nick Holden b4a1a8e7bf chore: Replace campsite.co with campsite.com (#16819) 2024-09-25 18:41:07 +00:00
Keith Williams ac43d90d53 feat: Improve API v2 docs (#16818)
* feat: Improve API v2 docs

* Consistency

* Made the descriptions complete sentences with period

* Update apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts
2024-09-25 18:09:53 +00:00
Anik Dhabal Babu 2b24b4472d fix: OOO issue with setting the start and end dates to the same (#16810) 2024-09-25 13:44:42 +00:00
Peer Richelsen 715cbc1c38 chore: small changes to license page (#16812) 2024-09-25 15:14:38 +02:00
Alex van Andel d670976a69 chore: Release v4.5.5 (#16804)
release.yml / release (push) Canceled after 0s
2024-09-24 21:35:29 +00:00
Benny Joo 17ff01af75 fix: Revert "feat: Update UI of signup / login pages (#16710)" (#16801)
* Revert "feat: Update UI of signup / login pages (#16710)"

This reverts commit b879aeef09.

* revert
2024-09-24 20:07:37 +00:00
Alex van Andel 77af36e2af bugfix: Failed foreign key constraint check due to daily_video 0 (#16798) 2024-09-24 19:15:04 +00:00
Alex van Andel d52b2a23bd chore: Release v4.5.4 (#16789)
release.yml / release (push) Canceled after 0s
2024-09-24 19:11:30 +01:00
Steven TeyandPeer Richelsen 54e79d3c08 feat: Add backlink to booking page (#16778)
* FEAT: Add backlink to booking page

* restrict to cal.com only

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-09-24 17:46:49 +00:00
Alex van Andel 498b55efdd chore: Improve performance of query that removes disconnected Credentials (#16792) 2024-09-24 17:36:23 +00:00
Anik Dhabal Babu 1e9a4c58a3 fix: update pr workflow (#16730)
* test

* Update pr.yml

* update

* Update pr.yml

* test

* update

* Update pr.yml

* use latest vetsion to avoid warning

* production build type error

* update

* revert

* Update publish-report.yml
2024-09-24 14:14:25 -03:00
Anik Dhabal Babu 35a5ed5431 fix: All flaky e2e tests (#16758)
* test

* fix: flaky e2e tests (The Endgame)

* Update pr.yml

* Update

* revert

* revert

* add

* update

* fix

* fis another

* update

* another

* fix type error

* Update team-invitation.e2e.ts

* update a test

* another fix

* last update

* update
2024-09-24 14:13:26 -03:00
Benny Joo 4921cd7ecb add env vars (#16791) 2024-09-24 16:32:43 +00:00
Alex van Andel 04a0e4642e fix: Error on existing booking when credential is deleted (#16784) 2024-09-24 17:13:08 +01:00
Keith Williams f6ba77ccfe chore: Add sorting to API v2 OpenAPI doc (#16786) 2024-09-24 14:03:22 +00:00
MorganandKeith Williams 9a40838fa4 feat: ics-feed calendar api-v2 (#16735)
* feat: ics-feed calendar api-v2

* fixup! feat: ics-feed calendar api-v2

* chore: bump platform libraries

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-09-24 17:48:25 +05:30
Keith Williams f94494b91e chore: Add summaries for all API v2 endpoints (#16776)
* chore: Add summaries for all API v2 endpoints

* Updated to Get all calendars

* Fix build issues

* Removed unneeded Swagger UI options

* Fix issues with OpenAPI spec
2024-09-24 08:12:46 -03:00
2e2a6c73b1 feat: add advanced tab attributes to api/v2/event-types (#16314)
* added bookerLayouts

* added requiresConfirmation

* added tests

* undo extra changes

* undo extra change

* fixed imports

* added requiresBookerEmailVerification

* added hideCalendarNotes

* added lockTimeZoneToggleOnBookingPage

* added eventTypeColor

* undo extra changes

* added seats

* added requiresConfirmationWillBlockSlot to requiresConfirmation

* refactor: Implement BaseEventType class to avoid duplication across EventType input/output classes

## D-R-Y ##

* temp-fix: will revert later

* Correct placement of users array from EventTypeOutput to TeamEventTypeOutput

* Refactor: Shifted transformation logic to controllers using interceptors and pipes for a cleaner service layer.

* small fix

* Refactor: Moved transformation logic from service to controller using interceptors and pipes

** organisations/event-types

* feat: adde tests for `validateEventTypeInputs`

* fix: failing test

* fix: layout validator

* renamed blockCalendarForUnconfirmedBookings to blockUnconfirmedBookingsInBooker

* testing changes

* added disabled state

* fix merege conflict

* renamed requiresConfirmation to confirmationPolicy

* renamed OutputEventTypesResponseInterceptor to OutputTeamEventTypesResponseInterceptor

* renamed `darkThemeColor` -> `darkThemeHex` and `lightThemeColor` -> `lightThemeHex`

* renamed `eventTypeColor` -> `color`

* changed `requiresConfirmation` -> `confirmationPolicy`

* added tests for disabled state

* added class-validators to `disabled?: false`

* Revert: Split create and update input/output classes

* feat: Refactor responses to use DTOs with plainToClass for explicit property control

	•	Updated all endpoints to return response DTOs (e.g., CreateTeamEventTypeOutput, GetTeamEventTypeOutput) to ensure consistent Swagger documentation generation.
	•	Introduced plainToClass for transforming responses, enabling explicit control over which properties are returned in the response, following Morgan’s recommendation.
	•	Replaced generic HandlerResponse type with class-validator based DTOs for each endpoint, ensuring TypeScript enforces correct response structure.
	•	Applied strategy: "excludeAll" to limit response properties to only those explicitly defined in the DTO, preventing unintended fields from being included in the API responses.

* added pipes to transform output data

* add clean script to platform-types package.json

* added customName

* added destination calendar

* fixed type errors

* added useDestinationCalendarEmail

* refactor: api-reqest and api-response

* Update event-type.output.ts

* fixed errors

* Update yarn.lock

* added some missed properties to output

* Update constants.ts

* removed return type

* fixed some type errors

* reuse types

* Improve readability of validation functions

* Update CHANGELOG.md

* chore: post publish platform libraries

* Update documentation.json

* fix: reset platform libraries to 0.0.0

* fixup! fix: reset platform libraries to 0.0.0

* Update event-type.tranformed.ts

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-09-24 13:36:05 +03:00
Lauris Skraucis 6de454b071 fix: validate managed user timeFormat (#16783) 2024-09-24 13:32:54 +03:00
Calcom BotandReplexica 7936709644 feat: update translations (#16775)
Co-authored-by: Replexica <support@replexica.com>
2024-09-23 22:42:57 +00:00
Carina WollendorferandCarinaWolli d7383b2c80 add booking limits to SettingsLayoutAppDirClient (#16777)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-09-23 20:22:50 +00:00
9e980dc0ab feat: global booking limits for teams (#16614)
* add booking booking to team settings

* add update mutation

* add missing export

* fix dirty state

* first version of global team limits in getUserAvailability

* add test setup

* create seperate test file for global limits

* add tests for all units

* move limitManager and booking limit functions outside of getUserAvailability

* add migration

* clean up code

* move yearly booking count to booking repository

* code clean up

* don't count rescheduling booking

* add test for getSchedule

* fix type error

* fix type error

* fix type error

* fix from and end date for fetching bookings

* reuse functions

* allow null for bookingLimits

* remove bookings from managed event type

* fix type error

* code clean up

* small fixes form clean up

* fix type issue

* same fixes in teams/_post

* fix existing tz issue

* tests for fix

* adds missingn await

* imrove description

* remove spreading

* fix reschedule issue with booking limits

* fix reschedule error with booking durations

* remove useeffect

* undo commit

* add bookingLimits to UpdateOrgTeamDto

* fix unit tests

* Prepare view for app router migration

* throw error if not in ascending order

* fix disabled update button

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-09-23 14:21:40 -04:00
Benny Joo b879aeef09 feat: Update UI of signup / login pages (#16710)
* update UI of signup, login pages

* remove disabled state from google sign-in

* Put isGoogleLoginEnabled to its place + fix displaySignupForm default value

* Rename Signup flow test -> Email Signup flow test

* add data-testid to continue with email button

* completely decouble formMethods values from google sign in

* fix

* refactor

* add signup page test

* revert yarn.lock

* fix e2e test

* add saml in signup

* add saml in signin

* revert

* remove space
2024-09-24 02:27:57 +09:00
0eabe7f28b refactor: v2 bookings (#16200)
* chore: version existing bookings as 2024-04-15

* feat: initialize bookings version 2024-08-13

* feat: Create and reschedule booking inputs logic

* feat: create booking

* refactor: create booking response

* feat: reschedule booking

* chore: update language input

* feat: recurring booking

* refactor: add booking status in response

* refactor: recurring bookings

* feat: get booking by uid

* wip: get event types

* feat: fetch by multiple status filters and sort

* feat: fetch by teamId, teamIds, eventTypeId, eventTypeIds

* wip: filter by attendee email

* feat: filter by attendee email

* feat: filter by attendee name

* feat: date range filter

* chore: format get bookings output

* chore: finish main merge

* feat: handle instant bookings

* refactor: separate reschedule endpoint

* feat: cancel endpoint

* feat: mark absent host or attendees

* chore: dont expose metadata for now

* chore: add hostId to response

* fix: metadata

* feat: bill bookings

* feat: cancellationReason

* feat: rescheduling reason

* handle already busy booking error

* test: create new booking

* fix: handleNewRecurringBooking ignoring noEmail

* test: recurring bookings

* test: get individual bookings

* fix: cancel email sent if arePlatformEmailsEnabled=false but platformClientId is undefined

* tests: cancel, reschedule, mark absent

* fix: generateIcsFile null pointer exception

* cancel test

* error msg improve

* tests: team event type creation and teamId, teamIds filters

* test: cancel recurring booking

* refactor: make hosts and attendees an array

* sort by asc start

* simplify

* refactor: absent

* fix: make work with api key

* test

* ts remove any

* feat: BookingUidGuard

* fix: recurring booking no email

* fix: legacy bookings recurring noEmail

* add swagger

* retrigger build

* fix: atom booker work with v2

* docs: exclude old controller from docs

* refactor: make eventTypeIds and teamIds getBookings query params comma separated string

* docs: swagger for get bookings query

* swagger docs

* swagger docs

* docs: document authorization header

* refactor: remove unused attendee variable

* refactor: remove unused check

* refactor: remove unused attendee variable

* refactor: spelling

* use published platform libraries

* fix: ci

* fix: ci

* fix: ci

* fix: ci

* cleanup script platform types

* fix: use libraries from npm

* chore: set test env vapid keys

* fix: event type tests

* fix: remove location from system fields

* fix legacy event types

* Revert "fix legacy event types"

This reverts commit e64b473b73f7ef0fe88942cd87277d29a512b946.

* Revert "fix: remove location from system fields"

This reverts commit bee9a15cb27cd34705f34c427b6b50d51e3b7ee7.

* Revert "fix: event type tests"

This reverts commit fab1cb0f5eeb65e4f542bfbeb83849ceed7ba428.

* update libraries

* fix: increase node space for ci runner

* fix: increase node space for ci runner

* fix: increase node space for ci runner

* readd swagger

* ci

* ci

* refactor: increase idle worker memory jest e2e

* fixup! refactor: increase idle worker memory jest e2e

* fixup! fixup! refactor: increase idle worker memory jest e2e

* refactor: split bookings e2e into smaller e2e files

* fixup! refactor: split bookings e2e into smaller e2e files

* fixup! fixup! refactor: split bookings e2e into smaller e2e files

* fixup! fixup! fixup! refactor: split bookings e2e into smaller e2e files

* fixup! Merge branch 'main' into v2-refactor-bookings

* revert event types service

* fix: remove resetModule, maxWorker 2 jest e2e config

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-09-23 16:19:25 +03:00
Rajiv SahalandPeer Richelsen 3371602e0f feat: billing tab for platform (#16755)
* modules for platform plans and billing pages

* add platform billing and plans related pages

* update platform navigation to include billing tab

* custom hook to upgrade team subscription

* refactors

* export cta row component

* fixup

* only pass in subscription id instead of whole subscription

* refactor :teamId/subscribe endpoint logic, add endpoint for upgrading stripe and shift all webhooks logic into services

* refactor team subscription logic, add logic for upgrading stripe and  webhooks logic from billing controller

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-09-23 13:36:03 +03:00
Aditya PandeyandAmit Sharma f642a6ebeb fix: Issue with zoom meetings getting "invalid Meeting ID" (#16669)
* Update VideoApiAdapter.ts

* Update VideoApiAdapter.ts

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-09-23 07:44:59 +00:00
bc34e67191 refactor: event limits web wrapper (#16679)
* chore: EventTypeWebWrapper base pr

* fixup! chore: EventTypeWebWrapper base pr

* fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* wip

* fix: error

* Event type atom wrapper handles app dir

* fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* fixup! fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* remove console log

* review comments

* refactor: event limits tab for atoms

* fixup! review comments

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-09-23 06:51:23 +00:00
Syed Ali Shahbaz 2fb1408dd1 Update util.ts (#16753) 2024-09-21 12:05:35 +00:00
VijayandAmit Sharma 16ca4dd39c fix: update zoom integration doc to include user settings scope (#16724)
* update zoom integration doc to include user settings scope

* Update README.md

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>

* chore: split point 10

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-09-21 17:14:54 +05:30
Omar López 6dd8812a19 fix: Flakyness on pages that use a hard refresh (#16752)
* fix: Flakyness on pages that use a hard refres

Signed-off-by: zomars <zomars@me.com>

* Update Shell.tsx

---------

Signed-off-by: zomars <zomars@me.com>
2024-09-21 03:41:10 +00:00
Anik Dhabal BabuandOmar López 1a60afad7a chore: booking verification token and booking rejection logic from email (#16324)
Co-authored-by: Omar López <zomars@me.com>
2024-09-21 02:25:27 +00:00
SDeVuyst 76a2773688 fix: more general translation for 'seats 'in NL (#16722) 2024-09-21 02:12:15 +00:00
Omar López 8e76d7bd10 chore: fix formatting (#16750) 2024-09-21 02:10:12 +00:00
Lauris Skraucis 9749ba1cd0 refactor: make managed user's name required (#16678)
* refactor: make manageg user input name required

* refactor: return managed user name in output

* refactor: store provided user name

* test: check managed user name in input and output

* docs: update swagger
2024-09-20 21:00:04 +00:00
Omar LópezandAnik Dhabal Babu f52497f181 fix: flaky e2e patterns (#16696)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-09-20 22:47:21 +05:30
Keith Williams c1c4b1278b chore: Use no isolation for unit tests (#16745)
* chore: Use no isolation and pooling for unit tests

* Added the params to main command

* Added back the base checkout

* Remove pooling

* Run against an 8vCPU machine

* Added pool=threads back

* Setting on 4vCPU since 8 is not needed due to workflow dependencies
2024-09-20 16:06:02 +00:00
Alex van Andel 4a914c2dfb chore: Release v4.5.3 (#16728)
release.yml / release (push) Canceled after 0s
2024-09-20 16:27:25 +01:00
Joe Au-Yeung 62d579d03d Default to connected Salesforce account as owner (#16747) 2024-09-20 15:27:05 +00:00
Morgan 74bed31edc chore: improve api-v2 rate limiting (#16746) 2024-09-20 15:15:48 +00:00
Alex van Andel 97f213f260 revert: be7d45afe2 (#16742) 2024-09-20 15:01:49 +00:00
Carina WollendorferandCarinaWolli b84cdc0d45 fix: bookings ilmits errror (#16739)
* use booker timezone

* add test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-09-20 13:35:12 +00:00
Syed Ali Shahbaz 8cb5a7080b feat: Add EXPAND accepting "team" in API V1 Bookings (#16736)
* add expand

* add expand to /[id]/_get

* add test

* fix test import

* --

* --

* fix test

* fix test

* revert unintentional commit
2024-09-20 11:46:26 +00:00
Lauris Skraucis 11f0d48d0b feat: onBeforeUpdate AvailabilitySettings prop (#16737) 2024-09-20 11:42:10 +00:00
Syed Ali Shahbaz c4a1382982 chore: add granular logging to the logger (#16734)
* Update README.md

* Update README.md

* Update README.md

* Update README.md

* Update turbo.json

* Update trpc-provider.tsx

* Update logger.ts

* update prisma index

* Update trpc.ts

* Update trpc-provider.tsx

* Update trpc.ts

* Update .env.example

* Update logger.ts
2024-09-20 09:06:37 +00:00
Calcom BotandReplexica e0922c5ee0 feat: update translations (#16726)
release.yml / release (push) Canceled after 0s
Co-authored-by: Replexica <support@replexica.com>
2024-09-19 16:58:47 +00:00
Benny JooandJoe Au-Yeung f8dc7a115a chore: App Router - remove references to /pages for remaining pages in /auth, /insights, /d, signup and add missing default /future page (#16589)
* remove references to /pages for sso, setup, signin pages in /auth

* remove references to pages for insights

* remove references to pages for d

* remove references to pages for signup

* add page for /future index page

* fix routing-forms

* fix

* add missing defaults

* use getServerSessionForAppDir instead

* fix apps/[slug]/[...pages]

* fix metadata in apps/slug/pages

* refactor

* refactor

* remove duplicate code for PageProps

* remove references to pages for /reschedule

* fix

* fix routing forms

* type fix

* fix routing forms again

* revert changes for app/slug/pages

* revert

* revert changes in yarn lock

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-09-19 15:06:51 +00:00
sean-brydonandPeer Richelsen a60aa1bf6c add create license key to admin tab if IS_CALCOM (#16721)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-09-19 13:09:14 +00:00
Hariom Balhara bc203d7439 Release latest embed (#16713) 2024-09-19 11:02:17 +00:00
Udit Takkar d82847035f fix: type error on booking cancelled page (#16716) 2024-09-19 10:13:16 +00:00
Hariom Balhara 6d133c26f8 Narrow argument type to the specific event being listened to (#16714) 2024-09-19 09:42:32 +00:00
Benny Joo 3ae53a4b6e chore: App router - Fix errors in settings pages & add remaining pages (#16648)
* fix settings/admin/page

* remove references to pages for settings/teams

* remove double layouts

* move pages that do not need layout out of (settings) group

* fix

* fix

* fix

* fix

* add sso page and fix layouts
2024-09-19 09:59:32 +01:00
Lauris Skraucis 63339d8c01 fix: connect apple calendar atom nested <button>'s (#16715) 2024-09-19 08:49:21 +00:00
Lauris SkraucisandMorgan 44410c87f0 docs: v2 schedules and event-types swagger (#16608)
* docs: schedules

* docs: wip event-types

* docs:event-types

* docs:event-types

* Delete yarn.lock

* Revert "Delete yarn.lock"

This reverts commit bb3eb23508c0fa2e216ba7bcb97756215f889daa.

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-09-19 10:00:17 +03:00
Omar López c016241004 chore: removes unnecessary prisma env warnings (#16708)
* chore: removes unnecessary prisma env warnings

* Revert lockfile

Signed-off-by: zomars <zomars@me.com>

* Update yarn lock

Signed-off-by: zomars <zomars@me.com>

---------

Signed-off-by: zomars <zomars@me.com>
2024-09-19 07:32:23 +05:30
Benny Joo 019ef6de2a chore: App router migration - (/bookings, /booking) Move getServerSideProps/getStaticProps to /lib (#16458)
* booking: Move getServerSideProps to /lib

* remove duplicate

* bookings: Move getStaticProps to /lib

* fix type check

* replace setHeader with a server action call in withEmbedSSR

* Add env variables for booking
2024-09-18 19:09:57 -04:00
Omar López a2b064b269 feat: per user and team feature flags (#16040) 2024-09-18 21:27:47 +00:00
abb4fd307f refactor: event setup tab web wrapper (#16672)
* chore: EventTypeWebWrapper base pr

* fixup! chore: EventTypeWebWrapper base pr

* fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* wip

* fix: error

* Event type atom wrapper handles app dir

* fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* fixup! fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* remove console log

* refactor: event-type-atom (setup tab)

* review comments

* update: better naming

* fixup! review comments

* revert: defaultMenuIsOpen --will create a separate PR

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-09-18 20:14:23 +03:00
Peer Richelsen a9ebb0d160 feat: Retell AI (#16706)
* added retell ai to app store

* added retell ai
2024-09-18 17:08:01 +00:00
sean-brydon 1058c0e171 fix(eslint): Adds eslint-ignore-patterns to eslint config (#16701) 2024-09-18 15:17:52 +00:00
Anik Dhabal Babu 021e5f3833 fix: fixed more flaky e2e tests (round 2) (#16660)
* fix: fixed more flaky e2e tests (round 2)

* update

* fix and update

* update
2024-09-18 23:25:44 +09:00
Lauris Skraucis 5b5a42e72d refactor: v2 and atoms booking fields (#16685)
* refactor: define inputs and outputs by separating default and custom fields

* refactor: event type api <-> internal transformers

* refactor: v2 use refactored libraries to transform input and output booking fields

* refactor: dont allow custom slugs with default reserved slugs

* refactor: input service store only specific default system fields

* refactor: atoms display only specific default system fields

* refactor: delete unused system field constants

* fix: tests

* fix: old event types

* fix: imports

* fix: import

* chore: release libraries and use v2 with them

* fix: unit tests

* refactor: push email in case of missing phone

* refactor: satisfies check for event-type locations
2024-09-18 13:59:31 +02:00
MorganandSomay Chauhan 2b43d9a716 chore: EventTypeWebWrapper base pr (#16550)
* chore: EventTypeWebWrapper base pr

* fixup! chore: EventTypeWebWrapper base pr

* fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* wip

* fix: error

* Event type atom wrapper handles app dir

* fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* fixup! fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

* remove console log

* review comments

* fixup! review comments

* fixup! Merge branch 'main' into morgan/cal-4183-eventtypeatom-web-wrapper-base

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-09-18 12:26:04 +03:00
Omar López 3a5325f2a8 fix: new user detection for dub analytics (#16695) 2024-09-17 19:19:25 -07:00
Calcom BotandReplexica 5ad73607f0 feat: update translations (#16693)
Co-authored-by: Replexica <support@replexica.com>
2024-09-17 23:21:10 +00:00
Carina Wollendorfer 5067f0dd16 fix: dates in ooo booking redirect email (#16692) 2024-09-17 22:28:40 +00:00
Keith Williams a53e697baa chore: v4.5.1 (#16690)
release.yml / release (push) Canceled after 0s
2024-09-18 04:05:16 +09:00
sean-brydon e7415fe6e2 feat: update vitest version v0.34 to V2.11 (#16676)
* update version

* fix button to match new spec

* migrate tests to objectContaining

* fix tests

* update RTL

* yarn.lock update

* bump fetch mock to latest vitest version

* update deep mock to use vitest 2.0

* fix mock prisma type cast

* meet base test fn singature for V2

* remove console.log from next test
2024-09-17 19:09:55 +01:00
sean-brydonandJoe Au-Yeung 562309d24b fix: add optimistic UI for deleting member on org user table (#16662)
* add optimistic UI for deleting member

* Update packages/features/users/components/UserTable/DeleteMemberModal.tsx

* Remove previous Value

* Update packages/features/users/components/UserTable/DeleteMemberModal.tsx

* Update packages/features/users/components/UserTable/DeleteMemberModal.tsx

* fix onError

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-09-17 10:19:45 +00:00
Lauris Skraucis 3900c55204 fix: make connect calendar atoms client side (#16677)
* fix: make connect calendar atoms client side

* delete unrelated file
2024-09-17 10:16:12 +00:00
Morgan b3be9a93e0 chore: expose metadata on platform booker atom (#16673) 2024-09-17 10:12:30 +00:00
d27ccd6f44 feat: org team app install (#15704)
* feat: remove dropdown from app-store and redirect to new-app-install-flow

* removed extra code

* fix: account select loading issue

* removed getPaymentCredential (unused)

* fix: only redirect after the app has been added to all the event-types

* remove overflowIndicatorStyles

* refactor getUserAdminTeams

* send teamId instead of id

* seperate locations component

* for conferencing apps skip select account page

* refactor InstallAppButtonChild

* installing conferencing apps shows locations dropdown in configure step

* send location data to the handler

* send location data to the handler
* add the newly installed to the locations dropdown (prefillLocation)

* fix: type errors

* fix: handle es-lint errors

* fix: app is added again on submit

* only add app if not already added

* fix: type erros

* filter out managed events for now

* fix: show installed count badge

* remove 2 toast message

* feat: added tests for conferencing apps

* fix: loading indicator

* Update apps/web/pages/apps/installation/[[...step]].tsx

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>

* Update apps/web/pages/apps/installation/[[...step]].tsx

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>

* move `getUserAdminTeams` to `UserRepository`

* fix: zoom and jelly not redirecting to the new app install flow

* fix: typo

* add `installableOnTeams` prop

* split `configureStepCard` into smaller components

* fix: step count

* fix: show loading indicator until the new page is loaded

* fix: organizer_default_conferencing_app not visible in a team

* Introduce QueryClient to app card tests

* fix: typo

* add installableOnTeams prop

* fix: tests

* seated events shouldn't have multiple locations #15174

* fix: o365 callback

* fix: office365_video not redirecting to event-types step

* Revert "fix: o365 callback"

This reverts commit bba841035ea34f8c31201b64c77221b8d8c3e626.

* add TEAM_SELECT_STEP

* add apps to orgs and their sub-teams initial commit

* undo team select step

* small ui fix

* fix: wrong step numbers

* fix: dont allow app installation without cretendialId

* fix: don't show acme team as it cannot have any events

* refactor useAddAppMutation

* removed console.log

* added comment

* added comment

* move locationOptions from getServerSideProps a trpc query

* fix: failing tests

* Update conferencingApps.e2e.ts

* refactor useAddAppMutation

* refactor useAddAppMutation 2

* fix: test failing

* fix: unit test

* Revert "fix: unit test"

This reverts commit 6d74032211d094478c6d7cf9aedbce696dfb768d.

* fix: failing test

* Increase test timeout for conferencing app tests

* fix: write separate tests for each conferencing app to prevent hitting 6000 ms timeout

* improved tests naming

* fix: correct message and translation key #15657

* fix: write separate tests for each analytics app to prevent hitting 6000 ms timeout

* fix: analytics apps test

* attempt to fix failing tests

* fix typo

* refactor

* update: replace text-gray with text-stuble

- works with light mode too

* update: use userRepository.getUserAdminTeams

* Merge branch 'main' into feat/org-team-app-install-2

* fix: after merge conflict from app router migration

* remove consoles

---------

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-09-17 04:40:56 +00:00
Benny Joo f86a5db843 chore: App Router Migration, add metadata to getting-started, extract into /modules and add method to UserRepository (#16449)
* getting-started: extract into /modules

* fix

* fix type-check

* add findByIdWithOptionalSelect to UserRepository

* fix import error

* fix type check

* add metadata

* fix
2024-09-17 02:42:19 +00:00
Steven Tey 4cbf9ec9ab Update dclid → dub_id (#16670) 2024-09-16 22:53:05 +00:00
Benny Joo aa3c91ebd6 chore: App router - add missing metadata for /[user] pages (#16516) 2024-09-16 10:42:00 -04:00
Rajiv Sahal 071c899ea6 fix: overlay calendar view for Booker atom (#16642)
* overlay calendar needs to be false when in month_view

* fixup
2024-09-16 14:39:18 +00:00
sean-brydon 32b2e83564 fix: fix org check on team slider (#16659) 2024-09-16 14:25:10 +00:00
Curious GeorgeandAnik Dhabal Babu 7ff6d6559a Update callback.ts to work with AU location (#16664)
as per https://accounts.zoho.com/oauth/serverinfo, the server location for au is .com.au instead of .au

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-09-16 12:49:17 +00:00
2572a5cb93 fixes multiselect required check (#16653)
Co-authored-by: Ashutosh Sangwan <ashutoshsangwan@Ashutoshs-MacBook-Pro.local>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-09-16 10:31:33 +00:00
Syed Ali Shahbaz 45e0f87e45 fix: add logger to util to debug v1 API slot (#16657)
* Update util.ts

* fix init

* add logger to repository_user
2024-09-16 08:29:58 +00:00
NETRAKAMAL BARUA 6e875fccbe fix: verifyCodeDialog ui (#16655) 2024-09-16 03:32:31 +00:00
Benny Joo 689e17bcf6 fix (#16632) 2024-09-15 20:23:37 -03:00
Keith Williams 584f9c6e93 chore: v4.5.0 (#16650)
release.yml / release (push) Canceled after 0s
2024-09-15 22:30:20 +09:00
Benny Joo 70348c6f64 fix: Add new api-key CTA button is missing in settings/developer/api-keys (#16649)
* Add new api-key button is missing

* fix type check
2024-09-15 22:09:03 +09:00
747591fded refactor: remove networkidle waiters (#16483)
* refactor: remove networkidle waiters

* Update out-of-office.e2e.ts

* Update auth-index.e2e.ts

* WIP

* Update booking-limits.e2e.ts

* fix-conflicts

* fix failed e2e tests

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-09-15 12:25:48 +00:00
Anik Dhabal Babu 9bfd933699 fix: managed eventype is not deleted to the assigned user when team is deleted (#16621) 2024-09-13 23:40:25 +01:00
Rajiv SahalandAnik Dhabal Babu a775e42e85 fix: prevent creating multiple platform orgs (#16629)
* set error messages to know whats wrong

* trying to find if org with same owner already exists

* ensure a user cannot create more than one platform team

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-09-13 23:37:48 +01:00
Peer Richelsen e3e2bc804a chore: added IS_EUROPE for signup (#16639) 2024-09-13 22:17:24 +00:00
sean-brydon b84099aec0 fix: use correct redirect url for google oauth when using overlay calendar (#16623) 2024-09-13 18:14:33 +00:00
Morgan 073d34f893 chore: rewrite imports of prisma enums to @calcom/prisma/enums (#16633) 2024-09-13 17:45:59 +00:00
Rajiv SahalandPeer Richelsen d72900d164 hide platform tab from navigation when team not setup (#16630)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-09-13 17:17:44 +00:00
Syed Ali ShahbazandKeith Williams e3b478f5b1 fix: get slot logic for v1 API (#16620)
* fix faulty slug match

* Allow team event slot

* pass it to getSchedule

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-09-13 16:34:03 +00:00
a5191d1e5a chore: App-router-migration(/auth/verify) add missing metadata / page & extract components into /module (#16444)
* create StripeRepository and simplify code

* verify-email-change: extract getServerSideProps

* add verify-email-change to app router

* auth/verify: Add metadata in app router

* auth/verify: extract client component into /modules

* fix type checks for stripe

* fix stripe session handler

* improve getServerSideProps for verify-email-change

* add title to verify-email-change page metadata

* add title to verify page metadata

* refactor

* add safety

* fix

* remove references to pages for verify-email

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-09-13 16:13:33 +00:00
Benny Joo ea21cd5826 fix: make admin role unnecessary for future-opt-in API (#16628) 2024-09-13 16:01:30 +00:00
sean-brydon e78c8738fb feat: create loading state for copy referral link from dub.co (#16626)
* create loading state for copy referal

* Update packages/features/shell/Shell.tsx
2024-09-14 00:03:05 +09:00
Calcom BotandReplexica 47314bae83 feat: update translations (#16616)
Co-authored-by: Replexica <support@replexica.com>
2024-09-13 14:37:39 +00:00
fd8afbc418 chore: Add Prisma extension to block deleteMany/updateMany with undefined 'in' (#15721)
* chore: Add Prisma extension to block updateMany with undefined 'in'

* Fix type

* types

* Added check for deleteMany as well

* Improved in check

* type

* Updated check to only check undefined and not isArray

* Renamed the guard

* another rename

* Changed undefined check to use typeof

* Types

* Added check

* Update packages/prisma/extensions/disallow-undefined-in-delete-update-many.ts

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* Added unit tests

* Added a good case

* Rename

* Rename fix

* Types

* Temporarily disabling the extension for testing

* Added back the extension

* Trying to also log to see if there are swallowed errors

* Update

* removed unused params

* removed console.log

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-09-13 22:52:06 +09:00
6e7012757f feat: replace team list view with org list view UI and fix team view user availability (#15344)
* feat: replace team list view with org list view UI and fix team view user availability

* update

* update2

* some changes

* Remove old team availability modal - fix flex direction

* fix

* fix type error

* update

* update

* fix type error

* update

* upfate edit sheet

* final update

* fix flaky e2e

* fix e2e

* typo

* profile update flow

* fix type error and refactor

* small update

* NIT

* update

* chore: fix types

* update

---------

Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-09-13 19:06:40 +05:30
sean-brydon 933eb0d057 remove teams layout (#16624) 2024-09-13 12:39:42 +00:00
Omar López fcb79888f3 fix: async clipboard support in Safari (#16619) 2024-09-13 09:27:28 +01:00
111e2f30c6 feat: overlay calendar for Booker atom (#16551)
* add prop to see if overlay calendar is enabled

* fix merge conflicts

* overlay calendar enabled prop

* cleanup

* hide toggle group if platform

* remove isPlatform conditional for overlay calendar

* dont display overlay calendar continue modal for platform

* fix images not appearing for platform

* update booker store

* update booker

* update booker

* shifting variables from booker store to useOverlayCalendar store

* update booker platform wrapper

* update booking page view

* revert changes to overlay calendar store

* update typings

* update toggle set value

* update handler to toggle connected calendar

* fixup

* update booker layout

* fixup! Merge branch 'main' into overlay-calendar-for-booker-atom

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-09-13 08:13:20 +00:00
Steven Teyandzomars f2b3e620b4 fix: Show toast after copy referral link + change icon to "gift" (#16618)
Co-authored-by: zomars <zomars@me.com>
2024-09-12 22:06:21 +00:00
Benny Joo d9133cb395 chore: App router migration - Create/Use EventRepository and Extract /team/**/* and /teams/* into /modules from /pages (#16453) 2024-09-12 18:58:08 -03:00
1535 changed files with 75411 additions and 23802 deletions
+9 -1
View File
@@ -168,13 +168,13 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
STRIPE_TEAM_MONTHLY_PRICE_ID=
STRIPE_TEAM_PRODUCT_ID=
STRIPE_ORG_MONTHLY_PRICE_ID=
STRIPE_WEBHOOK_SECRET=
STRIPE_WEBHOOK_SECRET_APPS=
STRIPE_PRIVATE_KEY=
STRIPE_CLIENT_ID=
# Use for internal Public API Keys and optional
API_KEY_PREFIX=cal_
# ***********************************************************************************************************
@@ -333,7 +333,9 @@ E2E_TEST_OIDC_USER_PASSWORD=
AB_TEST_BUCKET_PROBABILITY=50
# whether we redirect to the future/event-types from event-types or not
APP_ROUTER_EVENT_TYPES_ENABLED=0
APP_ROUTER_SETTINGS_DEVELOPER_ENABLED=0
APP_ROUTER_SETTINGS_ADMIN_ENABLED=0
APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED=0
APP_ROUTER_SETTINGS_PLATFORM_ENABLED=0
APP_ROUTER_SETTINGS_ORG_ENABLED=0
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
@@ -343,12 +345,14 @@ APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
APP_ROUTER_APPS_CATEGORIES_ENABLED=0
# whether we redirect to the future/apps/categories/[category] from /apps/categories/[category] or not
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=0
APP_ROUTER_BOOKING_ENABLED=0
APP_ROUTER_BOOKINGS_STATUS_ENABLED=0
APP_ROUTER_WORKFLOWS_ENABLED=0
APP_ROUTER_SETTINGS_TEAMS_ENABLED=0
APP_ROUTER_GETTING_STARTED_STEP_ENABLED=0
APP_ROUTER_APPS_ENABLED=0
APP_ROUTER_VIDEO_ENABLED=0
APP_ROUTER_TEAM_ENABLED=0
APP_ROUTER_TEAMS_ENABLED=0
APP_ROUTER_AVAILABILITY_ENABLED=0
APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED=0
@@ -392,3 +396,7 @@ VAPID_PRIVATE_KEY=
# Custom privacy policy / terms URLs (for self-hosters: change to your privacy policy / terms URLs)
NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL=
NEXT_PUBLIC_WEBSITE_TERMS_URL=
# NEXT_PUBLIC_LOGGER_LEVEL=3 sets to log info, warn, error and fatal logs.
# [0: silly & upwards, 1: trace & upwards, 2: debug & upwards, 3: info & upwards, 4: warn & upwards, 5: error & fatal, 6: fatal]
NEXT_PUBLIC_LOGGER_LEVEL=
@@ -49,7 +49,9 @@ assignees: ""
(Share it here.)
---
##### House rules
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves feature request by removing this label, then you can start coding.
- For clarity: Non-core member issues automatically get the `🚨 needs approval` label.
- Your feature ideas are invaluable to us! However, they undergo review to ensure alignment with the product's direction.
+1 -1
View File
@@ -12,7 +12,7 @@
## Mandatory Tasks (DO NOT REMOVE)
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com/docs). If N/A, write N/A here and check the checkbox.
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.
## How should this be tested?
+41
View File
@@ -0,0 +1,41 @@
# This is just to test this file
name: Build
on:
workflow_call:
jobs:
build:
name: Build Docs
permissions:
contents: read
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- name: Cache Docs build
uses: buildjet/cache@v4
id: cache-docs-build
env:
cache-name: docs-build
key-1: ${{ hashFiles('yarn.lock') }}
key-2: ${{ hashFiles('docs/**.*', '!**/node_modules') }}
key-3: ${{ github.event.pull_request.number || github.ref }}
key-4: ${{ github.sha }}
with:
path: |
**/docs/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
- name: Run build
working-directory: docs
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
if [ ${{ steps.cache-docs-build.outputs.cache-hit }} == 'true' ]; then
echo "Cache hit for Docs build. Skipping build."
else
npm install -g mintlify
mintlify dev &
sleep 5 # Let it run for 5 seconds
kill $!
fi
shell: bash
+1 -1
View File
@@ -15,7 +15,7 @@ env:
IS_E2E: true
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NODE_OPTIONS: --max-old-space-size=4096
NODE_OPTIONS: --max-old-space-size=29000
REDIS_URL: "redis://localhost:6379"
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
+10 -1
View File
@@ -33,7 +33,7 @@ jobs:
with:
filters: |
has-files-requiring-all-checks:
- "!(**.md|.github/CODEOWNERS)"
- "!(**.md|.github/CODEOWNERS|docs/**|apps/web/public/static/locales/**/common.json)"
- name: Get Latest Commit SHA
id: get_sha
run: |
@@ -139,6 +139,13 @@ jobs:
uses: ./.github/workflows/atoms-production-build.yml
secrets: inherit
build-docs:
name: Production builds
needs: [changes, check-label, deps]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/docs-build.yml
secrets: inherit
build:
name: Production builds
needs: [changes, check-label, deps]
@@ -224,6 +231,8 @@ jobs:
build,
build-api-v1,
build-api-v2,
build-atoms,
build-docs,
e2e,
e2e-api-v2,
e2e-embed,
@@ -39,7 +39,7 @@ env:
jobs:
build:
name: Web App
name: Build Web App
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
steps:
+3 -3
View File
@@ -7,14 +7,14 @@ jobs:
test:
name: Unit
timeout-minutes: 20
runs-on: buildjet-2vcpu-ubuntu-2204
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- run: yarn test
- run: yarn test -- --no-isolate
# We could add different timezones here that we need to run our tests in
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly --no-isolate
- name: Run API v2 tests
working-directory: apps/api/v2
run: |
File diff suppressed because one or more lines are too long
+12 -9
View File
@@ -177,24 +177,25 @@ Do not commit your `yarn.lock` unless you've made changes to the `package.json`.
If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`:
```bash
git checkout HEAD~1 yarn.lock
git commit -m "Revert yarn.lock changes"
```
```bash
git checkout HEAD~1 yarn.lock
git commit -m "Revert yarn.lock changes"
```
_NB_: You may have to bypass the pre-commit hook with by appending `--no-verify` to the git commit
If you've pushed the commit with the `yarn.lock`:
1. Correct the commit locally using the above method.
2. Carefully force push:
1. Correct the commit locally using the above method.
2. Carefully force push:
```bash
git push origin <your-branch-name> --force
```
```bash
git push origin <your-branch-name> --force
```
If `yarn.lock` was committed a while ago and there have been several commits since, you can use the following steps to revert just the `yarn.lock` changes without impacting the subsequent changes:
1. **Checkout a Previous Version**:
- Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log:
```bash
@@ -208,6 +209,7 @@ If `yarn.lock` was committed a while ago and there have been several commits sin
```
2. **Commit the Reverted Version**:
- After checking out the previous version of the `yarn.lock`, commit this change:
```bash
@@ -215,6 +217,7 @@ If `yarn.lock` was committed a while ago and there have been several commits sin
```
3. **Proceed with Caution**:
- If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes:
```bash
+50 -31
View File
@@ -173,12 +173,30 @@ yarn dx
#### Development tip
> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `.env` to get logging information for all the queries and mutations driven by **tRPC**.
Add `NEXT_PUBLIC_LOGGER_LEVEL={level}` to your .env file to control the logging verbosity for all tRPC queries and mutations.\
Where {level} can be one of the following:
`0` for silly \
`1` for trace \
`2` for debug \
`3` for info \
`4` for warn \
`5` for error \
`6` for fatal
When you set `NEXT_PUBLIC_LOGGER_LEVEL={level}` in your .env file, it enables logging at that level and higher. Here's how it works:
The logger will include all logs that are at the specified level or higher. For example: \
- If you set `NEXT_PUBLIC_LOGGER_LEVEL=2`, it will log from level 2 (debug) upwards, meaning levels 2 (debug), 3 (info), 4 (warn), 5 (error), and (fatal) will be logged. \
- If you set `NEXT_PUBLIC_LOGGER_LEVEL=3`, it will log from level 3 (info) upwards, meaning levels 3 (info), 4 (warn), 5 (error), and 6 (fatal) will be logged, but level 2 (debug) and level 1 (trace) will be ignored. \
```sh
echo 'NEXT_PUBLIC_DEBUG=1' >> .env
echo 'NEXT_PUBLIC_LOGGER_LEVEL=3' >> .env
```
for Logger level to be set at info, for example.
#### Gitpod Setup
1. Click the button below to open this project in Gitpod.
@@ -399,25 +417,25 @@ Cal.com, Inc. is a commercial open source company, which means some parts of thi
> [!NOTE]
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
| | AGPLv3 | EE |
|---|---|---|
| Self-host for commercial purposes | ✅ | ✅ |
| Clone privately | ✅ | ✅ |
| Fork publicly | ✅ | ✅ |
| Requires CLA | ✅ | ✅ |
| Official Support| ❌ | ✅ |
| Derivative work privately | ❌ | ✅ |
| SSO | ❌ | ✅ |
| Admin Panel | ❌ | ✅ |
| Impersonation | ❌ | ✅ |
| Managed Event Types | ❌ | ✅ |
| Organizations | ❌ | ✅ |
| Payments | ❌ | ✅ |
| Platform | ❌ | ✅ |
| Teams | ❌ | ✅ |
| Users | ❌ | ✅ |
| Video | ❌ | ✅ |
| Workflows | ❌ | ✅ |
| | AGPLv3 | EE |
| --------------------------------- | ------ | --- |
| Self-host for commercial purposes | ✅ | ✅ |
| Clone privately | ✅ | ✅ |
| Fork publicly | ✅ | ✅ |
| Requires CLA | ✅ | ✅ |
|  Official Support | ❌  | ✅ |
| Derivative work privately | ❌ | ✅ |
|  SSO | ❌ | ✅ |
| Admin Panel | ❌ | ✅ |
| Impersonation | ❌ | ✅ |
| Managed Event Types | ❌ | ✅ |
| Organizations | ❌ | ✅ |
| Payments | ❌ | ✅ |
| Platform | ❌ | ✅ |
| Teams | ❌ | ✅ |
| Users | ❌ | ✅ |
| Video | ❌ | ✅ |
| Workflows | ❌ | ✅ |
> [!TIP]
> We work closely with the community and always invite feedback about what should be open and what is fine to be commercial. This list is not set and stone and we have moved things from commercial to open in the past. Please open a [discussion](https://github.com/calcom/cal.com/discussions) if you feel like something is wrong.
@@ -513,17 +531,18 @@ following
1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.
2. On the upper right, click "Develop" => "Build App".
3. On "OAuth", select "Create".
3. Select "General App" , click "Create".
4. Name your App.
5. Choose "User-managed app" as the app type.
6. De-select the option to publish the app on the Zoom App Marketplace.
7. Click "Create".
8. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
9. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
10. Also add the redirect URL given above as an allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
11. You don't need to provide basic information about your app. Instead click on "Scopes" and then on "+ Add Scopes". On the left, click the category "Meeting" and check the scope `meeting:write`.
12. Click "Done".
13. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
5. Choose "User-managed app" for "Select how the app is managed".
6. De-select the option to publish the app on the Zoom App Marketplace, if asked.
7. Now copy the Client ID and Client Secret to your `.env` file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
8. Set the "OAuth Redirect URL" under "OAuth Information" as `<Cal.com URL>/api/integrations/zoomvideo/callback` replacing Cal.com URL with the URI at which your application runs.
9. Also add the redirect URL given above as an allow list URL and enable "Subdomain check". Make sure, it says "saved" below the form.
10. You don't need to provide basic information about your app. Instead click on "Scopes" and then on "+ Add Scopes". On the left,
1. click the category "Meeting" and check the scope `meeting:write:meeting`.
2. click the category "User" and check the scope `user:read:settings`.
11. Click "Done".
12. You're good to go. Now you can easily add your Zoom integration in the Cal.com settings.
### Obtaining Daily API Credentials
+3 -2
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { emailSchema } from "@calcom/lib/emailSchema";
import { _AttendeeModel as Attendee } from "@calcom/prisma/zod";
import { timeZone } from "~/lib/validations/shared/timeZone";
@@ -14,7 +15,7 @@ export const schemaAttendeeBaseBodyParams = Attendee.pick({
const schemaAttendeeCreateParams = z
.object({
bookingId: z.number().int(),
email: z.string().email(),
email: emailSchema,
name: z.string(),
timeZone: timeZone,
})
@@ -23,7 +24,7 @@ const schemaAttendeeCreateParams = z
const schemaAttendeeEditParams = z
.object({
name: z.string().optional(),
email: z.string().email().optional(),
email: emailSchema.optional(),
timeZone: timeZone.optional(),
})
.strict();
+26 -1
View File
@@ -1,6 +1,13 @@
import { z } from "zod";
import { _AttendeeModel, _BookingModel as Booking, _PaymentModel, _UserModel } from "@calcom/prisma/zod";
import {
_AttendeeModel,
_BookingModel as Booking,
_EventTypeModel,
_PaymentModel,
_TeamModel,
_UserModel,
} from "@calcom/prisma/zod";
import { extendedBookingCreateBody, iso8601 } from "@calcom/prisma/zod-utils";
import { schemaQueryUserId } from "./shared/queryUserId";
@@ -46,10 +53,27 @@ export const schemaBookingEditBodyParams = schemaBookingBaseBodyParams
.merge(schemaBookingEditParams)
.omit({ uid: true });
const teamSchema = _TeamModel.pick({
name: true,
slug: true,
});
export const schemaBookingReadPublic = Booking.extend({
eventType: _EventTypeModel
.pick({
title: true,
slug: true,
})
.merge(
z.object({
team: teamSchema.nullish(),
})
)
.nullish(),
attendees: z
.array(
_AttendeeModel.pick({
id: true,
email: true,
name: true,
timeZone: true,
@@ -87,6 +111,7 @@ export const schemaBookingReadPublic = Booking.extend({
timeZone: true,
attendees: true,
user: true,
eventType: true,
payment: true,
metadata: true,
status: true,
@@ -1,16 +1,18 @@
import { withValidation } from "next-validations";
import { z } from "zod";
import { emailSchema } from "@calcom/lib/emailSchema";
import { baseApiParams } from "./baseApiParams";
// Extracted out as utility function so can be reused
// at different endpoints that require this validation.
export const schemaQueryAttendeeEmail = baseApiParams.extend({
attendeeEmail: z.string().email(),
attendeeEmail: emailSchema,
});
export const schemaQuerySingleOrMultipleAttendeeEmails = z.object({
attendeeEmail: z.union([z.string().email(), z.array(z.string().email())]).optional(),
attendeeEmail: z.union([emailSchema, z.array(emailSchema)]).optional(),
});
export const withValidQueryAttendeeEmail = withValidation({
@@ -0,0 +1,12 @@
import { z } from "zod";
const expandEnum = z.enum(["team"]);
export const schemaQuerySingleOrMultipleExpand = z
.union([
expandEnum, // Allow a single value from the enum
z.array(expandEnum).refine((arr) => new Set(arr).size === arr.length, {
message: "Array values must be unique",
}), // Allow an array of enum values, with uniqueness constraint
])
.optional();
@@ -1,16 +1,18 @@
import { withValidation } from "next-validations";
import { z } from "zod";
import { emailSchema } from "@calcom/lib/emailSchema";
import { baseApiParams } from "./baseApiParams";
// Extracted out as utility function so can be reused
// at different endpoints that require this validation.
export const schemaQueryUserEmail = baseApiParams.extend({
email: z.string().email(),
email: emailSchema,
});
export const schemaQuerySingleOrMultipleUserEmails = z.object({
email: z.union([z.string().email(), z.array(z.string().email())]),
email: z.union([emailSchema, z.array(emailSchema)]),
});
export const withValidQueryUserEmail = withValidation({
+2
View File
@@ -10,6 +10,8 @@ export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true })
isPlatform: true,
smsLockState: true,
smsLockReviewedByAdmin: true,
bookingLimits: true,
includeManagedEventsInLimits: true,
});
const schemaTeamRequiredParams = z.object({
+3 -2
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { emailSchema } from "@calcom/lib/emailSchema";
import { checkUsername } from "@calcom/lib/server/checkUsername";
import { _UserModel as User } from "@calcom/prisma/zod";
import { iso8601 } from "@calcom/prisma/zod-utils";
@@ -92,7 +93,7 @@ export const schemaUserBaseBodyParams = User.pick({
// Here we can both require or not (adding optional or nullish) and also rewrite validations for any value
// for example making weekStart only accept weekdays as input
const schemaUserEditParams = z.object({
email: z.string().email().toLowerCase(),
email: emailSchema.toLowerCase(),
username: usernameSchema,
weekStart: z.nativeEnum(weekdays).optional(),
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
@@ -115,7 +116,7 @@ const schemaUserEditParams = z.object({
// merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end.
const schemaUserCreateParams = z.object({
email: z.string().email().toLowerCase(),
email: emailSchema.toLowerCase(),
username: usernameSchema,
weekStart: z.nativeEnum(weekdays).optional(),
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
+1 -1
View File
@@ -33,7 +33,7 @@
"@sentry/nextjs": "^8.8.0",
"bcryptjs": "^2.4.3",
"memory-cache": "^0.2.0",
"next": "^13.5.4",
"next": "^13.5.6",
"next-api-middleware": "^1.0.1",
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
@@ -2,7 +2,7 @@ import type { NextApiRequest } from "next";
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
import { defaultResponder } from "@calcom/lib/server";
import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
import { bookingCancelSchema } from "@calcom/prisma/zod-utils";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
@@ -65,7 +65,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
*/
async function handler(req: NextApiRequest) {
const { id, allRemainingBookings, cancellationReason } = schemaQueryIdParseInt
.merge(schemaBookingCancelParams.pick({ allRemainingBookings: true, cancellationReason: true }))
.merge(bookingCancelSchema.pick({ allRemainingBookings: true, cancellationReason: true }))
.parse({
...req.query,
allRemainingBookings: req.query.allRemainingBookings === "true",
+14 -1
View File
@@ -4,6 +4,7 @@ import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { schemaBookingReadPublic } from "~/lib/validations/booking";
import { schemaQuerySingleOrMultipleExpand } from "~/lib/validations/shared/queryExpandRelations";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
/**
@@ -89,9 +90,21 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
export async function getHandler(req: NextApiRequest) {
const { query } = req;
const { id } = schemaQueryIdParseInt.parse(query);
const queryFilterForExpand = schemaQuerySingleOrMultipleExpand.parse(req.query.expand);
const expand = Array.isArray(queryFilterForExpand)
? queryFilterForExpand
: queryFilterForExpand
? [queryFilterForExpand]
: [];
const booking = await prisma.booking.findUnique({
where: { id },
include: { attendees: true, user: true, payment: true },
include: {
attendees: true,
user: true,
payment: true,
eventType: expand.includes("team") ? { include: { team: true } } : false,
},
});
return { booking: schemaBookingReadPublic.parse(booking) };
}
@@ -41,8 +41,6 @@ import { getTranscriptFromRecordingId } from "~/lib/validations/shared/queryIdTr
* responses:
* 200:
* description: OK
* content:
* application/json:
* 401:
* description: Authorization information is missing or invalid.
* 404:
@@ -32,8 +32,6 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
* responses:
* 200:
* description: OK
* content:
* application/json:
* 401:
* description: Authorization information is missing or invalid.
* 404:
+9
View File
@@ -12,6 +12,7 @@ import {
} from "~/lib/utils/retrieveScopedAccessibleUsers";
import { schemaBookingGetParams, schemaBookingReadPublic } from "~/lib/validations/booking";
import { schemaQuerySingleOrMultipleAttendeeEmails } from "~/lib/validations/shared/queryAttendeeEmail";
import { schemaQuerySingleOrMultipleExpand } from "~/lib/validations/shared/queryExpandRelations";
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
/**
@@ -216,10 +217,18 @@ export async function handler(req: NextApiRequest) {
args.take = take;
args.skip = skip;
}
const queryFilterForExpand = schemaQuerySingleOrMultipleExpand.parse(req.query.expand);
const expand = Array.isArray(queryFilterForExpand)
? queryFilterForExpand
: queryFilterForExpand
? [queryFilterForExpand]
: [];
args.include = {
attendees: true,
user: true,
payment: true,
eventType: expand.includes("team") ? { include: { team: true } } : false,
};
const queryFilterForAttendeeEmails = schemaQuerySingleOrMultipleAttendeeEmails.parse(req.query);
+3 -2
View File
@@ -27,12 +27,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
isColdStart = false;
}
const { usernameList, ...rest } = req.query;
const { usernameList, isTeamEvent, ...rest } = req.query;
const parsedIsTeamEvent = String(isTeamEvent).toLowerCase() === "true";
let slugs = usernameList;
if (!Array.isArray(usernameList)) {
slugs = usernameList ? [usernameList] : undefined;
}
const input = getScheduleSchema.parse({ usernameList: slugs, ...rest });
const input = getScheduleSchema.parse({ usernameList: slugs, isTeamEvent: parsedIsTeamEvent, ...rest });
const timeZoneSupported = input.timeZone ? isSupportedTimeZone(input.timeZone) : false;
const availableSlots = await getAvailableSlots({ ctx: await createContext({ req, res }), input });
const slotsInProvidedTimeZone = timeZoneSupported
@@ -123,9 +123,11 @@ export async function patchHandler(req: NextApiRequest) {
// TODO: Perhaps there is a better fix for this?
const cloneData: typeof data & {
metadata: NonNullable<typeof data.metadata> | undefined;
bookingLimits: NonNullable<typeof data.bookingLimits> | undefined;
} = {
...data,
smsLockReviewedByAdmin: false,
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits,
metadata: data.metadata === null ? {} : data.metadata || undefined,
};
const team = await prisma.team.update({ where: { id: teamId }, data: cloneData });
+2
View File
@@ -123,9 +123,11 @@ async function postHandler(req: NextApiRequest) {
// TODO: Perhaps there is a better fix for this?
const cloneData: typeof data & {
metadata: NonNullable<typeof data.metadata> | undefined;
bookingLimits: NonNullable<typeof data.bookingLimits> | undefined;
} = {
...data,
smsLockReviewedByAdmin: false,
bookingLimits: data.bookingLimits === null ? {} : data.bookingLimits || undefined,
metadata: data.metadata === null ? {} : data.metadata || undefined,
};
@@ -164,7 +164,6 @@ describe("GET /api/bookings", async () => {
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
console.log(booking);
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(new Date().getTime());
});
});
@@ -188,4 +187,27 @@ describe("GET /api/bookings", async () => {
});
});
});
describe("Expand feature to add relational data in return payload", () => {
it("Returns only team data when expand=team is set", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
expand: "team",
},
pagination: DefaultPagination,
});
req.userId = adminUser.id;
req.isOrganizationOwnerOrAdmin = true;
const responseData = await handler(req);
console.log("bookings=>", responseData.bookings);
responseData.bookings.forEach((booking) => {
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
if (booking.id === 19) expect(booking.eventType?.team).toBe(null);
});
});
});
});
+2 -1
View File
@@ -26,4 +26,5 @@ WEB_APP_URL=http://localhost:3000/
CALCOM_LICENSE_KEY=
API_KEY_PREFIX=cal_
GET_LICENSE_KEY_URL="https://console.cal.com/api/license"
IS_E2E=false
IS_E2E=false
DOCS_URL=
+2 -2
View File
@@ -44,8 +44,8 @@ Copy `.env.example` to `.env` and fill values.
## Add license Key to deployments table in DB
id, logo theme licenseKey agreedLicenseAt
1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611'
id, logo theme licenseKey agreedLicenseAt
1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611'
your CALCOM_LICENSE_KEY env var need to contain the same value
+3 -1
View File
@@ -11,5 +11,7 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts"],
"reporters": ["default", "jest-summarizing-reporter"]
"reporters": ["default", "jest-summarizing-reporter"],
"workerIdleMemoryLimit": "512MB",
"maxWorkers": 2
}
+5 -4
View File
@@ -19,7 +19,7 @@
"test:watch": "yarn dev:build && jest --watch",
"test:cov": "yarn dev:build && jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json",
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192' jest --ci --forceExit --config ./jest-e2e.json",
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
"prisma": "yarn workspace @calcom/prisma prisma",
"generate-schemas": "yarn prisma generate && yarn prisma format",
@@ -28,13 +28,14 @@
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.34",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.46",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
"@golevelup/ts-jest": "^0.4.0",
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
"@nest-lab/throttler-storage-redis": "1.0.0",
"@nestjs/bull": "^10.1.1",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
@@ -43,7 +44,7 @@
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "^5.1.2",
"@nestjs/throttler": "6.2.1",
"@sentry/node": "^8.8.0",
"body-parser": "^1.20.2",
"bull": "^4.12.4",
@@ -57,10 +58,10 @@
"ioredis": "^5.3.2",
"luxon": "^3.4.4",
"nest-winston": "^1.9.4",
"nestjs-throttler-storage-redis": "^0.4.1",
"next-auth": "^4.22.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"qs-stringify": "^1.2.1",
"querystring": "^0.2.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
-2
View File
@@ -3,8 +3,6 @@ import { Controller, Get, Version, VERSION_NEUTRAL } from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
@Controller()
@DocsTags("Health - development only")
@DocsExcludeController(getEnv("NODE_ENV") === "production")
export class AppController {
@Get("health")
@Version(VERSION_NEUTRAL)
+373 -14
View File
@@ -1,26 +1,385 @@
import { AppModule } from "@/app.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { TestingModule } from "@nestjs/testing";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { RateLimitRepositoryFixture } from "test/fixtures/repository/rate-limit.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
describe("AppController (e2e)", () => {
let app: INestApplication;
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { User, PlatformOAuthClient, Team, RateLimit } from "@calcom/prisma/client";
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
describe("AppController", () => {
describe("Rate limiting", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let rateLimitRepositoryFixture: RateLimitRepositoryFixture;
const userEmail = "app-rate-limits-e2e@api.com";
let user: User;
app = moduleFixture.createNestApplication();
await app.init();
});
let organization: Team;
let oAuthClient: PlatformOAuthClient;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
it("/ (GET)", () => {
return request(app.getHttpServer()).get("/health").expect("OK");
});
let apiKeyString: string;
afterAll(async () => {
await app.close();
let rateLimit: RateLimit;
let apiKeyStringWithRateLimit: string;
let apiKeyStringWithMultipleLimits: string;
let firstRateLimitWithMultipleLimits: RateLimit;
let secondRateLimitWithMultipleLimits: RateLimit;
const mockDefaultLimit = 5;
const mockDefaultTtl = 2500;
const mockDefaultBlockDuration = 5000;
beforeEach(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
}).compile();
jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultLimit").mockReturnValue(mockDefaultLimit);
jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultTtl").mockReturnValue(mockDefaultTtl);
jest
.spyOn(CustomThrottlerGuard.prototype, "getDefaultBlockDuration")
.mockReturnValue(mockDefaultBlockDuration);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `cal_test_${keyString}`;
rateLimitRepositoryFixture = new RateLimitRepositoryFixture(moduleRef);
const { apiKey, keyString: keyStringWithRateLimit } = await apiKeysRepositoryFixture.createApiKey(
user.id,
null
);
apiKeyStringWithRateLimit = `cal_test_${keyStringWithRateLimit}`;
rateLimit = await rateLimitRepositoryFixture.createRateLimit("long", apiKey.id, 2000, 3, 4000);
const { apiKey: apiKeyWithMultipleLimits, keyString: keyStringWithMultipleLimits } =
await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyStringWithMultipleLimits = `cal_test_${keyStringWithMultipleLimits}`;
firstRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit(
"short",
apiKeyWithMultipleLimits.id,
1000,
2,
2000
);
secondRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit(
"long",
apiKeyWithMultipleLimits.id,
2000,
3,
4000
);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
organization = await organizationsRepositoryFixture.create({ name: "ecorp" });
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
oAuthClient = await createOAuthClient(organization.id);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
await profilesRepositoryFixture.create({
uid: "asd-asd",
username: userEmail,
user: { connect: { id: user.id } },
organization: { connect: { id: organization.id } },
});
app = moduleRef.createNestApplication();
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 1023,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it(
"api key with default rate limit - should enforce rate limits and reset after block duration",
async () => {
const limit = mockDefaultLimit;
const blockDuration = mockDefaultBlockDuration;
for (let i = 1; i <= limit; i++) {
const response = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyString}` })
.expect(200);
expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString());
expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
}
const blockedResponse = await request(app.getHttpServer())
.get("/v2/me")
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(429);
expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0");
expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual(
blockDuration / 1000
);
await new Promise((resolve) => setTimeout(resolve, blockDuration));
const afterBlockResponse = await request(app.getHttpServer())
.get("/v2/me")
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString());
expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
},
15 * 1000
);
it(
"api key with custom rate limit - should enforce rate limits and reset after block duration",
async () => {
const limit = rateLimit.limit;
const blockDuration = rateLimit.blockDuration;
const name = rateLimit.name;
for (let i = 1; i <= limit; i++) {
const response = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithRateLimit}` })
.expect(200);
expect(response.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
expect(response.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - i).toString());
expect(Number(response.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0);
}
const blockedResponse = await request(app.getHttpServer())
.get("/v2/me")
.set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`)
.expect(429);
expect(blockedResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
expect(blockedResponse.headers[`x-ratelimit-remaining-${name}`]).toBe("0");
expect(Number(blockedResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThanOrEqual(
blockDuration / 1000
);
await new Promise((resolve) => setTimeout(resolve, blockDuration));
const afterBlockResponse = await request(app.getHttpServer())
.get("/v2/me")
.set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`)
.expect(200);
expect(afterBlockResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
expect(afterBlockResponse.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - 1).toString());
expect(Number(afterBlockResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0);
},
15 * 1000
);
it(
"api key with multiple rate limits - should enforce both short and long rate limits",
async () => {
const shortLimit = firstRateLimitWithMultipleLimits.limit;
const longLimit = secondRateLimitWithMultipleLimits.limit;
const shortName = firstRateLimitWithMultipleLimits.name;
const longName = secondRateLimitWithMultipleLimits.name;
const shortBlock = firstRateLimitWithMultipleLimits.blockDuration;
const longBlock = secondRateLimitWithMultipleLimits.blockDuration;
let requestsMade = 0;
// note(Lauris): exhaust short limit to have remaining 0 for it
for (let i = 1; i <= shortLimit; i++) {
const response = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(200);
requestsMade++;
expect(response.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString());
expect(response.headers[`x-ratelimit-remaining-${shortName}`]).toBe((shortLimit - i).toString());
expect(Number(response.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(0);
expect(response.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
expect(response.headers[`x-ratelimit-remaining-${longName}`]).toBe((longLimit - i).toString());
expect(Number(response.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0);
}
// note(Lauris): short limit exhausted, now exhaust long limit to have remaining 0 for it
for (let i = requestsMade; i < longLimit; i++) {
const responseAfterShortLimit = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(200);
requestsMade++;
expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${shortName}`]).toBe(
shortLimit.toString()
);
expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0");
expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(
0
);
expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${longName}`]).toBe(
(longLimit - requestsMade).toString()
);
expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0);
}
// note(Lauris): both have remaining 0 so now exceed both
const blockedResponseLong = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(429);
expect(blockedResponseLong.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString());
expect(blockedResponseLong.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0");
expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThanOrEqual(
firstRateLimitWithMultipleLimits.blockDuration / 1000
);
expect(blockedResponseLong.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
expect(blockedResponseLong.headers[`x-ratelimit-remaining-${longName}`]).toBe("0");
expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThanOrEqual(
secondRateLimitWithMultipleLimits.blockDuration / 1000
);
// note(Lauris): wait for short limit to reset
await new Promise((resolve) => setTimeout(resolve, shortBlock));
const responseAfterShortLimitReload = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(200);
expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe(
shortLimit.toString()
);
expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe(
(shortLimit - 1).toString()
);
expect(
Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${shortName}`])
).toBeGreaterThan(0);
expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe(
longLimit.toString()
);
expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe(
(longLimit - requestsMade).toString()
);
expect(
Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${longName}`])
).toBeGreaterThan(0);
// note(Lauris): wait for long limit to reset
await new Promise((resolve) => setTimeout(resolve, longBlock));
const responseAfterLongLimitReload = await request(app.getHttpServer())
.get("/v2/me")
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
.expect(200);
expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe(
shortLimit.toString()
);
expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe(
(shortLimit - 1).toString()
);
expect(
Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${shortName}`])
).toBeGreaterThan(0);
expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe(
longLimit.toString()
);
expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe(
(longLimit - 1).toString()
);
expect(Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(
0
);
},
30 * 1000
);
it(
"non api key with default rate limit - should enforce rate limits and reset after block duration",
async () => {
const limit = mockDefaultLimit;
const blockDuration = mockDefaultBlockDuration;
for (let i = 1; i <= limit; i++) {
const response = await request(app.getHttpServer())
.get("/v2/me")
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200);
expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString());
expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
}
const blockedResponse = await request(app.getHttpServer())
.get("/v2/me")
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(429);
expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0");
expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual(
blockDuration / 1000
);
await new Promise((resolve) => setTimeout(resolve, blockDuration));
const afterBlockResponse = await request(app.getHttpServer())
.get("/v2/me")
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200);
expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString());
expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
},
15 * 1000
);
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(userEmail);
await organizationsRepositoryFixture.delete(organization.id);
await app.close();
});
});
});
+21 -6
View File
@@ -1,5 +1,7 @@
import appConfig from "@/config/app";
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
import { RedirectsMiddleware } from "@/middleware/app.redirects.middleware";
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware";
@@ -11,12 +13,12 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RedisService } from "@/modules/redis/redis.service";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { BullModule } from "@nestjs/bull";
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { seconds, ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
import { seconds, ThrottlerModule } from "@nestjs/throttler";
import { AppController } from "./app.controller";
@@ -36,11 +38,15 @@ import { AppController } from "./app.controller";
imports: [RedisModule],
inject: [RedisService],
useFactory: (redisService: RedisService) => ({
// note(Lauris): IMPORTANT: rate limiting is enforced by CustomThrottlerGuard, but we need to have at least one
// entry in the throttlers array otherwise CustomThrottlerGuard is not invoked at all. If we specify only ThrottlerModule
// without .forRootAsync then throttler options are not passed to CustomThrottlerGuard containing redis connection etc.
// So we need to specify at least one dummy throttler here and CustomThrottlerGuard is actually handling the default and custom rate limits.
throttlers: [
{
name: "long",
ttl: seconds(60), // Time to live for the long period in seconds
limit: 120, // Maximum number of requests within the long ttl
name: "dummy",
ttl: seconds(60),
limit: 120,
},
],
storage: new ThrottlerStorageRedisService(redisService.redis),
@@ -53,13 +59,20 @@ import { AppController } from "./app.controller";
],
controllers: [AppController],
providers: [
{
provide: ThrottlerStorageRedisService,
useFactory: (redisService: RedisService) => {
return new ThrottlerStorageRedisService(redisService.redis);
},
inject: [RedisService],
},
{
provide: APP_INTERCEPTOR,
useClass: ResponseInterceptor,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
useClass: CustomThrottlerGuard,
},
],
})
@@ -77,6 +90,8 @@ export class AppModule implements NestModule {
.forRoutes("*")
.apply(AppLoggerMiddleware)
.forRoutes("*")
.apply(RedirectsMiddleware)
.forRoutes("/")
.apply(RewriterMiddleware)
.forRoutes("/");
}
@@ -1,4 +1,4 @@
import { BookingsController } from "@/ee/bookings/controllers/bookings.controller";
import { BookingsController_2024_04_15 } from "@/ee/bookings/2024-04-15/controllers/bookings.controller";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { BillingModule } from "@/modules/billing/billing.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
@@ -12,6 +12,6 @@ import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, RedisModule, TokensModule, BillingModule],
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository, ApiKeyRepository],
controllers: [BookingsController],
controllers: [BookingsController_2024_04_15],
})
export class BookingsModule {}
export class BookingsModule_2024_04_15 {}
@@ -1,8 +1,8 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-booking.output";
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
@@ -24,7 +24,7 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { handleNewBooking } from "@calcom/platform-libraries";
import { ApiSuccessResponse, ApiResponse, ApiErrorResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
describe("Bookings Endpoints 2024-04-15", () => {
describe("User Authenticated", () => {
let app: INestApplication;
@@ -111,7 +111,7 @@ describe("Bookings Endpoints", () => {
guests: [],
};
const body: CreateBookingInput = {
const body: CreateBookingInput_2024_04_15 = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
@@ -166,7 +166,7 @@ describe("Bookings Endpoints", () => {
guests: [],
};
const body: CreateBookingInput = {
const body: CreateBookingInput_2024_04_15 = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
@@ -209,7 +209,7 @@ describe("Bookings Endpoints", () => {
guests: [],
};
const body: CreateBookingInput = {
const body: CreateBookingInput_2024_04_15 = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
@@ -266,7 +266,7 @@ describe("Bookings Endpoints", () => {
guests: [],
};
const body: CreateBookingInput = {
const body: CreateBookingInput_2024_04_15 = {
rescheduleUid: createdBooking.uid,
start: newBookingStart,
end: newBookingEnd,
@@ -305,7 +305,7 @@ describe("Bookings Endpoints", () => {
return request(app.getHttpServer())
.get("/v2/bookings?filters[status]=upcoming")
.then((response) => {
const responseBody: GetBookingsOutput = response.body;
const responseBody: GetBookingsOutput_2024_04_15 = response.body;
expect(responseBody.data.bookings.length).toEqual(2);
const fetchedBooking = responseBody.data.bookings.find(
@@ -329,7 +329,7 @@ describe("Bookings Endpoints", () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${createdBooking.uid}`)
.then((response) => {
const responseBody: GetBookingOutput = response.body;
const responseBody: GetBookingOutput_2024_04_15 = response.body;
const bookingInfo = responseBody.data;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
@@ -1,11 +1,11 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input";
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.output";
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
import { CreateRecurringBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-recurring-booking.input";
import { MarkNoShowInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/mark-no-show.input";
import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-booking.output";
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/mark-no-show.output";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
@@ -31,7 +31,7 @@ import {
UseGuards,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { Request } from "express";
import { NextApiRequest } from "next/types";
@@ -40,10 +40,10 @@ import { v4 as uuidv4 } from "uuid";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
import {
handleNewRecurringBooking,
handleNewBooking,
BookingResponse,
HttpError,
handleNewRecurringBooking,
handleInstantMeeting,
handleMarkNoShow,
getAllUserBookings,
@@ -52,7 +52,11 @@ import {
getBookingForReschedule,
ErrorCode,
} from "@calcom/platform-libraries";
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
import {
GetBookingsInput_2024_04_15,
CancelBookingInput_2024_04_15,
Status_2024_04_15,
} from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -80,11 +84,11 @@ const DEFAULT_PLATFORM_PARAMS = {
@Controller({
path: "/v2/bookings",
version: API_VERSIONS_VALUES,
version: [VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14],
})
@UseGuards(PermissionsGuard)
@DocsTags("Bookings")
export class BookingsController {
@DocsExcludeController(true)
export class BookingsController_2024_04_15 {
private readonly logger = new Logger("BookingsController");
constructor(
@@ -99,16 +103,16 @@ export class BookingsController {
@Get("/")
@UseGuards(ApiAuthGuard)
@Permissions([BOOKING_READ])
@ApiQuery({ name: "filters[status]", enum: Status, required: true })
@ApiQuery({ name: "filters[status]", enum: Status_2024_04_15, required: true })
@ApiQuery({ name: "limit", type: "number", required: false })
@ApiQuery({ name: "cursor", type: "number", required: false })
async getBookings(
@GetUser() user: User,
@Query() queryParams: GetBookingsInput
): Promise<GetBookingsOutput> {
@Query() queryParams: GetBookingsInput_2024_04_15
): Promise<GetBookingsOutput_2024_04_15> {
const { filters, cursor, limit } = queryParams;
const bookings = await getAllUserBookings({
bookingListingByStatus: filters.status,
bookingListingByStatus: [filters.status],
skip: cursor ?? 0,
take: limit ?? 10,
filters,
@@ -125,7 +129,7 @@ export class BookingsController {
}
@Get("/:bookingUid")
async getBooking(@Param("bookingUid") bookingUid: string): Promise<GetBookingOutput> {
async getBooking(@Param("bookingUid") bookingUid: string): Promise<GetBookingOutput_2024_04_15> {
const { bookingInfo } = await getBookingInfo(bookingUid);
if (!bookingInfo) {
@@ -155,7 +159,7 @@ export class BookingsController {
@Post("/")
async createBooking(
@Req() req: BookingRequest,
@Body() body: CreateBookingInput,
@Body() body: CreateBookingInput_2024_04_15,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<Partial<BookingResponse>>> {
const oAuthClientId = clientId?.toString();
@@ -186,7 +190,7 @@ export class BookingsController {
async cancelBooking(
@Req() req: BookingRequest,
@Param("bookingId") bookingId: string,
@Body() _: CancelBookingInput,
@Body() _: CancelBookingInput_2024_04_15,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<{ bookingId: number; bookingUid: string; onlyRemovedAttendee: boolean }>> {
const oAuthClientId = clientId?.toString();
@@ -219,9 +223,9 @@ export class BookingsController {
@UseGuards(ApiAuthGuard)
async markNoShow(
@GetUser("id") userId: number,
@Body() body: MarkNoShowInput,
@Body() body: MarkNoShowInput_2024_04_15,
@Param("bookingUid") bookingUid: string
): Promise<MarkNoShowOutput> {
): Promise<MarkNoShowOutput_2024_04_15> {
try {
const markNoShowResponse = await handleMarkNoShow({
bookingUid: bookingUid,
@@ -240,7 +244,7 @@ export class BookingsController {
@Post("/recurring")
async createRecurringBooking(
@Req() req: BookingRequest,
@Body() _: CreateRecurringBookingInput[],
@Body() _: CreateRecurringBookingInput_2024_04_15[],
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<BookingResponse[]>> {
const oAuthClientId = clientId?.toString();
@@ -278,7 +282,7 @@ export class BookingsController {
@Post("/instant")
async createInstantBooking(
@Req() req: BookingRequest,
@Body() _: CreateBookingInput,
@Body() _: CreateBookingInput_2024_04_15,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>>> {
const oAuthClientId = clientId?.toString();
@@ -369,7 +373,12 @@ export class BookingsController {
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
Object.assign(req, { userId, ...oAuthParams, platformBookingLocation });
Object.assign(req, {
userId,
...oAuthParams,
platformBookingLocation,
noEmail: !oAuthParams.arePlatformEmailsEnabled,
});
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
@@ -40,7 +40,7 @@ class Response {
notes?: string;
}
export class CreateBookingInput {
export class CreateBookingInput_2024_04_15 {
@IsString()
@IsOptional()
end?: string;
@@ -1,9 +1,9 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
import { IsBoolean, IsString, IsNumber, IsOptional } from "class-validator";
import type { AppsStatus } from "@calcom/platform-libraries";
export class CreateRecurringBookingInput extends CreateBookingInput {
export class CreateRecurringBookingInput_2024_04_15 extends CreateBookingInput_2024_04_15 {
@IsBoolean()
@IsOptional()
noEmail?: boolean;
@@ -9,7 +9,7 @@ class Attendee {
noShow!: boolean;
}
export class MarkNoShowInput {
export class MarkNoShowInput_2024_04_15 {
@IsBoolean()
@IsOptional()
noShowHost?: boolean;
@@ -88,7 +88,7 @@ class EventType {
timeZone!: string | null;
}
class GetBookingData {
class GetBookingData_2024_04_15 {
@IsString()
title!: string;
@@ -159,15 +159,15 @@ class GetBookingData {
eventType!: EventType | null;
}
export class GetBookingOutput {
export class GetBookingOutput_2024_04_15 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: GetBookingData,
type: GetBookingData_2024_04_15,
})
@ValidateNested()
@Type(() => GetBookingData)
data!: GetBookingData;
@Type(() => GetBookingData_2024_04_15)
data!: GetBookingData_2024_04_15;
}
@@ -202,7 +202,7 @@ class GetBookingsDataEntry {
rescheduled?: any;
}
class GetBookingsData {
class GetBookingsData_2024_04_15 {
@ValidateNested()
@Type(() => GetBookingsDataEntry)
@IsArray()
@@ -215,15 +215,15 @@ class GetBookingsData {
nextCursor!: number | null;
}
export class GetBookingsOutput {
export class GetBookingsOutput_2024_04_15 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: GetBookingsData,
type: GetBookingsData_2024_04_15,
})
@ValidateNested()
@Type(() => GetBookingsData)
data!: GetBookingsData;
@Type(() => GetBookingsData_2024_04_15)
data!: GetBookingsData_2024_04_15;
}
@@ -12,7 +12,7 @@ class Attendee {
noShow!: boolean;
}
class HandleMarkNoShowData {
class HandleMarkNoShowData_2024_04_15 {
@IsString()
message!: string;
@@ -27,15 +27,15 @@ class HandleMarkNoShowData {
attendees?: Attendee[];
}
export class MarkNoShowOutput {
export class MarkNoShowOutput_2024_04_15 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: HandleMarkNoShowData,
type: HandleMarkNoShowData_2024_04_15,
})
@ValidateNested()
@Type(() => HandleMarkNoShowData)
data!: HandleMarkNoShowData;
@Type(() => HandleMarkNoShowData_2024_04_15)
data!: HandleMarkNoShowData_2024_04_15;
}
@@ -0,0 +1,33 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { BillingModule } from "@/modules/billing/billing.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, RedisModule, TokensModule, BillingModule, UsersModule],
providers: [
TokensRepository,
OAuthFlowService,
OAuthClientRepository,
BookingsService_2024_08_13,
InputBookingsService_2024_08_13,
OutputBookingsService_2024_08_13,
BookingsRepository_2024_08_13,
EventTypesRepository_2024_06_14,
ApiKeyRepository,
],
controllers: [BookingsController_2024_08_13],
})
export class BookingsModule_2024_08_13 {}
@@ -0,0 +1,90 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class BookingsRepository_2024_08_13 {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getById(id: number) {
return this.dbRead.prisma.booking.findUnique({
where: {
id,
},
});
}
async getByIdsWithAttendeesAndUserAndEvent(ids: number[]) {
return this.dbRead.prisma.booking.findMany({
where: {
id: {
in: ids,
},
},
include: {
attendees: true,
user: true,
eventType: true,
},
});
}
async getByUid(bookingUid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
uid: bookingUid,
},
});
}
async getByIdWithAttendeesAndUserAndEvent(id: number) {
return this.dbRead.prisma.booking.findUnique({
where: {
id,
},
include: {
attendees: true,
user: true,
eventType: true,
},
});
}
async getByUidWithAttendeesAndUserAndEvent(uid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
uid,
},
include: {
attendees: true,
user: true,
eventType: true,
},
});
}
async getRecurringByUidWithAttendeesAndUserAndEvent(uid: string) {
return this.dbRead.prisma.booking.findMany({
where: {
recurringEventId: uid,
},
include: {
attendees: true,
user: true,
eventType: true,
},
});
}
async getByFromReschedule(fromReschedule: string) {
return this.dbRead.prisma.booking.findFirst({
where: {
fromReschedule,
},
include: {
attendees: true,
user: true,
},
});
}
}
@@ -0,0 +1,160 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("With api key", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let apiKeyString: string;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
let eventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
user = await userRepositoryFixture.create({
email: userEmail,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = keyString;
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const event = await eventTypesRepositoryFixture.create(
{ title: "peer coding", slug: "peer-coding", length: 60 },
user.id
);
eventTypeId = event.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
describe("create bookings", () => {
it("should create a booking with api key", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId,
attendee: {
name: "Mr Key",
email: "mr_key@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
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[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, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(eventTypeId);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
afterAll(async () => {
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -0,0 +1,221 @@
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { VERSION_2024_08_13_VALUE } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import {
Controller,
Post,
Logger,
Body,
UseGuards,
Req,
Get,
Param,
Query,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import {
ApiOperation,
ApiTags as DocsTags,
ApiHeader,
getSchemaPath,
ApiBody,
ApiExtraModels,
} from "@nestjs/swagger";
import { User } from "@prisma/client";
import { Request } from "express";
import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetBookingOutput_2024_08_13, GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
import {
CreateBookingInputPipe,
CreateBookingInput,
GetBookingsInput_2024_08_13,
RescheduleBookingInput_2024_08_13,
CancelBookingInput_2024_08_13,
MarkAbsentBookingInput_2024_08_13,
CreateBookingInput_2024_08_13,
CreateInstantBookingInput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
} from "@calcom/platform-types";
@Controller({
path: "/v2/bookings",
version: VERSION_2024_08_13_VALUE,
})
@UseGuards(PermissionsGuard)
@DocsTags("Bookings")
@ApiHeader({
name: "cal-api-version",
description: `Must be set to \`2024-08-13\``,
required: true,
})
export class BookingsController_2024_08_13 {
private readonly logger = new Logger("BookingsController");
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
@Post("/")
@ApiOperation({
summary: "Create a booking",
description: `
POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:
If eventTypeId in the request body is id of a regular event, then regular booking is created.
If it is an id of a recurring event type, then recurring booking is created.
Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.
For team event types it is possible to create instant meeting. To do that just pass \`"instant": true\` to the request body.
The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.
`,
})
@ApiBody({
schema: {
oneOf: [
{ $ref: getSchemaPath(CreateBookingInput_2024_08_13) },
{ $ref: getSchemaPath(CreateInstantBookingInput_2024_08_13) },
{ $ref: getSchemaPath(CreateRecurringBookingInput_2024_08_13) },
],
},
description:
"Accepts different types of booking input: CreateBookingInput_2024_08_13, CreateInstantBookingInput_2024_08_13, or CreateRecurringBookingInput_2024_08_13",
})
@ApiExtraModels(
CreateBookingInput_2024_08_13,
CreateInstantBookingInput_2024_08_13,
CreateRecurringBookingInput_2024_08_13
)
async createBooking(
@Body(new CreateBookingInputPipe())
body: CreateBookingInput,
@Req() request: Request
): Promise<CreateBookingOutput_2024_08_13> {
const booking = await this.bookingsService.createBooking(request, body);
if (Array.isArray(booking)) {
await this.bookingsService.billBookings(booking);
} else {
await this.bookingsService.billBooking(booking);
}
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Get("/:bookingUid")
@UseGuards(BookingUidGuard)
@ApiOperation({
summary: "Get a booking",
description: `\`:bookingUid\` can be
1. uid of a normal booking
2. uid of one of the recurring booking recurrences
3. uid of recurring booking which will return an array of all recurring booking recurrences (stored as recurringBookingUid on one of the individual recurrences).`,
})
async getBooking(@Param("bookingUid") bookingUid: string): Promise<GetBookingOutput_2024_08_13> {
const booking = await this.bookingsService.getBooking(bookingUid);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Get("/")
@UseGuards(ApiAuthGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@Permissions([BOOKING_READ])
@ApiOperation({ summary: "Get all bookings" })
async getBookings(
@Query() queryParams: GetBookingsInput_2024_08_13,
@GetUser() user: User
): Promise<GetBookingsOutput_2024_08_13> {
const bookings = await this.bookingsService.getBookings(queryParams, user);
return {
status: SUCCESS_STATUS,
data: bookings,
};
}
@Post("/:bookingUid/reschedule")
@UseGuards(BookingUidGuard)
@ApiOperation({
summary: "Reschedule a booking",
description:
"Reschedule a booking by passing `:bookingUid` of the booking that should be rescheduled and pass request body with a new start time to create a new booking.",
})
async rescheduleBooking(
@Param("bookingUid") bookingUid: string,
@Body() body: RescheduleBookingInput_2024_08_13,
@Req() request: Request
): Promise<RescheduleBookingOutput_2024_08_13> {
const newBooking = await this.bookingsService.rescheduleBooking(request, bookingUid, body);
await this.bookingsService.billRescheduledBooking(newBooking, bookingUid);
return {
status: SUCCESS_STATUS,
data: newBooking,
};
}
@Post("/:bookingUid/cancel")
@UseGuards(BookingUidGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Cancel a booking" })
async cancelBooking(
@Req() request: Request,
@Param("bookingUid") bookingUid: string,
@Body() body: CancelBookingInput_2024_08_13
): Promise<CancelBookingOutput_2024_08_13> {
const cancelledBooking = await this.bookingsService.cancelBooking(request, bookingUid, body);
return {
status: SUCCESS_STATUS,
data: cancelledBooking,
};
}
@Post("/:bookingUid/mark-absent")
@HttpCode(HttpStatus.OK)
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@ApiOperation({ summary: "Mark a booking absence" })
async markNoShow(
@Param("bookingUid") bookingUid: string,
@Body() body: MarkAbsentBookingInput_2024_08_13,
@GetUser("id") ownerId: number
): Promise<MarkAbsentBookingOutput_2024_08_13> {
const booking = await this.bookingsService.markAbsent(bookingUid, ownerId, body);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
}
@@ -0,0 +1,435 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Team bookings", () => {
let app: INestApplication;
let organization: Team;
let team1: Team;
let team2: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const teamUserEmail = "orgUser1team1@api.com";
const teamUserEmail2 = "orgUser2team1@api.com";
let teamUser: User;
let teamUser2: User;
let team1EventTypeId: number;
let team2EventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
teamUserEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" });
team1 = await teamRepositoryFixture.create({
name: "team 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
});
team2 = await teamRepositoryFixture.create({
name: "team 2",
isOrganization: false,
parent: { connect: { id: organization.id } },
});
oAuthClient = await createOAuthClient(organization.id);
teamUser = await userRepositoryFixture.create({
email: teamUserEmail,
locale: "it",
name: "orgUser1team1",
});
teamUser2 = await userRepositoryFixture.create({
email: teamUserEmail2,
locale: "es",
name: "orgUser2team1",
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(teamUser.id, userSchedule);
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
await profileRepositoryFixture.create({
uid: `usr-${teamUser.id}`,
username: teamUserEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: teamUser.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${teamUser2.id}`,
username: teamUserEmail2,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: teamUser2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: teamUser.id } },
team: { connect: { id: team1.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: teamUser.id } },
team: { connect: { id: team2.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: teamUser2.id } },
team: { connect: { id: team2.id } },
accepted: true,
});
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team1.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
team1EventTypeId = team1EventType.id;
const team2EventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team2.id },
},
title: "Collective Event Type 2",
slug: "collective-event-type-2",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
team2EventTypeId = team2EventType.id;
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: teamUser.id,
},
},
eventType: {
connect: {
id: team1EventType.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: teamUser.id,
},
},
eventType: {
connect: {
id: team2EventType.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: teamUser2.id,
},
},
eventType: {
connect: {
id: team2EventType.id,
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("create team bookings", () => {
it("should create a team 1 booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: team1EventTypeId,
attendee: {
name: "alice",
email: "alice@gmail.com",
timeZone: "Europe/Madrid",
language: "es",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
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(teamUser.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(team1EventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should create a team 2 booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: team2EventTypeId,
attendee: {
name: "bob",
email: "bob@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
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(teamUser.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(team2EventTypeId);
expect(data.attendees.length).toEqual(2);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.attendees[1]).toEqual({
name: teamUser2.name,
timeZone: teamUser2.timeZone,
language: teamUser2.locale,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
describe("get team bookings", () => {
it("should should get bookings by teamId", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings?teamId=${team1.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].eventTypeId).toEqual(team1EventTypeId);
});
});
it("should should get bookings by teamId", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings?teamId=${team2.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].eventTypeId).toEqual(team2EventTypeId);
});
});
it("should should get bookings by teamIds", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings?teamIds=${team1.id},${team2.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data.find((booking) => booking.eventTypeId === team1EventTypeId)).toBeDefined();
expect(data.find((booking) => booking.eventTypeId === team2EventTypeId)).toBeDefined();
});
});
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(teamUser.email);
await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email);
await app.close();
});
});
});
@@ -0,0 +1,16 @@
import { Injectable, CanActivate, ExecutionContext, BadRequestException } from "@nestjs/common";
@Injectable()
export class BookingUidGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const bookingUid = request.params.bookingUid;
if (!bookingUid) {
throw new BadRequestException("Booking UID missing in the request path");
}
return true;
}
}
@@ -0,0 +1,26 @@
import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
export class CancelBookingOutput_2024_08_13 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
{ $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) },
{ type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } },
],
description:
"Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects",
})
@Type(() => Object)
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13[];
}
@@ -0,0 +1,25 @@
import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
export class CreateBookingOutput_2024_08_13 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
{ type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } },
],
description:
"Booking data, which can be either a BookingOutput object or an array of RecurringBookingOutput objects",
})
@Type(() => Object)
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13[];
}
@@ -0,0 +1,25 @@
import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
export class MarkAbsentBookingOutput_2024_08_13 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
{ $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) },
],
description:
"Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object",
})
@ValidateNested()
@Type(() => Object)
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13;
}
@@ -0,0 +1,25 @@
import { ApiProperty, ApiExtraModels, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
export class RescheduleBookingOutput_2024_08_13 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
{ $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) },
],
description:
"Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object",
})
@ValidateNested()
@Type(() => Object)
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13;
}
@@ -0,0 +1,277 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { BillingService } from "@/modules/billing/services/billing.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException } from "@nestjs/common";
import { Request } from "express";
import {
handleNewBooking,
handleNewRecurringBooking,
getAllUserBookings,
handleInstantMeeting,
handleCancelBooking,
handleMarkNoShow,
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
RescheduleBookingInput_2024_08_13,
CreateBookingInput,
CreateRecurringBookingInput_2024_08_13,
GetBookingsInput_2024_08_13,
CreateInstantBookingInput_2024_08_13,
CancelBookingInput_2024_08_13,
MarkAbsentBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
type CreatedBooking = {
hosts: { id: number }[];
uid: string;
start: string;
};
@Injectable()
export class BookingsService_2024_08_13 {
private readonly logger = new Logger("BookingsService");
constructor(
private readonly inputService: InputBookingsService_2024_08_13,
private readonly outputService: OutputBookingsService_2024_08_13,
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly prismaReadService: PrismaReadService,
private readonly billingService: BillingService
) {}
async createBooking(request: Request, body: CreateBookingInput) {
try {
if ("instant" in body && body.instant) {
return await this.createInstantBooking(request, body);
}
if (await this.isRecurring(body)) {
return await this.createRecurringBooking(request, body);
}
return await this.createRegularBooking(request, body);
} 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;
}
}
async createInstantBooking(request: Request, body: CreateInstantBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createBookingRequest(request, body);
const booking = await handleInstantMeeting(bookingRequest);
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(
booking.bookingId
);
if (!databaseBooking) {
throw new Error(`Booking with id=${booking.bookingId} was not found in the database`);
}
return this.outputService.getOutputBooking(databaseBooking);
}
async isRecurring(body: CreateBookingInput) {
const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId);
return !!eventType?.recurringEvent;
}
async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
const bookings = await handleNewRecurringBooking(bookingRequest);
const uid = bookings[0].recurringEventId;
if (!uid) {
throw new Error("Recurring booking was not created");
}
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(uid);
return this.outputService.getOutputRecurringBookings(recurringBooking);
}
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createBookingRequest(request, body);
const booking = await handleNewBooking(bookingRequest);
if (!booking.id) {
throw new Error("Booking was not created");
}
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
if (!databaseBooking) {
throw new Error(`Booking with id=${booking.id} was not found in the database`);
}
return this.outputService.getOutputBooking(databaseBooking);
}
async getBooking(uid: string) {
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(uid);
if (booking) {
const isRecurring = !!booking.recurringEventId;
if (isRecurring) {
return this.outputService.getOutputRecurringBooking(booking);
}
return this.outputService.getOutputBooking(booking);
}
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(uid);
if (!recurringBooking.length) {
throw new NotFoundException(`Booking with uid=${uid} was not found in the database`);
}
return this.outputService.getOutputRecurringBookings(recurringBooking);
}
async getBookings(queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number }) {
const fetchedBookings: { bookings: { id: number }[] } = await getAllUserBookings({
bookingListingByStatus: queryParams.status || [],
skip: queryParams.skip ?? 0,
// note(Lauris): we substract -1 because getAllUSerBookings child function adds +1 for some reason
take: queryParams.take ? queryParams.take - 1 : 100,
filters: this.inputService.transformGetBookingsFilters(queryParams),
ctx: {
user,
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
},
sort: this.inputService.transformGetBookingsSort(queryParams),
});
// note(Lauris): fetchedBookings don't have attendees information and responses and i don't want to add them to the handler query,
// because its used elsewhere in code that does not need that information, so i get ids, fetch bookings and then return them formatted in same order as ids.
const ids = fetchedBookings.bookings.map((booking) => booking.id);
const bookings = await this.bookingsRepository.getByIdsWithAttendeesAndUserAndEvent(ids);
const bookingMap = new Map(bookings.map((booking) => [booking.id, booking]));
const orderedBookings = ids.map((id) => bookingMap.get(id));
const formattedBookings: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = [];
for (const booking of orderedBookings) {
if (!booking) {
continue;
}
const formatted = {
...booking,
eventType: booking.eventType,
eventTypeId: booking.eventTypeId,
startTime: new Date(booking.startTime),
endTime: new Date(booking.endTime),
absentHost: !!booking.noShowHost,
};
const isRecurring = !!formatted.recurringEventId;
if (isRecurring) {
formattedBookings.push(this.outputService.getOutputRecurringBooking(formatted));
} else {
formattedBookings.push(this.outputService.getOutputBooking(formatted));
}
}
return formattedBookings;
}
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput_2024_08_13) {
try {
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
request,
bookingUid,
body
);
const booking = await handleNewBooking(bookingRequest);
if (!booking.id) {
throw new Error("Booking was not created");
}
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
if (!databaseBooking) {
throw new Error(`Booking with id=${booking.id} was not found in the database`);
}
if (databaseBooking.recurringEventId) {
return this.outputService.getOutputRecurringBooking(databaseBooking);
}
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;
}
}
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
await handleCancelBooking(bookingRequest);
return this.getBooking(bookingUid);
}
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
await handleMarkNoShow({
bookingUid,
attendees: bodyTransformed.attendees,
noShowHost: bodyTransformed.noShowHost,
userId: bookingOwnerId,
});
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
if (!booking) {
throw new Error(`Booking with uid=${bookingUid} was not found in the database`);
}
const isRecurring = !!booking.recurringEventId;
if (isRecurring) {
return this.outputService.getOutputRecurringBooking(booking);
}
return this.outputService.getOutputBooking(booking);
}
async billBookings(bookings: CreatedBooking[]) {
for (const booking of bookings) {
await this.billBooking(booking);
}
}
async billBooking(booking: CreatedBooking) {
const hostId = booking.hosts[0].id;
if (!hostId) {
this.logger.error(`Booking with uid=${booking.uid} has no host`);
return;
}
await this.billingService.increaseUsageByUserId(hostId, {
uid: booking.uid,
startTime: new Date(booking.start),
});
}
async billRescheduledBooking(newBooking: CreatedBooking, oldBookingUid: string) {
const hostId = newBooking.hosts[0].id;
if (!hostId) {
this.logger.error(`Booking with uid=${newBooking.uid} has no host`);
return;
}
await this.billingService.increaseUsageByUserId(hostId, {
uid: newBooking.uid,
startTime: new Date(newBooking.start),
fromReschedule: oldBookingUid,
});
}
}
@@ -0,0 +1,422 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { bookingResponsesSchema } from "@/ee/bookings/2024-08-13/services/output.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Request } from "express";
import { DateTime } from "luxon";
import { NextApiRequest } from "next/types";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import {
CancelBookingInput_2024_08_13,
CreateBookingInput_2024_08_13,
CreateInstantBookingInput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
GetBookingsInput_2024_08_13,
MarkAbsentBookingInput_2024_08_13,
RescheduleBookingInput_2024_08_13,
} from "@calcom/platform-types";
type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams;
const DEFAULT_PLATFORM_PARAMS = {
platformClientId: "",
platformCancelUrl: "",
platformRescheduleUrl: "",
platformBookingUrl: "",
arePlatformEmailsEnabled: false,
platformBookingLocation: undefined,
};
type OAuthRequestParams = {
platformClientId: string;
platformRescheduleUrl: string;
platformCancelUrl: string;
platformBookingUrl: string;
platformBookingLocation?: string;
arePlatformEmailsEnabled: boolean;
};
export enum Frequency {
"YEARLY",
"MONTHLY",
"WEEKLY",
"DAILY",
"HOURLY",
"MINUTELY",
"SECONDLY",
}
const recurringEventSchema = z.object({
dtstart: z.string().optional(),
interval: z.number().int(),
count: z.number().int(),
freq: z.nativeEnum(Frequency),
until: z.string().optional(),
});
@Injectable()
export class InputBookingsService_2024_08_13 {
private readonly logger = new Logger("InputBookingsService_2024_08_13");
constructor(
private readonly oAuthFlowService: OAuthFlowService,
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly config: ConfigService,
private readonly apiKeyRepository: ApiKeyRepository
) {}
async createBookingRequest(
request: Request,
body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13
): Promise<BookingRequest> {
const bodyTransformed = await this.transformInputCreateBooking(body);
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
const newRequest = { ...request };
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
const oAuthParams = oAuthClientId
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
const location = request.body.location || request.body.meetingUrl;
Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
return newRequest as unknown as BookingRequest;
}
async transformInputCreateBooking(inputBooking: CreateBookingInput_2024_08_13) {
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
inputBooking.eventTypeId
);
if (!eventType) {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
}
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(
inputBooking.attendee.timeZone
);
const endTime = startTime.plus({ minutes: eventType.length });
return {
start: startTime.toISO(),
end: endTime.toISO(),
eventTypeId: inputBooking.eventTypeId,
timeZone: inputBooking.attendee.timeZone,
language: inputBooking.attendee.language || "en",
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
// metadata: inputBooking.metadata || {},
metadata: {},
hasHashedBookingLink: false,
guests: inputBooking.guests,
// note(Lauris): responses with name and email are required by the handleNewBooking
responses: inputBooking.bookingFieldsResponses
? {
...inputBooking.bookingFieldsResponses,
name: inputBooking.attendee.name,
email: inputBooking.attendee.email,
}
: { name: inputBooking.attendee.name, email: inputBooking.attendee.email },
};
}
async createRecurringBookingRequest(
request: Request,
body: CreateRecurringBookingInput_2024_08_13
): Promise<BookingRequest> {
// note(Lauris): update to this.transformInputCreate when rescheduling is implemented
const bodyTransformed = await this.transformInputCreateRecurringBooking(body);
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
const newRequest = { ...request };
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
const oAuthParams = oAuthClientId
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
const location = request.body.location || request.body.meetingUrl;
Object.assign(newRequest, {
userId,
...oAuthParams,
platformBookingLocation: location,
noEmail: !oAuthParams.arePlatformEmailsEnabled,
});
newRequest.body = bodyTransformed.map((event) => ({
...event,
}));
return newRequest as unknown as BookingRequest;
}
async transformInputCreateRecurringBooking(inputBooking: CreateRecurringBookingInput_2024_08_13) {
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
inputBooking.eventTypeId
);
if (!eventType) {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
}
if (!eventType.recurringEvent) {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
}
const occurrence = recurringEventSchema.parse(eventType.recurringEvent);
const repeatsEvery = occurrence.interval;
if (inputBooking.recurrenceCount && inputBooking.recurrenceCount > occurrence.count) {
throw new BadRequestException(
"Provided recurrence count is higher than the event type's recurring event count."
);
}
const repeatsTimes = inputBooking.recurrenceCount || occurrence.count;
// note(Lauris): timeBetween 0=yearly, 1=monthly and 2=weekly
const timeBetween = occurrence.freq;
const events = [];
const recurringEventId = uuidv4();
let startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(
inputBooking.attendee.timeZone
);
for (let i = 0; i < repeatsTimes; i++) {
const endTime = startTime.plus({ minutes: eventType.length });
events.push({
start: startTime.toISO(),
end: endTime.toISO(),
eventTypeId: inputBooking.eventTypeId,
recurringEventId,
timeZone: inputBooking.attendee.timeZone,
language: inputBooking.attendee.language || "en",
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
// metadata: inputBooking.metadata || {},
metadata: {},
hasHashedBookingLink: false,
guests: inputBooking.guests,
// note(Lauris): responses with name and email are required by the handleNewBooking
responses: inputBooking.bookingFieldsResponses
? {
...inputBooking.bookingFieldsResponses,
name: inputBooking.attendee.name,
email: inputBooking.attendee.email,
}
: { name: inputBooking.attendee.name, email: inputBooking.attendee.email },
schedulingType: eventType.schedulingType,
});
switch (timeBetween) {
case 0: // Yearly
startTime = startTime.plus({ years: repeatsEvery });
break;
case 1: // Monthly
startTime = startTime.plus({ months: repeatsEvery });
break;
case 2: // Weekly
startTime = startTime.plus({ weeks: repeatsEvery });
break;
default:
throw new Error("Unsupported timeBetween value");
}
}
return events;
}
async createRescheduleBookingRequest(
request: Request,
bookingUid: string,
body: RescheduleBookingInput_2024_08_13
): Promise<BookingRequest> {
const bodyTransformed = await this.transformInputRescheduleBooking(bookingUid, body);
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
const newRequest = { ...request };
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
const oAuthParams = oAuthClientId
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
const location = await this.getRescheduleBookingLocation(bookingUid);
Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
return newRequest as unknown as BookingRequest;
}
async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) {
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
}
if (!booking.eventTypeId) {
throw new NotFoundException(`Booking with uid=${bookingUid} is missing event type`);
}
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(booking.eventTypeId);
if (!eventType) {
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
}
const bookingResponses = bookingResponsesSchema.parse(booking.responses);
const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email);
if (!attendee) {
throw new NotFoundException(
`Attendee with e-mail ${bookingResponses.email} for booking with uid=${bookingUid} not found`
);
}
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone);
const endTime = startTime.plus({ minutes: eventType.length });
return {
start: startTime.toISO(),
end: endTime.toISO(),
eventTypeId: eventType.id,
timeZone: attendee.timeZone,
language: attendee.locale,
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
// metadata: booking.metadata || {},
metadata: {},
hasHashedBookingLink: false,
guests: bookingResponses.guests,
responses: { ...bookingResponses, rescheduledReason: inputBooking.reschedulingReason },
rescheduleUid: bookingUid,
};
}
async getRescheduleBookingLocation(rescheduleBookingUid: string) {
const booking = await this.bookingsRepository.getByUid(rescheduleBookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${rescheduleBookingUid} not found`);
}
return booking.location;
}
private async createBookingRequestOwnerId(req: Request): Promise<number | undefined> {
try {
const bearerToken = req.get("Authorization")?.replace("Bearer ", "");
if (bearerToken) {
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = hashAPIKey(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
return keyData?.userId;
} else {
// Access Token
return this.oAuthFlowService.getOwnerId(bearerToken);
}
}
} catch (err) {
this.logger.error(err);
}
}
private async createBookingRequestOAuthClientParams(clientId: string) {
const params = DEFAULT_PLATFORM_PARAMS;
try {
const client = await this.oAuthClientRepository.getOAuthClient(clientId);
if (client) {
params.platformClientId = clientId;
params.platformCancelUrl = client.bookingCancelRedirectUri ?? "";
params.platformRescheduleUrl = client.bookingRescheduleRedirectUri ?? "";
params.platformBookingUrl = client.bookingRedirectUri ?? "";
params.arePlatformEmailsEnabled = client.areEmailsEnabled ?? false;
}
return params;
} catch (err) {
this.logger.error(err);
return params;
}
}
transformGetBookingsFilters(queryParams: GetBookingsInput_2024_08_13) {
return {
attendeeEmail: queryParams.attendeeEmail,
attendeeName: queryParams.attendeeName,
afterStartDate: queryParams.afterStart,
beforeEndDate: queryParams.beforeEnd,
teamIds: queryParams.teamsIds || (queryParams.teamId ? [queryParams.teamId] : undefined),
eventTypeIds:
queryParams.eventTypeIds || (queryParams.eventTypeId ? [queryParams.eventTypeId] : undefined),
};
}
transformGetBookingsSort(queryParams: GetBookingsInput_2024_08_13) {
if (!queryParams.sortStart && !queryParams.sortEnd && !queryParams.sortCreated) {
return undefined;
}
return {
sortStart: queryParams.sortStart,
sortEnd: queryParams.sortEnd,
sortCreated: queryParams.sortCreated,
};
}
async createCancelBookingRequest(
request: Request,
bookingUid: string,
body: CancelBookingInput_2024_08_13
): Promise<BookingRequest> {
const bodyTransformed = await this.transformInputCancelBooking(bookingUid, body);
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
const newRequest = { ...request };
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
const oAuthParams = oAuthClientId
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
Object.assign(newRequest, { userId, ...oAuthParams });
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
return newRequest as unknown as BookingRequest;
}
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
let allRemainingBookings = false;
let uid = bookingUid;
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(
bookingUid
);
if (recurringBooking.length) {
// note(Lauirs): this means that bookingUid is equal to recurringEventId on individual bookings of recurring one aka main recurring event
allRemainingBookings = true;
// note(Lauirs): we need to set uid as one of the individual recurring ids, not the main recurring event id
uid = recurringBooking[0].uid;
}
return {
uid,
cancellationReason: inputBooking.cancellationReason,
allRemainingBookings,
};
}
transformInputMarkAbsentBooking(inputBooking: MarkAbsentBookingInput_2024_08_13) {
return {
noShowHost: inputBooking.host,
attendees: inputBooking.attendees?.map((attendee) => ({
email: attendee.email,
noShow: attendee.absent,
})),
};
}
}
@@ -0,0 +1,135 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { Injectable } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import { DateTime } from "luxon";
import { z } from "zod";
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
import { Booking } from "@calcom/prisma/client";
export const bookingResponsesSchema = z
.object({
email: z.string(),
name: z.string(),
guests: z.array(z.string()).optional(),
rescheduledReason: z.string().optional(),
})
.passthrough();
type DatabaseBooking = Booking & {
eventType: {
id: number;
slug: string;
} | null;
attendees: {
name: string;
email: string;
timeZone: string;
locale: string | null;
noShow: boolean | null;
}[];
} & { user: { id: number; name: string | null; email: string } | null };
@Injectable()
export class OutputBookingsService_2024_08_13 {
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
getOutputBooking(databaseBooking: DatabaseBooking) {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
const booking = {
id: databaseBooking.id,
uid: databaseBooking.uid,
title: databaseBooking.title,
description: databaseBooking.description,
hosts: [databaseBooking.user],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
duration,
eventType: databaseBooking.eventType,
// note(Lauris): eventTypeId is deprecated
eventTypeId: databaseBooking.eventTypeId,
attendees: databaseBooking.attendees.map((attendee) => ({
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: attendee.locale,
absent: !!attendee.noShow,
})),
guests: bookingResponses.guests,
location: databaseBooking.location,
// note(Lauris): meetingUrl is deprecated
meetingUrl: databaseBooking.location,
absentHost: !!databaseBooking.noShowHost,
};
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
bookingTransformed.bookingFieldsResponses = bookingResponses;
return bookingTransformed;
}
async getOutputRecurringBookings(databaseBookings: DatabaseBooking[]) {
const transformed = [];
for (const booking of databaseBookings) {
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
if (!databaseBooking) {
throw new Error(`Booking with id=${booking.id} was not found in the database`);
}
transformed.push(this.getOutputRecurringBooking(databaseBooking));
}
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
}
getOutputRecurringBooking(databaseBooking: DatabaseBooking) {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
const booking = {
id: databaseBooking.id,
uid: databaseBooking.uid,
title: databaseBooking.title,
description: databaseBooking.description,
hosts: [databaseBooking.user],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
duration,
eventType: databaseBooking.eventType,
// note(Lauris): eventTypeId is deprecated
eventTypeId: databaseBooking.eventTypeId,
attendees: databaseBooking.attendees.map((attendee) => ({
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: attendee.locale,
absent: !!attendee.noShow,
})),
guests: bookingResponses.guests,
location: databaseBooking.location,
// note(Lauris): meetingUrl is deprecated
meetingUrl: databaseBooking.location,
recurringBookingUid: databaseBooking.recurringEventId,
absentHost: !!databaseBooking.noShowHost,
bookingFieldsResponses: databaseBooking.responses,
};
return plainToClass(RecurringBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
}
}
@@ -1,3 +1,4 @@
import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
import { Request } from "express";
import { ApiResponse } from "@calcom/platform-types";
@@ -12,6 +13,16 @@ export interface CredentialSyncCalendarApp {
check(userId: number): Promise<ApiResponse>;
}
export interface ICSFeedCalendarApp {
save(
userId: number,
userEmail: string,
urls: string[],
readonly?: boolean
): Promise<CreateIcsFeedOutputResponseDto>;
check(userId: number): Promise<ApiResponse>;
}
export interface OAuthCalendarApp extends CalendarApp {
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
}
@@ -3,6 +3,7 @@ import { CalendarsController } from "@/ee/calendars/controllers/calendars.contro
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
@@ -20,6 +21,7 @@ import { Module } from "@nestjs/common";
OutlookService,
GoogleCalendarService,
AppleCalendarService,
IcsFeedService,
SelectedCalendarsRepository,
AppsRepository,
CalendarsRepository,
@@ -1,5 +1,6 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateIcsFeedOutput, CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
@@ -18,6 +19,7 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
import { IcsCalendarServiceMock } from "test/mocks/ics-calendar-service-mock";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
@@ -27,6 +29,8 @@ import {
GOOGLE_CALENDAR_ID,
} from "@calcom/platform-constants";
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
import { ICS_CALENDAR } from "@calcom/platform-constants/apps";
import { IcsFeedCalendarService } from "@calcom/platform-libraries";
const CLIENT_REDIRECT_URI = "http://localhost:5555";
@@ -45,6 +49,7 @@ describe("Platform Calendars Endpoints", () => {
let googleCalendarCredentials: Credential;
let accessTokenSecret: string;
let refreshTokenSecret: string;
let icsCalendarCredentials: CreateIcsFeedOutput;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
@@ -192,6 +197,52 @@ describe("Platform Calendars Endpoints", () => {
.expect(200);
});
it(`/POST/v2/calendars/${ICS_CALENDAR}/save with access token should fail to create a new ics feed calendar credentials with invalid urls`, async () => {
const body = {
urls: ["https://cal.com/ics/feed.ics", "https://not-an-ics-feed.com"],
readOnly: false,
};
await request(app.getHttpServer())
.post(`/v2/calendars/${ICS_CALENDAR}/save`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.send(body)
.expect(400);
});
it(`/POST/v2/calendars/${ICS_CALENDAR}/save with access token should create a new ics feed calendar credentials`, async () => {
const body = {
urls: ["https://cal.com/ics/feed.ics"],
readOnly: false,
};
jest
.spyOn(IcsFeedCalendarService.prototype, "listCalendars")
.mockImplementation(IcsCalendarServiceMock.prototype.listCalendars);
await request(app.getHttpServer())
.post(`/v2/calendars/${ICS_CALENDAR}/save`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: CreateIcsFeedOutputResponseDto = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.userId).toBeDefined();
expect(responseBody.data.userId).toEqual(user.id);
expect(responseBody.data.id).toBeDefined();
icsCalendarCredentials = responseBody.data;
});
});
it(`/GET/v2/calendars/${ICS_CALENDAR}/check with access token`, async () => {
await request(app.getHttpServer())
.get(`/v2/calendars/${ICS_CALENDAR}/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
});
it.skip(`/POST/v2/calendars/${OFFICE_365_CALENDAR}/disconnect: it should respond with a 201 returning back the user deleted calendar credentials`, async () => {
const body = {
id: 10,
@@ -217,6 +268,7 @@ describe("Platform Calendars Endpoints", () => {
await teamRepositoryFixture.delete(organization.id);
await credentialsRepositoryFixture.delete(office365Credentials.id);
await credentialsRepositoryFixture.delete(googleCalendarCredentials.id);
await credentialsRepositoryFixture.delete(icsCalendarCredentials.id);
await userRepositoryFixture.deleteByEmail(user.email);
await app.close();
});
@@ -1,4 +1,6 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CreateIcsFeedInputDto } from "@/ee/calendars/input/create-ics.input";
import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
import { DeleteCalendarCredentialsInputBodyDto } from "@/ee/calendars/input/delete-calendar-credentials.input";
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
@@ -9,6 +11,7 @@ import {
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
@@ -31,7 +34,7 @@ import {
Post,
Body,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { plainToClass } from "class-transformer";
import { Request } from "express";
@@ -44,6 +47,7 @@ import {
GOOGLE_CALENDAR,
OFFICE_365_CALENDAR,
APPLE_CALENDAR,
CREDENTIAL_CALENDARS,
} from "@calcom/platform-constants";
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
@@ -58,11 +62,31 @@ export class CalendarsController {
private readonly outlookService: OutlookService,
private readonly googleCalendarService: GoogleCalendarService,
private readonly appleCalendarService: AppleCalendarService,
private readonly icsFeedService: IcsFeedService,
private readonly calendarsRepository: CalendarsRepository
) {}
@Post("/ics-feed/save")
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Save an ICS feed" })
async createIcsFeed(
@GetUser("id") userId: number,
@GetUser("email") userEmail: string,
@Body() body: CreateIcsFeedInputDto
): Promise<CreateIcsFeedOutputResponseDto> {
return await this.icsFeedService.save(userId, userEmail, body.urls, body.readOnly);
}
@Get("/ics-feed/check")
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Check an ICS feed" })
async checkIcsFeed(@GetUser("id") userId: number): Promise<ApiResponse> {
return await this.icsFeedService.check(userId);
}
@UseGuards(ApiAuthGuard)
@Get("/busy-times")
@ApiOperation({ summary: "Get busy times" })
async getBusyTimes(
@Query() queryParams: CalendarBusyTimesInput,
@GetUser() user: UserWithProfile
@@ -91,6 +115,7 @@ export class CalendarsController {
@Get("/")
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Get all calendars" })
async getCalendars(@GetUser("id") userId: number): Promise<ConnectedCalendarsOutput> {
const calendars = await this.calendarsService.getCalendars(userId);
@@ -103,6 +128,7 @@ export class CalendarsController {
@UseGuards(ApiAuthGuard)
@Get("/:calendar/connect")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get connect URL" })
async redirect(
@Req() req: Request,
@Headers("Authorization") authorization: string,
@@ -125,6 +151,7 @@ export class CalendarsController {
@Get("/:calendar/save")
@HttpCode(HttpStatus.OK)
@Redirect(undefined, 301)
@ApiOperation({ summary: "Save a calendar" })
async save(
@Query("state") state: string,
@Query("code") code: string,
@@ -154,6 +181,7 @@ export class CalendarsController {
@UseGuards(ApiAuthGuard)
@Post("/:calendar/credentials")
@ApiOperation({ summary: "Sync credentials" })
async syncCredentials(
@GetUser() user: User,
@Param("calendar") calendar: string,
@@ -167,7 +195,7 @@ export class CalendarsController {
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
CREDENTIAL_CALENDARS.join(", ")
);
}
}
@@ -176,6 +204,7 @@ export class CalendarsController {
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
@Permissions([APPS_READ])
@ApiOperation({ summary: "Check a calendar connection" })
async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise<ApiResponse> {
switch (calendar) {
case OFFICE_365_CALENDAR:
@@ -195,6 +224,7 @@ export class CalendarsController {
@UseGuards(ApiAuthGuard)
@Post("/:calendar/disconnect")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Disconnect a calendar" })
async deleteCalendarCredentials(
@Param("calendar") calendar: string,
@Body() body: DeleteCalendarCredentialsInputBodyDto,
@@ -0,0 +1,62 @@
import { ApiProperty } from "@nestjs/swagger";
import {
ArrayNotEmpty,
IsBoolean,
IsOptional,
Validate,
ValidatorConstraint,
ValidatorConstraintInterface,
} from "class-validator";
import { IsNotEmpty, IsArray } from "class-validator";
// Custom constraint to validate ICS URLs
@ValidatorConstraint({ async: false })
export class IsICSUrlConstraint implements ValidatorConstraintInterface {
validate(url: unknown) {
if (typeof url !== "string") return false;
// Check if it's a valid URL and ends with .ics
try {
const urlObject = new URL(url);
return (
(urlObject.protocol === "http:" || urlObject.protocol === "https:") &&
urlObject.pathname.endsWith(".ics")
);
} catch (error) {
return false;
}
}
defaultMessage() {
return "The URL must be a valid ICS URL (ending with .ics)";
}
}
export class CreateIcsFeedInputDto {
@ApiProperty({
example: ["https://cal.com/ics/feed.ics", "http://cal.com/ics/feed.ics"],
description: "An array of ICS URLs",
type: "array",
items: {
type: "string",
example: "https://cal.com/ics/feed.ics",
},
required: true,
})
@IsArray()
@ArrayNotEmpty()
@IsNotEmpty({ each: true })
@Validate(IsICSUrlConstraint, { each: true }) // Apply the custom validator to each element in the array
urls!: string[];
@IsBoolean()
@ApiProperty({
example: false,
description: "Whether to allowing writing to the calendar or not",
type: "boolean",
required: false,
default: true,
})
@IsOptional()
readOnly?: boolean = true;
}
@@ -0,0 +1,57 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsEnum, IsInt, IsBoolean } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class CreateIcsFeedOutput {
@IsInt()
@Expose()
@ApiProperty({ example: 1234567890, description: "The id of the calendar credential" })
readonly id!: number;
@IsString()
@Expose()
@ApiProperty({ example: "ics-feed_calendar", description: "The type of the calendar" })
readonly type!: string;
@IsInt()
@Expose()
@ApiProperty({
example: 1234567890,
description: "The user id of the user that created the calendar",
type: "integer",
})
readonly userId!: number | null;
@IsInt()
@Expose()
@ApiProperty({
example: 1234567890,
nullable: true,
description: "The team id of the user that created the calendar",
type: "integer",
})
readonly teamId!: number | null;
@IsString()
@Expose()
@ApiProperty({ example: "ics-feed", description: "The slug of the calendar" })
readonly appId!: string | null;
@IsBoolean()
@Expose()
@ApiProperty({ example: false, description: "Whether the calendar credentials are valid or not" })
readonly invalid!: boolean | null;
}
export class CreateIcsFeedOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => CreateIcsFeedOutput)
data!: CreateIcsFeedOutput;
}
@@ -198,7 +198,7 @@ class DestinationCalendar {
integrationTitle?: string;
}
class ConnectedCalendarsData {
export class ConnectedCalendarsData {
@ValidateNested({ each: true })
@IsArray()
connectedCalendars!: ConnectedCalendar[];
@@ -4,7 +4,6 @@ import {
CredentialsRepository,
CredentialsWithUserEmail,
} from "@/modules/credentials/credentials.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { UsersRepository } from "@/modules/users/users.repository";
@@ -14,7 +13,6 @@ import {
UnauthorizedException,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { User } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { DateTime } from "luxon";
@@ -34,9 +32,7 @@ export class CalendarsService {
private readonly credentialsRepository: CredentialsRepository,
private readonly appsRepository: AppsRepository,
private readonly calendarsRepository: CalendarsRepository,
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private readonly config: ConfigService,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
) {}
@@ -42,7 +42,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
}
@@ -0,0 +1,103 @@
import { ICSFeedCalendarApp } from "@/ee/calendars/calendars.interface";
import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { BadRequestException, UnauthorizedException, Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { SUCCESS_STATUS, ICS_CALENDAR_TYPE, ICS_CALENDAR } from "@calcom/platform-constants";
import { symmetricEncrypt, IcsFeedCalendarService } from "@calcom/platform-libraries";
@Injectable()
export class IcsFeedService implements ICSFeedCalendarApp {
constructor(
private readonly calendarsService: CalendarsService,
private readonly credentialRepository: CredentialsRepository
) {}
private logger = new Logger("IcsFeedService");
async save(
userId: number,
userEmail: string,
urls: string[],
readonly = true
): Promise<CreateIcsFeedOutputResponseDto> {
const data = {
type: ICS_CALENDAR_TYPE,
ICS_CALENDAR,
key: symmetricEncrypt(
JSON.stringify({ urls, skipWriting: readonly }),
process.env.CALENDSO_ENCRYPTION_KEY || ""
),
userId: userId,
teamId: null,
appId: ICS_CALENDAR,
invalid: false,
};
try {
const dav = new IcsFeedCalendarService({
id: 0,
...data,
user: { email: userEmail },
});
const listedCals = await dav.listCalendars();
if (listedCals.length !== urls.length) {
throw new BadRequestException(
`Listed cals and URLs mismatch: ${listedCals.length} vs. ${urls.length}`
);
}
const credential = await this.credentialRepository.upsertAppCredential(
ICS_CALENDAR_TYPE,
data.key,
userId
);
return {
status: SUCCESS_STATUS,
data: {
id: credential.id,
type: credential.type,
userId: credential.userId,
teamId: credential.teamId,
appId: credential.appId,
invalid: credential.invalid,
},
};
} catch (e) {
this.logger.error("Could not add ICS feeds", e);
throw new BadRequestException("Could not add ICS feeds, try using private ics feed.");
}
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const icsFeedCredentials = await this.credentialRepository.getByTypeAndUserId(ICS_CALENDAR_TYPE, userId);
if (!icsFeedCredentials) {
throw new BadRequestException("Credentials for Ics Feed calendar not found.");
}
if (icsFeedCredentials.invalid) {
throw new BadRequestException("Invalid Ics Feed credentials.");
}
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
const icsCalendar = connectedCalendars.find(
(cal: { integration: { type: string } }) => cal.integration.type === ICS_CALENDAR_TYPE
);
if (!icsCalendar) {
throw new UnauthorizedException("Ics Feed not connected.");
}
if (icsCalendar.error?.message) {
throw new UnauthorizedException(icsCalendar.error?.message);
}
return {
status: SUCCESS_STATUS,
};
}
}
@@ -36,7 +36,7 @@ import {
InternalServerErrorException,
ParseIntPipe,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
@@ -47,7 +47,7 @@ import { PrismaClient } from "@calcom/prisma";
version: [VERSION_2024_04_15, VERSION_2024_06_11],
})
@UseGuards(PermissionsGuard)
@DocsTags("Event types")
@DocsExcludeController(true)
export class EventTypesController_2024_04_15 {
constructor(
private readonly eventTypesService: EventTypesService_2024_04_15,
@@ -15,6 +15,7 @@ import {
updateEventType,
EventTypesPublic,
getEventTypesPublic,
systemBeforeFieldEmail,
} from "@calcom/platform-libraries";
import { EventType } from "@calcom/prisma/client";
@@ -127,8 +128,17 @@ export class EventTypesService_2024_04_15 {
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_04_15, user: UserWithProfile) {
this.checkCanUpdateEventType(user.id, eventTypeId);
const eventTypeUser = await this.getUserToUpdateEvent(user);
const bookingFields = [...(body.bookingFields || [])];
if (
!bookingFields.find((field) => field.type === "email") &&
!bookingFields.find((field) => field.type === "phone")
) {
bookingFields.push(systemBeforeFieldEmail);
}
await updateEventType({
input: { id: eventTypeId, ...body },
input: { id: eventTypeId, ...body, bookingFields },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -1,16 +1,44 @@
export const DEFAULT_EVENT_TYPES = {
thirtyMinutes: { length: 30, slug: "thirty-minutes", title: "30 Minutes" },
thirtyMinutesVideo: {
length: 30,
slug: "thirty-minutes-video",
title: "30 Minutes",
locations: [{ type: "integrations:daily" }],
},
sixtyMinutes: { length: 60, slug: "sixty-minutes", title: "60 Minutes" },
sixtyMinutesVideo: {
length: 60,
slug: "sixty-minutes-video",
title: "60 Minutes",
locations: [{ type: "integrations:daily" }],
},
import { IntegrationLocation } from "@calcom/lib";
type BaseEventType = {
length: number;
slug: string;
title: string;
};
type EventTypeWithLocation = BaseEventType & {
locations: IntegrationLocation[];
};
const thirtyMinutes: BaseEventType = {
length: 30,
slug: "thirty-minutes",
title: "30 Minutes",
};
const thirtyMinutesVideo: EventTypeWithLocation = {
length: 30,
slug: "thirty-minutes-video",
title: "30 Minutes",
locations: [{ type: "integrations:daily" }],
};
const sixtyMinutes: BaseEventType = {
length: 60,
slug: "sixty-minutes",
title: "60 Minutes",
};
const sixtyMinutesVideo: EventTypeWithLocation = {
length: 60,
slug: "sixty-minutes-video",
title: "60 Minutes",
locations: [{ type: "integrations:daily" }],
};
export const DEFAULT_EVENT_TYPES = {
thirtyMinutes,
thirtyMinutesVideo,
sixtyMinutes,
sixtyMinutesVideo,
};
@@ -20,7 +20,13 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
import { BookingWindowPeriodInputTypeEnum_2024_06_14, FrequencyInput } from "@calcom/platform-enums";
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookerLayoutsInputEnum_2024_06_14,
ConfirmationPolicyEnum,
NoticeThresholdUnitEnum,
FrequencyInput,
} from "@calcom/platform-enums";
import {
ApiSuccessResponse,
CreateEventTypeInput_2024_06_14,
@@ -230,11 +236,37 @@ describe("Event types Endpoints", () => {
value: 30,
rolling: true,
},
bookerLayouts: {
enabledLayouts: [
BookerLayoutsInputEnum_2024_06_14.column,
BookerLayoutsInputEnum_2024_06_14.month,
BookerLayoutsInputEnum_2024_06_14.week,
],
defaultLayout: BookerLayoutsInputEnum_2024_06_14.month,
},
confirmationPolicy: {
type: ConfirmationPolicyEnum.TIME,
noticeThreshold: {
count: 60,
unit: NoticeThresholdUnitEnum.MINUTES,
},
blockUnconfirmedBookingsInBooker: true,
},
recurrence: {
frequency: FrequencyInput.weekly,
interval: 2,
occurrences: 10,
disabled: false,
},
requiresBookerEmailVerification: false,
hideCalendarNotes: false,
hideCalendarEventDetails: false,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
lightThemeHex: "#fafafa",
},
customName: `{Event type title} between {Organiser} and {Scheduler}`,
};
return request(app.getHttpServer())
@@ -250,7 +282,6 @@ describe("Event types Endpoints", () => {
expect(createdEventType.description).toEqual(body.description);
expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes);
expect(createdEventType.locations).toEqual(body.locations);
expect(createdEventType.bookingFields).toEqual(body.bookingFields);
expect(createdEventType.ownerId).toEqual(user.id);
expect(createdEventType.scheduleId).toEqual(firstSchedule.id);
expect(createdEventType.bookingLimitsCount).toEqual(body.bookingLimitsCount);
@@ -258,11 +289,346 @@ describe("Event types Endpoints", () => {
expect(createdEventType.bookingLimitsDuration).toEqual(body.bookingLimitsDuration);
expect(createdEventType.offsetStart).toEqual(body.offsetStart);
expect(createdEventType.bookingWindow).toEqual(body.bookingWindow);
expect(createdEventType.bookerLayouts).toEqual(body.bookerLayouts);
expect(createdEventType.confirmationPolicy).toEqual(body.confirmationPolicy);
expect(createdEventType.recurrence).toEqual(body.recurrence);
expect(createdEventType.customName).toEqual(body.customName);
expect(createdEventType.requiresBookerEmailVerification).toEqual(
body.requiresBookerEmailVerification
);
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
body.lockTimeZoneToggleOnBookingPage
);
expect(createdEventType.color).toEqual(body.color);
const responseBookingFields = body.bookingFields || [];
const expectedBookingFields = [
{ isDefault: true, required: true, slug: "name", type: "name" },
{ isDefault: true, required: true, slug: "email", type: "email" },
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
...responseBookingFields.map((field) => ({ isDefault: false, ...field })),
];
expect(createdEventType.bookingFields).toEqual(expectedBookingFields);
eventType = responseBody.data;
});
});
it(`/GET/even-types by username`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${username}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(1);
const fetchedEventType = responseBody.data?.[0];
expect(fetchedEventType?.id).toEqual(eventType.id);
expect(fetchedEventType?.title).toEqual(eventType.title);
expect(fetchedEventType?.description).toEqual(eventType.description);
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.ownerId).toEqual(user.id);
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
expect(fetchedEventType.bookerLayouts).toEqual(eventType.bookerLayouts);
expect(fetchedEventType.confirmationPolicy).toEqual(eventType.confirmationPolicy);
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
expect(fetchedEventType.customName).toEqual(eventType.customName);
expect(fetchedEventType.requiresBookerEmailVerification).toEqual(
eventType.requiresBookerEmailVerification
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
expect(fetchedEventType.color).toEqual(eventType.color);
});
it("should return an error when creating an event type with seats enabled and multiple locations", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 2",
slug: "coding-class-2",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
{
type: "phone",
phone: "+37120993151",
public: true,
},
],
scheduleId: firstSchedule.id,
seats: {
seatsPerTimeSlot: 4,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
};
await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(400);
});
it("should return an error when trying to enable seats for an event type with multiple locations", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 3",
slug: "coding-class-3",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
{
type: "phone",
phone: "+37120993151",
public: true,
},
],
scheduleId: firstSchedule.id,
};
const createResponse = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201);
const createdEventType = createResponse.body.data;
expect(createdEventType).toMatchObject({
id: expect.any(Number),
title: body.title,
description: body.description,
lengthInMinutes: body.lengthInMinutes,
locations: body.locations,
scheduleId: firstSchedule.id,
});
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send({
seats: {
seatsPerTimeSlot: 4,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
})
.expect(400);
});
it("should return an error when creating an event type with seats enabled and confirmationPolicy enabled", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 4",
slug: "coding-class-4",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
scheduleId: firstSchedule.id,
confirmationPolicy: {
type: ConfirmationPolicyEnum.ALWAYS,
blockUnconfirmedBookingsInBooker: false,
},
seats: {
seatsPerTimeSlot: 4,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
};
await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(400);
});
it("should return an error when trying to enable seats for an event type with confirmationPolicy enabled", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 5",
slug: "coding-class-5",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
confirmationPolicy: {
type: ConfirmationPolicyEnum.ALWAYS,
blockUnconfirmedBookingsInBooker: false,
},
scheduleId: firstSchedule.id,
};
const createResponse = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201);
const createdEventType = createResponse.body.data;
console.log("createdEventType: ", createdEventType);
expect(createdEventType).toMatchObject({
id: expect.any(Number),
title: body.title,
description: body.description,
lengthInMinutes: body.lengthInMinutes,
confirmationPolicy: body.confirmationPolicy,
scheduleId: firstSchedule.id,
});
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send({
seats: {
seatsPerTimeSlot: 4,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
})
.expect(400);
});
it("should return an error when trying to set multiple locations for an event type with seats enabled", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 6",
slug: "coding-class-6",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
scheduleId: firstSchedule.id,
seats: {
seatsPerTimeSlot: 4,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
};
const createResponse = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201);
const createdEventType = createResponse.body.data;
expect(createdEventType).toMatchObject({
id: expect.any(Number),
title: body.title,
description: body.description,
lengthInMinutes: body.lengthInMinutes,
seats: body.seats,
scheduleId: firstSchedule.id,
});
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send({
locations: [
{
type: "integration",
integration: "cal-video",
},
{
type: "phone",
phone: "+37120993151",
public: true,
},
],
})
.expect(400);
});
it("should return an error when creating an event type with confirmationPolicy enabled and seats enabled", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 7",
slug: "coding-class-7",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
confirmationPolicy: {
type: ConfirmationPolicyEnum.ALWAYS,
blockUnconfirmedBookingsInBooker: false,
},
scheduleId: firstSchedule.id,
seats: {
seatsPerTimeSlot: 4,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
};
await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(400);
});
it("should return an error when trying to enable confirmationPolicy for an event type with seats enabled", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 8",
slug: "coding-class-8",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
seats: {
seatsPerTimeSlot: 4,
showAttendeeInfo: true,
showAvailabilityCount: true,
},
scheduleId: firstSchedule.id,
};
const createResponse = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201);
const createdEventType = createResponse.body.data;
expect(createdEventType).toMatchObject({
id: expect.any(Number),
title: body.title,
description: body.description,
lengthInMinutes: body.lengthInMinutes,
seats: body.seats,
scheduleId: firstSchedule.id,
});
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send({
confirmationPolicy: {
type: ConfirmationPolicyEnum.ALWAYS,
blockUnconfirmedBookingsInBooker: false,
},
})
.expect(400);
});
it("should update event type", async () => {
const newTitle = "Coding class in Italian!";
@@ -284,11 +650,33 @@ describe("Event types Endpoints", () => {
value: 40,
rolling: false,
},
bookerLayouts: {
enabledLayouts: [
BookerLayoutsInputEnum_2024_06_14.column,
BookerLayoutsInputEnum_2024_06_14.month,
BookerLayoutsInputEnum_2024_06_14.week,
],
defaultLayout: BookerLayoutsInputEnum_2024_06_14.month,
},
confirmationPolicy: {
type: ConfirmationPolicyEnum.ALWAYS,
blockUnconfirmedBookingsInBooker: false,
},
recurrence: {
frequency: FrequencyInput.monthly,
interval: 4,
occurrences: 10,
disabled: false,
},
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
lightThemeHex: "#fafafa",
},
customName: `{Event type title} betweennnnnnnnnnn {Organiser} and {Scheduler}`,
};
return request(app.getHttpServer())
@@ -314,7 +702,19 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.bookingLimitsDuration).toEqual(body.bookingLimitsDuration);
expect(updatedEventType.offsetStart).toEqual(body.offsetStart);
expect(updatedEventType.bookingWindow).toEqual(body.bookingWindow);
expect(updatedEventType.bookerLayouts).toEqual(body.bookerLayouts);
expect(updatedEventType.confirmationPolicy).toEqual(body.confirmationPolicy);
expect(updatedEventType.recurrence).toEqual(body.recurrence);
expect(updatedEventType.customName).toEqual(body.customName);
expect(updatedEventType.requiresBookerEmailVerification).toEqual(
body.requiresBookerEmailVerification
);
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
body.lockTimeZoneToggleOnBookingPage
);
expect(updatedEventType.color).toEqual(body.color);
eventType.title = newTitle;
eventType.scheduleId = secondSchedule.id;
@@ -323,7 +723,15 @@ describe("Event types Endpoints", () => {
eventType.bookingLimitsDuration = updatedEventType.bookingLimitsDuration;
eventType.offsetStart = updatedEventType.offsetStart;
eventType.bookingWindow = updatedEventType.bookingWindow;
eventType.bookerLayouts = updatedEventType.bookerLayouts;
eventType.confirmationPolicy = updatedEventType.confirmationPolicy;
eventType.recurrence = updatedEventType.recurrence;
eventType.customName = updatedEventType.customName;
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
eventType.color = updatedEventType.color;
});
});
@@ -364,38 +772,19 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
expect(fetchedEventType.bookerLayouts).toEqual(eventType.bookerLayouts);
expect(fetchedEventType.confirmationPolicy).toEqual(eventType.confirmationPolicy);
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
});
it(`/GET/even-types by username`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${username}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(1);
const fetchedEventType = responseBody.data?.[0];
expect(fetchedEventType?.id).toEqual(eventType.id);
expect(fetchedEventType?.title).toEqual(eventType.title);
expect(fetchedEventType?.description).toEqual(eventType.description);
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.ownerId).toEqual(user.id);
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
expect(fetchedEventType.customName).toEqual(eventType.customName);
expect(fetchedEventType.requiresBookerEmailVerification).toEqual(
eventType.requiresBookerEmailVerification
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
expect(fetchedEventType.color).toEqual(eventType.color);
});
it(`/GET/event-types by username and eventSlug`, async () => {
@@ -421,7 +810,19 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
expect(fetchedEventType.bookerLayouts).toEqual(eventType.bookerLayouts);
expect(fetchedEventType.confirmationPolicy).toEqual(eventType.confirmationPolicy);
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
expect(fetchedEventType.customName).toEqual(eventType.customName);
expect(fetchedEventType.requiresBookerEmailVerification).toEqual(
eventType.requiresBookerEmailVerification
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
expect(fetchedEventType.color).toEqual(eventType.color);
});
it(`/GET/:id not existing`, async () => {
@@ -476,6 +877,16 @@ describe("Event types Endpoints", () => {
let legacyEventTypeId1: number;
let legacyEventTypeId2: number;
const expectedReturnSystemFields = [
{ isDefault: true, required: true, slug: "name", type: "name" },
{ isDefault: true, required: true, slug: "email", type: "email" },
{ isDefault: true, type: "radioInput", slug: "location", required: false },
{ isDefault: true, required: true, slug: "title", type: "text" },
{ isDefault: true, required: false, slug: "notes", type: "textarea" },
{ isDefault: true, required: false, slug: "guests", type: "multiemail" },
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
];
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
@@ -545,7 +956,7 @@ describe("Event types Endpoints", () => {
.expect(400);
});
it("should return empty bookingFields if system fields are the only one in database", async () => {
it("should return system bookingFields stored in database", async () => {
const legacyEventTypeInput = {
title: "legacy event type",
description: "legacy event type description",
@@ -638,11 +1049,11 @@ describe("Event types Endpoints", () => {
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.bookingFields).toEqual([]);
expect(fetchedEventType.bookingFields).toEqual(expectedReturnSystemFields);
});
});
it("should return user created bookingFields among system fields in the database", async () => {
it("should return user created bookingFields with system fields", async () => {
const userDefinedBookingField = {
name: "team",
type: "textarea",
@@ -755,7 +1166,9 @@ describe("Event types Endpoints", () => {
const fetchedEventType = responseBody.data;
expect(fetchedEventType.bookingFields).toEqual([
...expectedReturnSystemFields,
{
isDefault: false,
type: userDefinedBookingField.type,
slug: userDefinedBookingField.name,
label: userDefinedBookingField.label,
@@ -3,7 +3,9 @@ import { DeleteEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2
import { GetEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/get-event-type.output";
import { GetEventTypesOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/get-event-types.output";
import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/outputs/update-event-type.output";
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
@@ -23,14 +25,15 @@ import {
HttpStatus,
Delete,
Query,
ParseIntPipe,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreateEventTypeInput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
GetEventTypesQuery_2024_06_14,
CreateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
@Controller({
@@ -38,28 +41,56 @@ import {
version: VERSION_2024_06_14_VALUE,
})
@UseGuards(PermissionsGuard)
@DocsTags("Event types")
@DocsTags("Event Types")
@ApiHeader({
name: "cal-api-version",
description: `Must be set to \`2024-06-14\``,
required: true,
})
export class EventTypesController_2024_06_14 {
constructor(private readonly eventTypesService: EventTypesService_2024_06_14) {}
constructor(
private readonly eventTypesService: EventTypesService_2024_06_14,
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
private readonly eventTypeResponseTransformPipe: EventTypeResponseTransformPipe
) {}
@Post("/")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@ApiOperation({ summary: "Create an event type" })
async createEventType(
@Body() body: CreateEventTypeInput_2024_06_14,
@GetUser() user: UserWithProfile
): Promise<CreateEventTypeOutput_2024_06_14> {
const eventType = await this.eventTypesService.createUserEventType(user, body);
const transformedBody = await this.inputEventTypesService.transformAndValidateCreateEventTypeInput(
user.id,
body
);
const eventType = await this.eventTypesService.createUserEventType(user, transformedBody);
return {
status: SUCCESS_STATUS,
data: eventType,
data: this.eventTypeResponseTransformPipe.transform(eventType),
};
}
@Get("/:eventTypeId")
@Permissions([EVENT_TYPE_READ])
@UseGuards(ApiAuthGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@ApiOperation({ summary: "Get an event type" })
async getEventTypeById(
@Param("eventTypeId") eventTypeId: string,
@GetUser() user: UserWithProfile
@@ -72,11 +103,12 @@ export class EventTypesController_2024_06_14 {
return {
status: SUCCESS_STATUS,
data: eventType,
data: this.eventTypeResponseTransformPipe.transform(eventType),
};
}
@Get("/")
@ApiOperation({ summary: "Get all event types" })
async getEventTypes(
@Query() queryParams: GetEventTypesQuery_2024_06_14
): Promise<GetEventTypesOutput_2024_06_14> {
@@ -84,30 +116,50 @@ export class EventTypesController_2024_06_14 {
return {
status: SUCCESS_STATUS,
data: eventTypes,
data: this.eventTypeResponseTransformPipe.transform(eventTypes),
};
}
@Patch("/:eventTypeId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Update an event type" })
async updateEventType(
@Param("eventTypeId") eventTypeId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: UpdateEventTypeInput_2024_06_14,
@GetUser() user: UserWithProfile
): Promise<UpdateEventTypeOutput_2024_06_14> {
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
const transformedBody = await this.inputEventTypesService.transformAndValidateUpdateEventTypeInput(
body,
user.id,
eventTypeId
);
const eventType = await this.eventTypesService.updateEventType(eventTypeId, transformedBody, user);
return {
status: SUCCESS_STATUS,
data: eventType,
data: this.eventTypeResponseTransformPipe.transform(eventType),
};
}
@Delete("/:eventTypeId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@ApiOperation({ summary: "Delete an event type" })
async deleteEventType(
@Param("eventTypeId") eventTypeId: number,
@GetUser("id") userId: number
@@ -1,9 +1,14 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { EventTypesController_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/controllers/event-types.controller";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
@@ -22,6 +27,11 @@ import { Module } from "@nestjs/common";
UsersRepository,
UsersService,
SchedulesRepository_2024_06_11,
EventTypeResponseTransformPipe,
CalendarsService,
CredentialsRepository,
AppsRepository,
CalendarsRepository,
],
controllers: [EventTypesController_2024_06_14],
exports: [
@@ -1,38 +1,9 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import {
getEventTypeById,
transformApiEventTypeBookingFields,
transformApiEventTypeLocations,
} from "@calcom/platform-libraries";
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
import type { PrismaClient } from "@calcom/prisma";
type InputEventTransformed = Omit<
CreateEventTypeInput_2024_06_14,
| "lengthInMinutes"
| "locations"
| "bookingFields"
| "bookingLimitsCount"
| "onlyShowFirstAvailableSlot"
| "bookingLimitsDuration"
| "offsetStart"
| "periodType"
| "periodDays"
| "periodCountCalendarDays"
| "periodStartDate"
| "periodEndDate"
| "recurrence"
> & {
length: number;
slug: string;
locations?: ReturnType<typeof transformApiEventTypeLocations>;
bookingFields?: ReturnType<typeof transformApiEventTypeBookingFields>;
};
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
@Injectable()
export class EventTypesRepository_2024_06_14 {
@@ -42,7 +13,10 @@ export class EventTypesRepository_2024_06_14 {
private usersService: UsersService
) {}
async createUserEventType(userId: number, body: InputEventTransformed) {
async createUserEventType(
userId: number,
body: Omit<InputEventTransformed_2024_06_14, "destinationCalendar">
) {
return this.dbWrite.prisma.eventType.create({
data: {
...body,
@@ -57,7 +31,19 @@ export class EventTypesRepository_2024_06_14 {
async getEventTypeWithSeats(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
select: { users: { select: { id: true } }, seatsPerTimeSlot: true },
select: {
users: { select: { id: true } },
seatsPerTimeSlot: true,
locations: true,
requiresConfirmation: true,
},
});
}
async getEventTypeWithMetaData(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
select: { metadata: true },
});
}
@@ -67,7 +53,7 @@ export class EventTypesRepository_2024_06_14 {
id: eventTypeId,
userId,
},
include: { users: true, schedule: true },
include: { users: true, schedule: true, destinationCalendar: true },
});
}
@@ -76,29 +62,21 @@ export class EventTypesRepository_2024_06_14 {
where: {
userId,
},
include: { users: true, schedule: true },
});
}
async getUserEventTypeForAtom(
user: UserWithProfile,
isUserOrganizationAdmin: boolean,
eventTypeId: number
) {
return await getEventTypeById({
currentOrganizationId: this.usersService.getUserMainOrgId(user),
eventTypeId,
userId: user.id,
prisma: this.dbRead.prisma as unknown as PrismaClient,
isUserOrganizationAdmin,
isTrpcCall: true,
include: { users: true, schedule: true, destinationCalendar: true },
});
}
async getEventTypeById(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
include: { users: true, schedule: true },
include: { users: true, schedule: true, destinationCalendar: true },
});
}
async getEventTypeByIdWithOwnerAndTeam(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
include: { owner: true, team: true },
});
}
@@ -110,7 +88,7 @@ export class EventTypesRepository_2024_06_14 {
slug: slug,
},
},
include: { users: true, schedule: true },
include: { users: true, schedule: true, destinationCalendar: true },
});
}
@@ -0,0 +1,39 @@
import { Injectable, PipeTransform } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
import {
DatabaseEventType,
OutputEventTypesService_2024_06_14,
} from "../services/output-event-types.service";
type EventTypeResponse = DatabaseEventType & { ownerId: number };
@Injectable()
export class EventTypeResponseTransformPipe implements PipeTransform {
constructor(private readonly outputEventTypesService: OutputEventTypesService_2024_06_14) {}
private transformEventType(eventType: EventTypeResponse): EventTypeOutput_2024_06_14 {
return plainToClass(
EventTypeOutput_2024_06_14,
this.outputEventTypesService.getResponseEventType(eventType.ownerId, eventType),
{ strategy: "exposeAll" }
);
}
// Implementing function overloading to ensure correct return types based on input type:
transform(value: EventTypeResponse[]): EventTypeOutput_2024_06_14[];
transform(value: EventTypeResponse): EventTypeOutput_2024_06_14;
transform(
value: EventTypeResponse | EventTypeResponse[]
): EventTypeOutput_2024_06_14 | EventTypeOutput_2024_06_14[] {
if (Array.isArray(value)) {
return value.map((item) => this.transformEventType(item));
} else {
return this.transformEventType(value);
}
}
}
@@ -1,7 +1,5 @@
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/constants/constants";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
@@ -13,20 +11,13 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
import { dynamicEvent } from "@calcom/platform-libraries";
import {
CreateEventTypeInput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
GetEventTypesQuery_2024_06_14,
EventTypeOutput_2024_06_14,
} from "@calcom/platform-types";
import { GetEventTypesQuery_2024_06_14, InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
import { EventType } from "@calcom/prisma/client";
@Injectable()
export class EventTypesService_2024_06_14 {
constructor(
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
private readonly membershipsRepository: MembershipsRepository,
private readonly usersRepository: UsersRepository,
private readonly usersService: UsersService,
@@ -35,22 +26,13 @@ export class EventTypesService_2024_06_14 {
private readonly schedulesRepository: SchedulesRepository_2024_06_11
) {}
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput_2024_06_14) {
async createUserEventType(user: UserWithProfile, body: InputEventTransformed_2024_06_14) {
await this.checkCanCreateEventType(user.id, body);
const eventTypeUser = await this.getUserToCreateEvent(user);
const {
bookingLimits,
durationLimits,
periodType = undefined,
periodDays = undefined,
periodCountCalendarDays = undefined,
periodStartDate = undefined,
periodEndDate = undefined,
recurrence = undefined,
...bodyTransformed
} = this.inputEventTypesService.transformInputCreateEventType(body);
const { destinationCalendar: _destinationCalendar, ...rest } = body;
const { eventType: eventTypeCreated } = await createEventType({
input: bodyTransformed,
input: rest,
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -62,15 +44,7 @@ export class EventTypesService_2024_06_14 {
await updateEventType({
input: {
id: eventTypeCreated.id,
bookingLimits,
durationLimits,
periodType,
periodDays,
periodCountCalendarDays,
periodStartDate,
periodEndDate,
recurrence,
...bodyTransformed,
...body,
},
ctx: {
user: eventTypeUser,
@@ -86,10 +60,13 @@ export class EventTypesService_2024_06_14 {
throw new NotFoundException(`Event type with id ${eventTypeCreated.id} not found`);
}
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
return {
ownerId: user.id,
...eventType,
};
}
async checkCanCreateEventType(userId: number, body: CreateEventTypeInput_2024_06_14) {
async checkCanCreateEventType(userId: number, body: InputEventTransformed_2024_06_14) {
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
if (existsWithSlug) {
throw new BadRequestException("User already has an event type with this slug.");
@@ -109,7 +86,10 @@ export class EventTypesService_2024_06_14 {
return null;
}
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
return {
ownerId: user.id,
...eventType,
};
}
async getEventTypesByUsername(username: string) {
@@ -117,7 +97,7 @@ export class EventTypesService_2024_06_14 {
if (!user) {
return [];
}
return this.getUserEventTypes(user.id);
return await this.getUserEventTypes(user.id);
}
async getUserToCreateEvent(user: UserWithProfile) {
@@ -147,38 +127,19 @@ export class EventTypesService_2024_06_14 {
}
this.checkUserOwnsEventType(userId, eventType);
return this.outputEventTypesService.getResponseEventType(userId, eventType);
return {
ownerId: userId,
...eventType,
};
}
async getUserEventTypes(userId: number) {
const eventTypes = await this.eventTypesRepository.getUserEventTypes(userId);
const eventTypePromises = eventTypes.map(async (eventType) => {
return await this.outputEventTypesService.getResponseEventType(userId, eventType);
return eventTypes.map((eventType) => {
return { ownerId: userId, ...eventType };
});
return await Promise.all(eventTypePromises);
}
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
const organizationId = this.usersService.getUserMainOrgId(user);
const isUserOrganizationAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
const eventType = await this.eventTypesRepository.getUserEventTypeForAtom(
user,
isUserOrganizationAdmin,
eventTypeId
);
if (!eventType) {
return null;
}
this.checkUserOwnsEventType(user.id, eventType.eventType);
return eventType;
}
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
@@ -190,7 +151,7 @@ export class EventTypesService_2024_06_14 {
return await getEventTypesPublic(user.id);
}
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14): Promise<EventTypeOutput_2024_06_14[]> {
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14) {
const { username, eventSlug, usernames } = queryParams;
if (username && eventSlug) {
@@ -218,12 +179,12 @@ export class EventTypesService_2024_06_14 {
usersFiltered.push(user);
}
}
return this.outputEventTypesService.getResponseEventType(0, {
return {
ownerId: 0,
...dynamicEvent,
users: usersFiltered,
isInstantEvent: false,
});
};
}
async createUserDefaultEventTypes(userId: number) {
@@ -239,12 +200,12 @@ export class EventTypesService_2024_06_14 {
return defaultEventTypes;
}
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_06_14, user: UserWithProfile) {
async updateEventType(eventTypeId: number, body: InputEventTransformed_2024_06_14, user: UserWithProfile) {
await this.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
const eventTypeUser = await this.getUserToUpdateEvent(user);
const bodyTransformed = this.inputEventTypesService.transformInputUpdateEventType(body);
await updateEventType({
input: { id: eventTypeId, ...bodyTransformed },
input: { id: eventTypeId, ...body },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -259,7 +220,10 @@ export class EventTypesService_2024_06_14 {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
return {
ownerId: user.id,
...eventType,
};
}
async checkCanUpdateEventType(userId: number, eventTypeId: number, scheduleId: number | undefined) {
@@ -1,16 +1,97 @@
import { Injectable } from "@nestjs/common";
import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException } from "@nestjs/common";
import {
transformApiEventTypeBookingFields,
transformApiEventTypeLocations,
transformApiEventTypeIntervalLimits,
transformApiEventTypeFutureBookingLimits,
transformApiEventTypeRecurrence,
transformBookingFieldsApiToInternal,
transformLocationsApiToInternal,
transformIntervalLimitsApiToInternal,
transformFutureBookingLimitsApiToInternal,
transformRecurrenceApiToInternal,
systemBeforeFieldName,
systemBeforeFieldEmail,
systemAfterFieldRescheduleReason,
EventTypeMetaDataSchema,
systemBeforeFieldLocation,
transformBookerLayoutsApiToInternal,
transformConfirmationPolicyApiToInternal,
transformEventColorsApiToInternal,
validateCustomEventName,
transformSeatsApiToInternal,
} from "@calcom/platform-libraries";
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
import {
CreateEventTypeInput_2024_06_14,
DestinationCalendar_2024_06_14,
InputEventTransformed_2024_06_14,
UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { OutputEventTypesService_2024_06_14 } from "./output-event-types.service";
interface ValidationContext {
eventTypeId?: number;
seatsPerTimeSlot?: number | null;
locations?: InputEventTransformed_2024_06_14["locations"];
requiresConfirmation?: boolean;
eventName?: string;
}
@Injectable()
export class InputEventTypesService_2024_06_14 {
constructor(
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
private readonly calendarsService: CalendarsService
) {}
async transformAndValidateCreateEventTypeInput(
userId: UserWithProfile["id"],
inputEventType: CreateEventTypeInput_2024_06_14
) {
const transformedBody = this.transformInputCreateEventType(inputEventType);
await this.validateEventTypeInputs({
seatsPerTimeSlot: transformedBody.seatsPerTimeSlot,
locations: transformedBody.locations,
requiresConfirmation: transformedBody.requiresConfirmation,
eventName: transformedBody.eventName,
});
transformedBody.destinationCalendar &&
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
transformedBody.useEventTypeDestinationCalendarEmail &&
(await this.validateInputUseDestinationCalendarEmail(userId));
return transformedBody;
}
async transformAndValidateUpdateEventTypeInput(
inputEventType: UpdateEventTypeInput_2024_06_14,
userId: UserWithProfile["id"],
eventTypeId: number
) {
const transformedBody = await this.transformInputUpdateEventType(inputEventType, eventTypeId);
await this.validateEventTypeInputs({
eventTypeId: eventTypeId,
seatsPerTimeSlot: transformedBody.seatsPerTimeSlot,
locations: transformedBody.locations,
requiresConfirmation: transformedBody.requiresConfirmation,
eventName: transformedBody.eventName,
});
transformedBody.destinationCalendar &&
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
transformedBody.useEventTypeDestinationCalendarEmail &&
(await this.validateInputUseDestinationCalendarEmail(userId));
return transformedBody;
}
transformInputCreateEventType(inputEventType: CreateEventTypeInput_2024_06_14) {
const defaultLocations: CreateEventTypeInput_2024_06_14["locations"] = [
{
@@ -26,74 +107,271 @@ export class InputEventTypesService_2024_06_14 {
bookingLimitsCount,
bookingLimitsDuration,
bookingWindow,
bookerLayouts,
confirmationPolicy,
color,
recurrence,
seats,
customName,
useDestinationCalendarEmail,
...rest
} = inputEventType;
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
const hasMultipleLocations = (locations || defaultLocations).length > 1;
const eventType = {
...rest,
length: lengthInMinutes,
locations: this.transformInputLocations(locations || defaultLocations),
bookingFields: this.transformInputBookingFields(bookingFields),
bookingFields: this.transformInputBookingFields(bookingFields, hasMultipleLocations),
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
durationLimits: bookingLimitsDuration
? this.transformInputIntervalLimits(bookingLimitsDuration)
: undefined,
...this.transformInputBookingWindow(bookingWindow),
metadata: {
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
},
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
requiresConfirmationWillBlockSlot:
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
eventTypeColor: this.transformInputEventTypeColor(color),
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
...this.transformInputSeatOptions(seats),
eventName: customName,
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
};
return eventType;
}
transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14) {
async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) {
const {
lengthInMinutes,
locations,
bookingFields,
scheduleId,
bookingLimitsCount,
bookingLimitsDuration,
bookingWindow,
bookerLayouts,
confirmationPolicy,
color,
recurrence,
seats,
customName,
useDestinationCalendarEmail,
...rest
} = inputEventType;
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
const metadataTransformed = !!eventTypeDb?.metadata
? EventTypeMetaDataSchema.parse(eventTypeDb.metadata)
: {};
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
const hasMultipleLocations = !!(locations && locations?.length > 1);
const eventType = {
...rest,
length: lengthInMinutes,
locations: locations ? this.transformInputLocations(locations) : undefined,
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
schedule: scheduleId,
bookingFields: bookingFields
? this.transformInputBookingFields(bookingFields, hasMultipleLocations)
: undefined,
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
durationLimits: bookingLimitsDuration
? this.transformInputIntervalLimits(bookingLimitsDuration)
: undefined,
...this.transformInputBookingWindow(bookingWindow),
metadata: {
...metadataTransformed,
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
},
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
requiresConfirmationWillBlockSlot:
confirmationPolicyTransformed?.requiresConfirmationWillBlockSlot ?? undefined,
eventTypeColor: this.transformInputEventTypeColor(color),
...this.transformInputSeatOptions(seats),
eventName: customName,
useEventTypeDestinationCalendarEmail: useDestinationCalendarEmail,
};
return eventType;
}
transformInputLocations(inputLocations: CreateEventTypeInput_2024_06_14["locations"]) {
return transformApiEventTypeLocations(inputLocations);
return transformLocationsApiToInternal(inputLocations);
}
transformInputBookingFields(inputBookingFields: CreateEventTypeInput_2024_06_14["bookingFields"]) {
return transformApiEventTypeBookingFields(inputBookingFields);
transformInputBookingFields(
inputBookingFields: CreateEventTypeInput_2024_06_14["bookingFields"],
hasMultipleLocations: boolean
) {
const defaultFieldsBefore = [systemBeforeFieldName, systemBeforeFieldEmail];
// note(Lauris): if event type has multiple locations then a radio button booking field has to be displayed to allow booker to pick location
if (hasMultipleLocations) {
defaultFieldsBefore.push(systemBeforeFieldLocation);
}
const customFields = transformBookingFieldsApiToInternal(inputBookingFields);
const defaultFieldsAfter = [systemAfterFieldRescheduleReason];
return [...defaultFieldsBefore, ...customFields, ...defaultFieldsAfter];
}
transformInputIntervalLimits(inputBookingFields: CreateEventTypeInput_2024_06_14["bookingLimitsCount"]) {
return transformApiEventTypeIntervalLimits(inputBookingFields);
return transformIntervalLimitsApiToInternal(inputBookingFields);
}
transformInputBookingWindow(inputBookingWindow: CreateEventTypeInput_2024_06_14["bookingWindow"]) {
const res = transformApiEventTypeFutureBookingLimits(inputBookingWindow);
const res = transformFutureBookingLimitsApiToInternal(inputBookingWindow);
return !!res ? res : {};
}
transformInputBookerLayouts(inputBookerLayouts: CreateEventTypeInput_2024_06_14["bookerLayouts"]) {
return transformBookerLayoutsApiToInternal(inputBookerLayouts);
}
transformInputConfirmationPolicy(
requiresConfirmation: CreateEventTypeInput_2024_06_14["confirmationPolicy"]
) {
return transformConfirmationPolicyApiToInternal(requiresConfirmation);
}
transformInputRecurrignEvent(recurrence: CreateEventTypeInput_2024_06_14["recurrence"]) {
return transformApiEventTypeRecurrence(recurrence);
if (!recurrence || recurrence.disabled) {
return undefined;
}
return transformRecurrenceApiToInternal(recurrence);
}
transformInputEventTypeColor(color: CreateEventTypeInput_2024_06_14["color"]) {
return transformEventColorsApiToInternal(color);
}
transformInputSeatOptions(seats: CreateEventTypeInput_2024_06_14["seats"]) {
return transformSeatsApiToInternal(seats);
}
async validateEventTypeInputs({
eventTypeId,
seatsPerTimeSlot,
locations,
requiresConfirmation,
eventName,
}: ValidationContext) {
let seatsPerTimeSlotDb: number | null = null;
let locationsDb: ReturnType<typeof this.transformInputLocations> = [];
let requiresConfirmationDb = false;
if (eventTypeId != null) {
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithSeats(eventTypeId);
seatsPerTimeSlotDb = eventTypeDb?.seatsPerTimeSlot ?? null;
locationsDb = this.outputEventTypesService.transformLocations(eventTypeDb?.locations) ?? [];
requiresConfirmationDb = eventTypeDb?.requiresConfirmation ?? false;
}
const seatsPerTimeSlotFinal = seatsPerTimeSlot !== undefined ? seatsPerTimeSlot : seatsPerTimeSlotDb;
const seatsEnabledFinal = seatsPerTimeSlotFinal != null && seatsPerTimeSlotFinal > 0;
const locationsFinal = locations !== undefined ? locations : locationsDb;
const requiresConfirmationFinal =
requiresConfirmation !== undefined ? requiresConfirmation : requiresConfirmationDb;
this.validateSeatsSingleLocationRule(seatsEnabledFinal, locationsFinal);
this.validateSeatsRequiresConfirmationFalseRule(seatsEnabledFinal, requiresConfirmationFinal);
this.validateMultipleLocationsSeatsDisabledRule(locationsFinal, seatsEnabledFinal);
this.validateRequiresConfirmationSeatsDisabledRule(requiresConfirmationFinal, seatsEnabledFinal);
if (eventName) {
await this.validateCustomEventNameInput(eventName);
}
}
validateSeatsSingleLocationRule(
seatsEnabled: boolean,
locations: ReturnType<typeof this.transformInputLocations>
) {
if (seatsEnabled && locations.length > 1) {
throw new BadRequestException(
"Seats Validation failed: Seats are enabled but more than one location provided."
);
}
}
validateSeatsRequiresConfirmationFalseRule(seatsEnabled: boolean, requiresConfirmation: boolean) {
if (seatsEnabled && requiresConfirmation) {
throw new BadRequestException(
"Seats Validation failed: Seats are enabled but requiresConfirmation is true."
);
}
}
validateMultipleLocationsSeatsDisabledRule(
locations: ReturnType<typeof this.transformInputLocations>,
seatsEnabled: boolean
) {
if (locations.length > 1 && seatsEnabled) {
throw new BadRequestException("Locations Validation failed: Multiple locations but seats are enabled.");
}
}
validateRequiresConfirmationSeatsDisabledRule(requiresConfirmation: boolean, seatsEnabled: boolean) {
if (requiresConfirmation && seatsEnabled) {
throw new BadRequestException(
"RequiresConfirmation Validation failed: Seats are enabled but requiresConfirmation is true."
);
}
}
async validateCustomEventNameInput(value: string) {
const validationResult = validateCustomEventName(value);
if (validationResult !== true) {
throw new BadRequestException(`Invalid event name variables: ${validationResult}`);
}
return;
}
async validateInputDestinationCalendar(
userId: number,
destinationCalendar: DestinationCalendar_2024_06_14
) {
const calendars: ConnectedCalendarsData = await this.calendarsService.getCalendars(userId);
const allCals = calendars.connectedCalendars.map((cal) => cal.calendars ?? []).flat();
const matchedCalendar = allCals.find(
(cal) =>
cal.externalId === destinationCalendar.externalId &&
cal.integration === destinationCalendar.integration
);
if (!matchedCalendar) {
throw new BadRequestException("Invalid destinationCalendarId: Calendar does not exist");
}
if (matchedCalendar.readOnly) {
throw new BadRequestException("Invalid destinationCalendarId: Calendar does not have write permission");
}
return;
}
async validateInputUseDestinationCalendarEmail(userId: number) {
const calendars: ConnectedCalendarsData = await this.calendarsService.getCalendars(userId);
const allCals = calendars.connectedCalendars.map((cal) => cal.calendars ?? []).flat();
const primaryCalendar = allCals.find((cal) => cal.primary);
if (!primaryCalendar) {
throw new BadRequestException(
"Validation failed: A primary connected calendar is required to set useDestinationCalendarEmail"
);
}
return;
}
}
@@ -1,25 +1,39 @@
import { Injectable } from "@nestjs/common";
import type { EventType, User, Schedule } from "@prisma/client";
import type { EventType, User, Schedule, DestinationCalendar } from "@prisma/client";
import {
EventTypeMetaDataSchema,
userMetadata,
getResponseEventTypeLocations,
getResponseEventTypeBookingFields,
transformLocationsInternalToApi,
transformBookingFieldsInternalToApi,
parseRecurringEvent,
TransformedLocationsSchema,
BookingFieldsSchema,
SystemField,
UserField,
CustomField,
parseBookingLimit,
getResponseEventTypeIntervalLimits,
getResponseEventTypeFutureBookingLimits,
getResponseEventTypeRecurrence,
transformIntervalLimitsInternalToApi,
transformFutureBookingLimitsInternalToApi,
transformRecurrenceInternalToApi,
transformBookerLayoutsInternalToApi,
transformRequiresConfirmationInternalToApi,
transformEventTypeColorsInternalToApi,
parseEventTypeColor,
transformSeatsInternalToApi,
} from "@calcom/platform-libraries";
import { TransformFutureBookingsLimitSchema_2024_06_14 } from "@calcom/platform-types";
import {
TransformFutureBookingsLimitSchema_2024_06_14,
BookerLayoutsTransformedSchema,
NoticeThresholdTransformedSchema,
EventTypeOutput_2024_06_14,
} from "@calcom/platform-types";
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
type DatabaseEventType = EventType & EventTypeRelations;
type EventTypeRelations = {
users: User[];
schedule: Schedule | null;
destinationCalendar?: DestinationCalendar | null;
};
export type DatabaseEventType = EventType & EventTypeRelations;
type Input = Pick<
DatabaseEventType,
@@ -58,11 +72,20 @@ type Input = Pick<
| "periodCountCalendarDays"
| "periodStartDate"
| "periodEndDate"
| "requiresBookerEmailVerification"
| "hideCalendarNotes"
| "eventTypeColor"
| "seatsShowAttendees"
| "requiresConfirmationWillBlockSlot"
| "eventName"
| "destinationCalendar"
| "useEventTypeDestinationCalendarEmail"
| "hideCalendarEventDetails"
>;
@Injectable()
export class OutputEventTypesService_2024_06_14 {
async getResponseEventType(ownerId: number, databaseEventType: Input) {
getResponseEventType(ownerId: number, databaseEventType: Input): EventTypeOutput_2024_06_14 {
const {
id,
length,
@@ -74,8 +97,6 @@ export class OutputEventTypesService_2024_06_14 {
beforeEventBuffer,
afterEventBuffer,
slug,
schedulingType,
requiresConfirmation,
price,
currency,
lockTimeZoneToggleOnBookingPage,
@@ -87,17 +108,35 @@ export class OutputEventTypesService_2024_06_14 {
scheduleId,
onlyShowFirstAvailableSlot,
offsetStart,
requiresBookerEmailVerification,
hideCalendarNotes,
seatsShowAttendees,
useEventTypeDestinationCalendarEmail,
hideCalendarEventDetails,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
const customName = databaseEventType?.eventName ?? undefined;
const bookingFields = databaseEventType.bookingFields
? this.transformBookingFields(BookingFieldsSchema.parse(databaseEventType.bookingFields))
: [];
const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
const users = this.transformUsers(databaseEventType.users);
const users = this.transformUsers(databaseEventType.users || []);
const bookingLimitsCount = this.transformIntervalLimits(databaseEventType.bookingLimits);
const bookingLimitsDuration = this.transformIntervalLimits(databaseEventType.durationLimits);
const color = this.transformEventTypeColor(databaseEventType.eventTypeColor);
const bookerLayouts = this.transformBookerLayouts(
metadata.bookerLayouts as unknown as BookerLayoutsTransformedSchema
);
const confirmationPolicy = this.transformRequiresConfirmation(
!!databaseEventType.requiresConfirmation,
!!databaseEventType.requiresConfirmationWillBlockSlot,
metadata.requiresConfirmationThreshold as NoticeThresholdTransformedSchema
);
delete metadata["bookerLayouts"];
delete metadata["requiresConfirmationThreshold"];
const seats = this.transformSeats(seatsPerTimeSlot, seatsShowAttendees, seatsShowAvailabilityCount);
const bookingWindow = this.transformBookingWindow({
periodType: databaseEventType.periodType,
periodDays: databaseEventType.periodDays,
@@ -105,6 +144,7 @@ export class OutputEventTypesService_2024_06_14 {
periodStartDate: databaseEventType.periodStartDate,
periodEndDate: databaseEventType.periodEndDate,
} as TransformFutureBookingsLimitSchema_2024_06_14);
const destinationCalendar = this.transformDestinationCalendar(databaseEventType.destinationCalendar);
return {
id,
@@ -121,16 +161,12 @@ export class OutputEventTypesService_2024_06_14 {
minimumBookingNotice,
beforeEventBuffer,
afterEventBuffer,
schedulingType,
metadata,
requiresConfirmation,
price,
currency,
lockTimeZoneToggleOnBookingPage,
seatsPerTimeSlot,
forwardParamsSuccessRedirect,
successRedirectUrl,
seatsShowAvailabilityCount,
isInstantEvent,
users,
scheduleId,
@@ -139,24 +175,43 @@ export class OutputEventTypesService_2024_06_14 {
onlyShowFirstAvailableSlot,
offsetStart,
bookingWindow,
bookerLayouts,
confirmationPolicy,
requiresBookerEmailVerification,
hideCalendarNotes,
color,
seats,
customName,
destinationCalendar,
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
hideCalendarEventDetails,
};
}
transformLocations(locations: any) {
if (!locations) return [];
return getResponseEventTypeLocations(TransformedLocationsSchema.parse(locations));
return transformLocationsInternalToApi(TransformedLocationsSchema.parse(locations));
}
transformBookingFields(inputBookingFields: (SystemField | UserField)[] | null) {
if (!inputBookingFields) return [];
const userFields = inputBookingFields.filter((field) => field.editable === "user") as UserField[];
return getResponseEventTypeBookingFields(userFields);
transformDestinationCalendar(destinationCalendar?: DestinationCalendar | null) {
if (!destinationCalendar) return undefined;
return {
integration: destinationCalendar.integration,
externalId: destinationCalendar.externalId,
};
}
transformBookingFields(bookingFields: (SystemField | CustomField)[] | null) {
if (!bookingFields) return [];
return transformBookingFieldsInternalToApi(bookingFields);
}
transformRecurringEvent(recurringEvent: any) {
if (!recurringEvent) return null;
const recurringEventParsed = parseRecurringEvent(recurringEvent);
return getResponseEventTypeRecurrence(recurringEventParsed);
if (!recurringEventParsed) return null;
return transformRecurrenceInternalToApi(recurringEventParsed);
}
transformMetadata(metadata: any) {
@@ -182,10 +237,45 @@ export class OutputEventTypesService_2024_06_14 {
transformIntervalLimits(bookingLimits: any) {
const bookingLimitsParsed = parseBookingLimit(bookingLimits);
return getResponseEventTypeIntervalLimits(bookingLimitsParsed);
return transformIntervalLimitsInternalToApi(bookingLimitsParsed);
}
transformBookingWindow(bookingLimits: TransformFutureBookingsLimitSchema_2024_06_14) {
return getResponseEventTypeFutureBookingLimits(bookingLimits);
return transformFutureBookingLimitsInternalToApi(bookingLimits);
}
transformBookerLayouts(bookerLayouts: BookerLayoutsTransformedSchema) {
if (!bookerLayouts) return undefined;
return transformBookerLayoutsInternalToApi(bookerLayouts);
}
transformRequiresConfirmation(
requiresConfirmation: boolean,
requiresConfirmationWillBlockSlot: boolean,
requiresConfirmationThreshold?: NoticeThresholdTransformedSchema
) {
return transformRequiresConfirmationInternalToApi(
requiresConfirmation,
requiresConfirmationWillBlockSlot,
requiresConfirmationThreshold
);
}
transformEventTypeColor(eventTypeColor: any) {
if (!eventTypeColor) return undefined;
const parsedeventTypeColor = parseEventTypeColor(eventTypeColor);
return transformEventTypeColorsInternalToApi(parsedeventTypeColor);
}
transformSeats(
seatsPerTimeSlot: number | null,
seatsShowAttendees: boolean | null,
seatsShowAvailabilityCount: boolean | null
) {
return transformSeatsInternalToApi({
seatsPerTimeSlot,
seatsShowAttendees: !!seatsShowAttendees,
seatsShowAvailabilityCount: !!seatsShowAvailabilityCount,
});
}
}
+5 -2
View File
@@ -26,7 +26,7 @@ import {
Headers,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { Prisma } from "@prisma/client";
import { Request } from "express";
import { google } from "googleapis";
@@ -44,7 +44,7 @@ const CALENDAR_SCOPES = [
path: "/v2/gcal",
version: API_VERSIONS_VALUES,
})
@DocsTags("Google Calendar")
@DocsTags("Platform / Google Calendar")
export class GcalController {
private readonly logger = new Logger("Platform Gcal Provider");
@@ -62,6 +62,7 @@ export class GcalController {
@Get("/oauth/auth-url")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Get auth URL" })
async redirect(
@Headers("Authorization") authorization: string,
@Req() req: Request
@@ -81,6 +82,7 @@ export class GcalController {
@Get("/oauth/save")
@Redirect(undefined, 301)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Connect a calendar" })
async save(@Query("state") state: string, @Query("code") code: string): Promise<GcalSaveRedirectOutput> {
const url = new URL(this.config.get("api.url") + "/calendars/google/save");
url.searchParams.append("code", code);
@@ -92,6 +94,7 @@ export class GcalController {
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
@Permissions([APPS_READ])
@ApiOperation({ summary: "Check a calendar connection status" })
async check(@GetUser("id") userId: number): Promise<GcalCheckOutput> {
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
+3 -1
View File
@@ -10,7 +10,7 @@ import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-us
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Patch, Body } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { userSchemaResponse } from "@calcom/platform-types";
@@ -30,6 +30,7 @@ export class MeController {
@Get("/")
@Permissions([PROFILE_READ])
@ApiOperation({ summary: "Get my profile" })
async getMe(@GetUser() user: UserWithProfile): Promise<GetMeOutput> {
const organization = this.usersService.getUserMainProfile(user)?.organization;
const me = userSchemaResponse.parse(
@@ -52,6 +53,7 @@ export class MeController {
@Patch("/")
@Permissions([PROFILE_WRITE])
@ApiOperation({ summary: "Update my profile" })
async updateMe(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: UpdateManagedUserInput
@@ -1,4 +1,5 @@
import { BookingsModule } from "@/ee/bookings/bookings.module";
import { BookingsModule_2024_04_15 } from "@/ee/bookings/2024-04-15/bookings.module";
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
import { CalendarsModule } from "@/ee/calendars/calendars.module";
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
@@ -21,7 +22,8 @@ import { Module } from "@nestjs/common";
EventTypesModule_2024_04_15,
EventTypesModule_2024_06_14,
CalendarsModule,
BookingsModule,
BookingsModule_2024_04_15,
BookingsModule_2024_08_13,
SlotsModule,
],
})
@@ -16,7 +16,7 @@ import {
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -24,12 +24,13 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
path: "/v2/provider",
version: API_VERSIONS_VALUES,
})
@DocsTags("Cal provider")
@DocsTags("Platform / Cal Provider")
export class CalProviderController {
constructor(private readonly oauthClientRepository: OAuthClientRepository) {}
@Get("/:clientId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get a provider" })
async verifyClientId(@Param("clientId") clientId: string): Promise<ProviderVerifyClientOutput> {
if (!clientId) {
throw new NotFoundException();
@@ -51,6 +52,7 @@ export class CalProviderController {
@Get("/:clientId/access-token")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Verify an access token" })
async verifyAccessToken(
@Param("clientId") clientId: string,
@GetUser() user: UserWithProfile
@@ -23,8 +23,7 @@ import {
Patch,
UseGuards,
} from "@nestjs/common";
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
import { Throttle } from "@nestjs/throttler";
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
@@ -36,7 +35,7 @@ import { CreateScheduleInput_2024_04_15 } from "../inputs/create-schedule.input"
version: VERSION_2024_04_15_VALUE,
})
@UseGuards(ApiAuthGuard, PermissionsGuard)
@DocsTags("Schedules")
@DocsExcludeController(true)
export class SchedulesController_2024_04_15 {
constructor(private readonly schedulesService: SchedulesService_2024_04_15) {}
@@ -57,11 +56,6 @@ export class SchedulesController_2024_04_15 {
@Get("/default")
@Permissions([SCHEDULE_READ])
@ApiResponse({
status: 200,
description: "Returns the default schedule",
type: GetDefaultScheduleOutput_2024_04_15,
})
async getDefaultSchedule(
@GetUser() user: UserWithProfile
): Promise<GetDefaultScheduleOutput_2024_04_15 | null> {
@@ -78,7 +72,6 @@ export class SchedulesController_2024_04_15 {
@Get("/:scheduleId")
@Permissions([SCHEDULE_READ])
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
async getSchedule(
@GetUser() user: UserWithProfile,
@Param("scheduleId") scheduleId: number
@@ -17,8 +17,7 @@ import {
Patch,
UseGuards,
} from "@nestjs/common";
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
import { Throttle } from "@nestjs/throttler";
import { ApiHeader, ApiOperation, ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
@@ -38,11 +37,40 @@ import {
})
@UseGuards(ApiAuthGuard, PermissionsGuard)
@DocsTags("Schedules")
@ApiHeader({
name: "cal-api-version",
description: `Must be set to \`2024-06-11\``,
required: true,
})
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
export class SchedulesController_2024_06_11 {
constructor(private readonly schedulesService: SchedulesService_2024_06_11) {}
@Post("/")
@Permissions([SCHEDULE_WRITE])
@ApiOperation({
summary: "Create a schedule",
description: `
Create a schedule for the authenticated user.
The point of creating schedules is for event types to be available at specific times.
The first goal of schedules is to have a default schedule. If you are platform customer and created managed users, then it is important to note that each managed user should have a default schedule.
1. If you passed \`timeZone\` when creating managed user, then the default schedule from Monday to Friday from 9AM to 5PM will be created with that timezone. The managed user can then change the default schedule via the \`AvailabilitySettings\` atom.
2. If you did not, then we assume you want the user to have this specific schedule right away. You should create a default schedule by specifying
\`"isDefault": true\` in the request body. Until the user has a default schedule the user can't be booked nor manage their schedule via the AvailabilitySettings atom.
The second goal of schedules is to create another schedule that event types can point to. This is useful for when an event is booked because availability is not checked against the default schedule but instead against that specific schedule.
After creating a non-default schedule, you can update an event type to point to that schedule via the PATCH \`event-types/{eventTypeId}\` endpoint.
When specifying start time and end time for each day use the 24 hour format e.g. 08:00, 15:00 etc.
`,
})
async createSchedule(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: CreateScheduleInput_2024_06_11
@@ -59,9 +87,12 @@ export class SchedulesController_2024_06_11 {
@Permissions([SCHEDULE_READ])
@ApiResponse({
status: 200,
description: "Returns the default schedule",
type: GetDefaultScheduleOutput_2024_06_11,
})
@ApiOperation({
summary: "Get default schedule",
description: "Get the default schedule of the authenticated user.",
})
async getDefaultSchedule(@GetUser() user: UserWithProfile): Promise<GetScheduleOutput_2024_06_11> {
const schedule = await this.schedulesService.getUserScheduleDefault(user.id);
@@ -73,7 +104,7 @@ export class SchedulesController_2024_06_11 {
@Get("/:scheduleId")
@Permissions([SCHEDULE_READ])
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
@ApiOperation({ summary: "Get a schedule" })
async getSchedule(
@GetUser() user: UserWithProfile,
@Param("scheduleId") scheduleId: number
@@ -88,6 +119,10 @@ export class SchedulesController_2024_06_11 {
@Get("/")
@Permissions([SCHEDULE_READ])
@ApiOperation({
summary: "Get all schedules",
description: "Get all schedules of the authenticated user.",
})
async getSchedules(@GetUser() user: UserWithProfile): Promise<GetSchedulesOutput_2024_06_11> {
const schedules = await this.schedulesService.getUserSchedules(user.id);
@@ -99,6 +134,7 @@ export class SchedulesController_2024_06_11 {
@Patch("/:scheduleId")
@Permissions([SCHEDULE_WRITE])
@ApiOperation({ summary: "Update a schedule" })
async updateSchedule(
@GetUser() user: UserWithProfile,
@Body() bodySchedule: UpdateScheduleInput_2024_06_11,
@@ -119,6 +155,7 @@ export class SchedulesController_2024_06_11 {
@Delete("/:scheduleId")
@HttpCode(HttpStatus.OK)
@Permissions([SCHEDULE_WRITE])
@ApiOperation({ summary: "Delete a schedule" })
async deleteSchedule(
@GetUser("id") userId: number,
@Param("scheduleId") scheduleId: number
+7
View File
@@ -19,6 +19,13 @@ export type Environment = {
CALCOM_LICENSE_KEY: string;
GET_LICENSE_KEY_URL: string;
API_KEY_PREFIX: string;
DOCS_URL: string;
RATE_LIMIT_DEFAULT_TTL_MS: number;
RATE_LIMIT_DEFAULT_LIMIT_API_KEY: number;
RATE_LIMIT_DEFAULT_LIMIT_OAUTH_CLIENT: number;
RATE_LIMIT_DEFAULT_LIMIT_ACCESS_TOKEN: number;
RATE_LIMIT_DEFAULT_LIMIT: number;
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number;
};
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
+3
View File
@@ -5,13 +5,16 @@ import {
VERSION_2024_04_15,
VERSION_2024_06_11,
VERSION_2024_06_14,
VERSION_2024_08_13,
} from "@calcom/platform-constants";
export const API_VERSIONS_VALUES: VersionValue = API_VERSIONS as unknown as VersionValue;
export const VERSION_2024_06_14_VALUE: VersionValue = VERSION_2024_06_14 as unknown as VersionValue;
export const VERSION_2024_06_11_VALUE: VersionValue = VERSION_2024_06_11 as unknown as VersionValue;
export const VERSION_2024_04_15_VALUE: VersionValue = VERSION_2024_04_15 as unknown as VersionValue;
export const VERSION_2024_08_13_VALUE: VersionValue = VERSION_2024_08_13 as unknown as VersionValue;
export { VERSION_2024_04_15 };
export { VERSION_2024_06_11 };
export { VERSION_2024_06_14 };
export { VERSION_2024_08_13 };
+233
View File
@@ -0,0 +1,233 @@
import { getEnv } from "@/env";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import {
ThrottlerGuard,
ThrottlerException,
ThrottlerRequest,
ThrottlerModuleOptions,
seconds,
} from "@nestjs/throttler";
import { Request, Response } from "express";
import { z } from "zod";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
const rateLimitSchema = z.object({
name: z.string(),
limit: z.number(),
ttl: z.number(),
blockDuration: z.number(),
});
type RateLimitType = z.infer<typeof rateLimitSchema>;
const rateLimitsSchema = z.array(rateLimitSchema);
const sixtySecondsMs = 60 * 1000;
@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
private logger = new Logger("CustomThrottlerGuard");
private defaultTttl = Number(getEnv("RATE_LIMIT_DEFAULT_TTL_MS", sixtySecondsMs));
private defaultLimitApiKey = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT_API_KEY", 120));
private defaultLimitOAuthClient = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT_OAUTH_CLIENT", 500));
private defaultLimitAccessToken = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT_ACCESS_TOKEN", 500));
private defaultLimit = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT", 120));
private defaultBlockDuration = Number(getEnv("RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS", sixtySecondsMs));
constructor(
options: ThrottlerModuleOptions,
@Inject(ThrottlerStorageRedisService) protected readonly storageService: ThrottlerStorageRedisService,
reflector: Reflector,
private readonly dbRead: PrismaReadService
) {
super(options, storageService, reflector);
this.storageService = storageService;
}
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
const { context } = requestProps;
const request = context.switchToHttp().getRequest<Request>();
const response = context.switchToHttp().getResponse<Response>();
const tracker = await this.getTracker(request);
this.logger.log(
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
"Authorization"
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${request.ip}"`
);
if (tracker.startsWith("api_key_")) {
return this.handleApiKeyRequest(tracker, response);
} else {
return this.handleNonApiKeyRequest(tracker, response);
}
}
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
let allLimitsBlocked = true;
for (const rateLimit of rateLimits) {
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
if (!isBlocked) {
allLimitsBlocked = false;
}
}
if (allLimitsBlocked) {
throw new ThrottlerException("Too many requests. Please try again later.");
}
return true;
}
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimit = this.getDefaultRateLimit(tracker);
this.logger.log(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
${JSON.stringify(rateLimit, null, 2)}
`);
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
if (isBlocked) {
throw new ThrottlerException("Too many requests. Please try again later.");
}
return true;
}
private getDefaultRateLimit(tracker: string) {
return {
name: "default",
limit: this.getDefaultLimit(tracker),
ttl: this.getDefaultTtl(),
blockDuration: this.getDefaultBlockDuration(),
};
}
getDefaultLimit(tracker: string) {
if (tracker.startsWith("api_key_")) {
return this.defaultLimitApiKey;
} else if (tracker.startsWith("oauth_client_")) {
return this.defaultLimitOAuthClient;
} else if (tracker.startsWith("access_token_")) {
return this.defaultLimitAccessToken;
} else {
return this.defaultLimit;
}
}
getDefaultTtl() {
return this.defaultTttl;
}
getDefaultBlockDuration() {
return this.defaultBlockDuration;
}
private async getRateLimitsForApiKeyTracker(tracker: string) {
const cacheKey = `rate_limit:${tracker}`;
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
if (cachedRateLimits) {
this.logger.log(`Tracker "${tracker}" rate limits retrieved from redis cache:
${cachedRateLimits}
`);
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
}
const apiKey = tracker.replace("api_key_", "");
let rateLimits: RateLimitType[];
const apiKeyRecord = await this.dbRead.prisma.apiKey.findUnique({
where: { hashedKey: apiKey },
select: { id: true },
});
if (!apiKeyRecord) {
throw new UnauthorizedException("Invalid API Key");
}
rateLimits = await this.dbRead.prisma.rateLimit.findMany({
where: { apiKeyId: apiKeyRecord.id },
select: { name: true, limit: true, ttl: true, blockDuration: true },
});
if (rateLimits) {
this.logger.log(`Tracker "${tracker}" rate limits retrieved from database:
${JSON.stringify(rateLimits, null, 2)}`);
}
if (!rateLimits || rateLimits.length === 0) {
rateLimits = [this.getDefaultRateLimit(tracker)];
this.logger.log(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
${JSON.stringify(rateLimits, null, 2)}`);
}
await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600);
return rateLimits;
}
private async incrementRateLimit(tracker: string, rateLimit: RateLimitType, response: Response) {
const { name, limit, ttl, blockDuration } = rateLimit;
const key = `${tracker}:${limit}:${ttl}`;
const { isBlocked, totalHits, timeToExpire, timeToBlockExpire } = await this.storageService.increment(
key,
ttl,
limit,
blockDuration,
name
);
const nameFirstUpper = name.charAt(0).toUpperCase() + name.slice(1);
response.setHeader(`X-RateLimit-Limit-${nameFirstUpper}`, limit);
response.setHeader(
`X-RateLimit-Remaining-${nameFirstUpper}`,
timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)
);
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
this.logger.log(
`Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}`
);
this.logger.log(
`Tracker "${tracker}" rate limit "${name}" response headers:
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}`
);
return { isBlocked };
}
protected async getTracker(request: Request): Promise<string> {
const authorizationHeader = request.get("Authorization")?.replace("Bearer ", "");
if (authorizationHeader) {
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
return isApiKey(authorizationHeader, apiKeyPrefix)
? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}`
: `access_token_${authorizationHeader}`;
}
const oauthClientId = request.get(X_CAL_CLIENT_ID);
if (oauthClientId) {
return `oauth_client_${oauthClientId}`;
}
if (request.ip) {
return `ip_${request.ip}`;
}
this.logger.log(`no tracker found: ${request.url}`);
return "unknown";
}
}
+73 -6
View File
@@ -4,6 +4,12 @@ import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import {
PathItemObject,
PathsObject,
OperationObject,
TagObject,
} from "@nestjs/swagger/dist/interfaces/open-api-spec.interface";
import "dotenv/config";
import * as fs from "fs";
import { Server } from "http";
@@ -13,6 +19,8 @@ import { bootstrap } from "./app";
import { AppModule } from "./app.module";
import { loggerConfig } from "./lib/logger";
const HttpMethods: (keyof PathItemObject)[] = ["get", "post", "put", "delete", "patch", "options", "head"];
const run = async () => {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: WinstonModule.createLogger(loggerConfig()),
@@ -35,13 +43,69 @@ const run = async () => {
}
};
function customTagSort(a: string, b: string): number {
const platformPrefix = "Platform";
const orgsPrefix = "Orgs";
if (a.startsWith(platformPrefix) && !b.startsWith(platformPrefix)) {
return -1;
}
if (!a.startsWith(platformPrefix) && b.startsWith(platformPrefix)) {
return 1;
}
if (a.startsWith(orgsPrefix) && !b.startsWith(orgsPrefix)) {
return -1;
}
if (!a.startsWith(orgsPrefix) && b.startsWith(orgsPrefix)) {
return 1;
}
return a.localeCompare(b);
}
function isOperationObject(obj: any): obj is OperationObject {
return obj && typeof obj === "object" && "tags" in obj;
}
function groupAndSortPathsByFirstTag(paths: PathsObject): PathsObject {
const groupedPaths: { [key: string]: PathsObject } = {};
Object.keys(paths).forEach((pathKey) => {
const pathItem = paths[pathKey];
HttpMethods.forEach((method) => {
const operation = pathItem[method];
if (isOperationObject(operation) && operation.tags && operation.tags.length > 0) {
const firstTag = operation.tags[0];
if (!groupedPaths[firstTag]) {
groupedPaths[firstTag] = {};
}
groupedPaths[firstTag][pathKey] = pathItem;
}
});
});
const sortedTags = Object.keys(groupedPaths).sort(customTagSort);
const sortedPaths: PathsObject = {};
sortedTags.forEach((tag) => {
Object.assign(sortedPaths, groupedPaths[tag]);
});
return sortedPaths;
}
async function generateSwagger(app: NestExpressApplication<Server>) {
const logger = new Logger("App");
logger.log(`Generating Swagger documentation...\n`);
const config = new DocumentBuilder().setTitle("Cal.com v2 API").build();
const config = new DocumentBuilder().setTitle("Cal.com API v2").build();
const document = SwaggerModule.createDocument(app, config);
document.paths = groupAndSortPathsByFirstTag(document.paths);
const outputFile = "./swagger/documentation.json";
@@ -50,11 +114,14 @@ async function generateSwagger(app: NestExpressApplication<Server>) {
}
fs.writeFileSync(outputFile, JSON.stringify(document, null, 2), { encoding: "utf8" });
SwaggerModule.setup("docs", app, document, {
customCss: ".swagger-ui .topbar { display: none }",
});
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
if (!process.env.DOCS_URL) {
SwaggerModule.setup("docs", app, document, {
customCss: ".swagger-ui .topbar { display: none }",
});
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
}
}
run().catch((error: Error) => {
@@ -0,0 +1,12 @@
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response } from "express";
@Injectable()
export class RedirectsMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => void) {
if (process.env.DOCS_URL && (req.url.startsWith("/v2/docs") || req.url.startsWith("/docs"))) {
return res.redirect(process.env.DOCS_URL);
}
next();
}
}
@@ -1,7 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { App } from "@prisma/client";
import { App, Prisma } from "@prisma/client";
@Injectable()
export class AppsRepository {
@@ -10,4 +10,15 @@ export class AppsRepository {
async getAppBySlug(slug: string): Promise<App | null> {
return await this.dbRead.prisma.app.findUnique({ where: { slug } });
}
async createAppCredential(type: string, key: Prisma.InputJsonValue, userId: number, appId: string) {
return this.dbWrite.prisma.credential.create({
data: {
type: type,
key: key,
userId: userId,
appId: appId,
},
});
}
}
@@ -0,0 +1,24 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule],
providers: [
EventTypesAtomService,
MembershipsRepository,
UsersRepository,
UsersService,
SchedulesRepository_2024_06_11,
],
exports: [EventTypesAtomService],
controllers: [AtomsController],
})
export class AtomsModule {}
@@ -0,0 +1,83 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Get,
Param,
ParseIntPipe,
UseGuards,
Version,
VERSION_NEUTRAL,
Patch,
Body,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries";
import { ApiResponse } from "@calcom/platform-types";
/*
Endpoints used only by platform atoms, reusing code from other modules, data is already formatted and ready to be used by frontend atoms
these endpoints should not be recommended for use by third party and are excluded from docs
*/
@Controller({
path: "/v2/atoms",
version: API_VERSIONS_VALUES,
})
@DocsTags("Atoms - endpoints for atoms")
@DocsExcludeController(true)
export class AtomsController {
constructor(private readonly eventTypesService: EventTypesAtomService) {}
@Get("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getAtomEventType(
@GetUser() user: UserWithProfile,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<ApiResponse<unknown>> {
const eventType = await this.eventTypesService.getUserEventType(user, eventTypeId);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Patch("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async updateAtomEventType(
@GetUser() user: UserWithProfile,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: UpdateEventTypeReturn
): Promise<ApiResponse<UpdateEventTypeReturn>> {
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Patch("/organizations/:organizationId/teams/:teamId/event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async updateAtomTeamEventType(
@GetUser() user: UserWithProfile,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() body: UpdateEventTypeReturn
): Promise<ApiResponse<UpdateEventTypeReturn>> {
const eventType = await this.eventTypesService.updateTeamEventType(eventTypeId, body, user, teamId);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
}
@@ -0,0 +1,149 @@
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common";
import {
updateEventType,
TUpdateEventTypeInputSchema,
systemBeforeFieldEmail,
getEventTypeById,
} from "@calcom/platform-libraries";
import { PrismaClient } from "@calcom/prisma/client";
@Injectable()
export class EventTypesAtomService {
constructor(
private readonly membershipsRepository: MembershipsRepository,
private readonly usersService: UsersService,
private readonly dbWrite: PrismaWriteService,
private readonly dbRead: PrismaReadService,
private readonly eventTypeService: EventTypesService_2024_06_14,
private readonly teamEventTypeService: OrganizationsEventTypesService
) {}
async getUserEventType(user: UserWithProfile, eventTypeId: number) {
const organizationId = this.usersService.getUserMainOrgId(user);
const isUserOrganizationAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
const eventType = await getEventTypeById({
currentOrganizationId: this.usersService.getUserMainOrgId(user),
eventTypeId,
userId: user.id,
prisma: this.dbRead.prisma as unknown as PrismaClient,
isUserOrganizationAdmin,
isTrpcCall: true,
});
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
if (eventType?.team?.id) {
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
} else {
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
}
return eventType;
}
async updateTeamEventType(
eventTypeId: number,
body: TUpdateEventTypeInputSchema,
user: UserWithProfile,
teamId: number
) {
await this.checkCanUpdateTeamEventType(user.id, eventTypeId, teamId, body.scheduleId);
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
const bookingFields = [...(body.bookingFields || [])];
if (
!bookingFields.find((field) => field.type === "email") &&
!bookingFields.find((field) => field.type === "phone")
) {
bookingFields.push(systemBeforeFieldEmail);
}
const eventType = await updateEventType({
input: { id: eventTypeId, ...body, bookingFields },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
return eventType.eventType;
}
async updateEventType(eventTypeId: number, body: TUpdateEventTypeInputSchema, user: UserWithProfile) {
await this.eventTypeService.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
const bookingFields = [...(body.bookingFields || [])];
if (
!bookingFields.find((field) => field.type === "email") &&
!bookingFields.find((field) => field.type === "phone")
) {
bookingFields.push(systemBeforeFieldEmail);
}
const eventType = await updateEventType({
input: { id: eventTypeId, ...body, bookingFields },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
return eventType.eventType;
}
async checkCanUpdateTeamEventType(userId: number, eventTypeId: number, teamId: number, scheduleId: number) {
await this.checkTeamOwnsEventType(userId, eventTypeId, teamId);
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
await this.eventTypeService.checkUserOwnsSchedule(userId, scheduleId);
}
async checkTeamOwnsEventType(userId: number, eventTypeId: number, teamId: number) {
const membership = await this.dbRead.prisma.membership.findFirst({
where: {
userId,
teamId,
accepted: true,
OR: [{ role: "ADMIN" }, { role: "OWNER" }],
},
select: {
team: {
select: {
eventTypes: true,
},
},
},
});
if (!membership?.team?.eventTypes?.some((item) => item.id === eventTypeId)) {
throw new ForbiddenException(
`Access denied. Either the team with ID=${teamId} does not own the event type with ID=${eventTypeId}, or your MEMBER role does not have permission to access this resource.`
);
}
}
}
@@ -1,3 +1,4 @@
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext } from "@nestjs/common";
@@ -26,7 +27,8 @@ describe("PermissionsGuard", () => {
return null;
}
}),
})
}),
createMock<OAuthClientRepository>()
);
});
@@ -38,7 +40,7 @@ describe("PermissionsGuard", () => {
it("should return false", async () => {
const mockContext = createMockExecutionContext({});
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(0);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(0);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
@@ -51,7 +53,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -62,7 +64,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
oAuthClientPermissions |= SCHEDULE_READ;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -73,7 +75,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -83,7 +85,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= APPS_WRITE;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
@@ -94,7 +96,63 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
});
describe("when oauth id is provided", () => {
it("should return true for valid permissions", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return true for multiple valid permissions", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
oAuthClientPermissions |= SCHEDULE_READ;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return true for empty Permissions decorator", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should return false for invalid permissions", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= APPS_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
it("should return false for a missing permission", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]);
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});
@@ -1,11 +1,13 @@
import { isApiKey } from "@/lib/api-key";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Reflector } from "@nestjs/core";
import { getToken } from "next-auth/jwt";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { hasPermissions } from "@calcom/platform-utils";
@Injectable()
@@ -13,7 +15,8 @@ export class PermissionsGuard implements CanActivate {
constructor(
private reflector: Reflector,
private tokensRepository: TokensRepository,
private readonly config: ConfigService
private readonly config: ConfigService,
private readonly oAuthClientRepository: OAuthClientRepository
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -27,12 +30,13 @@ export class PermissionsGuard implements CanActivate {
const authString = request.get("Authorization")?.replace("Bearer ", "");
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
if (nextAuthToken) {
return true;
}
if (!authString) {
if (!authString && !oAuthClientId) {
return false;
}
@@ -41,7 +45,9 @@ export class PermissionsGuard implements CanActivate {
return true;
}
const oAuthClientPermissions = await this.getOAuthClientPermissions(authString);
const oAuthClientPermissions = authString
? await this.getOAuthClientPermissionsByAccessToken(authString)
: await this.getOAuthClientPermissionsById(oAuthClientId);
if (!oAuthClientPermissions) {
return false;
@@ -50,8 +56,13 @@ export class PermissionsGuard implements CanActivate {
return hasPermissions(oAuthClientPermissions, [...requiredPermissions]);
}
async getOAuthClientPermissions(accessToken: string) {
async getOAuthClientPermissionsByAccessToken(accessToken: string) {
const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken);
return oAuthClient?.permissions;
}
async getOAuthClientPermissionsById(id: string) {
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id);
return oAuthClient?.permissions;
}
}
@@ -19,7 +19,7 @@ export class BillingRepository {
billingStart: number,
billingEnd: number,
plan: PlatformPlan,
subscription?: string
subscriptionId?: string
) {
return this.dbWrite.prisma.platformBilling.update({
where: {
@@ -28,7 +28,7 @@ export class BillingRepository {
data: {
billingCycleStart: billingStart,
billingCycleEnd: billingEnd,
subscriptionId: subscription,
subscriptionId,
plan: plan.toString(),
},
});
@@ -7,9 +7,8 @@ import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subsc
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
import { BillingService } from "@/modules/billing/services/billing.service";
import { PlatformPlan } from "@/modules/billing/types";
import { StripeService } from "@/modules/stripe/stripe.service";
import {
BadRequestException,
Body,
Controller,
Get,
@@ -25,7 +24,6 @@ import {
import { ConfigService } from "@nestjs/config";
import { ApiExcludeController } from "@nestjs/swagger";
import { Request } from "express";
import { Stripe } from "stripe";
import { ApiResponse } from "@calcom/platform-types";
@@ -40,6 +38,7 @@ export class BillingController {
constructor(
private readonly billingService: BillingService,
public readonly stripeService: StripeService,
private readonly configService: ConfigService<AppConfig>
) {
this.stripeWhSecret = configService.get("stripe.webhookSecret", { infer: true }) ?? "";
@@ -69,25 +68,31 @@ export class BillingController {
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
const { status } = await this.billingService.getBillingData(teamId);
if (status === "valid") {
throw new BadRequestException("This team is already subscribed to a plan.");
}
const { action, url } = await this.billingService.createSubscriptionForTeam(teamId, input.plan);
if (action === "redirect") {
return {
status: "success",
data: {
action: "redirect",
url,
},
};
}
const customerId = await this.billingService.createTeamBilling(teamId);
const url = await this.billingService.redirectToSubscribeCheckout(teamId, input.plan, customerId);
return {
status: "success",
data: {
url,
},
};
}
@Post("/:teamId/upgrade")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@MembershipRoles(["OWNER", "ADMIN"])
async upgradeTeamBillingInStripe(
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
const url = await this.billingService.updateSubscriptionForTeam(teamId, input.plan);
return {
status: "success",
data: {
url,
},
};
}
@@ -103,33 +108,7 @@ export class BillingController {
this.stripeWhSecret
);
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
const subscription = event.data.object as Stripe.Subscription;
if (!subscription.metadata?.teamId) {
return {
status: "success",
};
}
const teamId = Number.parseInt(subscription.metadata.teamId);
const plan = subscription.metadata.plan;
if (!plan || !teamId) {
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return {
status: "success",
};
}
await this.billingService.setSubscriptionForTeam(
teamId,
subscription,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
);
return {
status: "success",
};
}
await this.billingService.createOrUpdateStripeSubscription(event);
return {
status: "success",
@@ -6,7 +6,13 @@ import { PlatformPlan } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { StripeService } from "@/modules/stripe/stripe.service";
import { InjectQueue } from "@nestjs/bull";
import { Injectable, InternalServerErrorException, Logger, OnModuleDestroy } from "@nestjs/common";
import {
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
OnModuleDestroy,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Queue } from "bull";
import { DateTime } from "luxon";
@@ -41,14 +47,11 @@ export class BillingService implements OnModuleDestroy {
}
}
async createSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
async createTeamBilling(teamId: number) {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
let brandNewBilling = false;
let customerId = teamWithBilling?.platformBilling?.customerId;
if (!teamWithBilling?.platformBilling) {
brandNewBilling = true;
customerId = await this.teamsRepository.createNewBillingRelation(teamId);
this.logger.log("Team had no Stripe Customer ID, created one for them.", {
@@ -57,43 +60,65 @@ export class BillingService implements OnModuleDestroy {
});
}
if (brandNewBilling || !teamWithBilling?.platformBilling?.subscriptionId) {
const { url } = await this.stripeService.stripe.checkout.sessions.create({
customer: customerId,
line_items: [
{
price: this.billingConfigService.get(plan)?.overage,
},
{
price: this.billingConfigService.get(plan)?.base,
quantity: 1,
},
],
success_url: `${this.webAppUrl}/settings/platform/`,
cancel_url: `${this.webAppUrl}/settings/platform/`,
mode: "subscription",
return customerId;
}
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
const { url } = await this.stripeService.stripe.checkout.sessions.create({
customer: customerId,
line_items: [
{
price: this.billingConfigService.get(plan)?.base,
quantity: 1,
},
{
price: this.billingConfigService.get(plan)?.overage,
},
],
success_url: `${this.webAppUrl}/settings/platform/`,
cancel_url: `${this.webAppUrl}/settings/platform/`,
mode: "subscription",
metadata: {
teamId: teamId.toString(),
plan: plan.toString(),
},
currency: "usd",
subscription_data: {
metadata: {
teamId: teamId.toString(),
plan: plan.toString(),
},
subscription_data: {
metadata: {
teamId: teamId.toString(),
plan: plan.toString(),
},
},
allow_promotion_codes: true,
});
},
allow_promotion_codes: true,
});
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
return { action: "redirect", url };
}
return { action: "none" };
return url;
}
async setSubscriptionForTeam(teamId: number, subscription: Stripe.Subscription, plan: PlatformPlan) {
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
const customerId = teamWithBilling?.platformBilling?.customerId;
const { url } = await this.stripeService.stripe.checkout.sessions.create({
customer: customerId,
success_url: `${this.webAppUrl}/settings/platform/`,
cancel_url: `${this.webAppUrl}/settings/platform/plans`,
mode: "setup",
metadata: {
teamId: teamId.toString(),
plan: plan.toString(),
},
currency: "usd",
});
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
return url;
}
async setSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
const billingCycleStart = DateTime.now().get("day");
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
@@ -102,10 +127,94 @@ export class BillingService implements OnModuleDestroy {
billingCycleStart,
billingCycleEnd,
plan,
subscription.id
subscriptionId
);
}
async createOrUpdateStripeSubscription(event: Stripe.Event) {
if (event.type === "checkout.session.completed") {
const subscription = event.data.object as Stripe.Checkout.Session;
if (!subscription.metadata?.teamId) {
return {
status: "success",
};
}
const teamId = Number.parseInt(subscription.metadata.teamId);
const plan = subscription.metadata.plan;
if (!plan || !teamId) {
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return {
status: "success",
};
}
if (subscription.mode === "subscription") {
await this.setSubscriptionForTeam(
teamId,
subscription.subscription as string,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
);
}
if (subscription.mode === "setup") {
await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan);
}
return {
status: "success",
};
}
}
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
if (!teamWithBilling?.platformBilling || !teamWithBilling?.platformBilling.subscriptionId) {
throw new NotFoundException("Team plan not found");
}
const existingUserSubscription = await this.stripeService.stripe.subscriptions.retrieve(
teamWithBilling?.platformBilling?.subscriptionId
);
const currentLicensedItem = existingUserSubscription.items.data.find(
(item) => item.price?.recurring?.usage_type === "licensed"
);
const currentOverageItem = existingUserSubscription.items.data.find(
(item) => item.price?.recurring?.usage_type === "metered"
);
if (!currentLicensedItem) {
throw new NotFoundException("There is no licensed item present in the subscription");
}
if (!currentOverageItem) {
throw new NotFoundException("There is no overage item present in the subscription");
}
await this.stripeService.stripe.subscriptions.update(teamWithBilling?.platformBilling?.subscriptionId, {
items: [
{
id: currentLicensedItem.id,
price: this.billingConfigService.get(plan)?.base,
},
{
id: currentOverageItem.id,
price: this.billingConfigService.get(plan)?.overage,
clear_usage: false,
},
],
billing_cycle_anchor: "now",
proration_behavior: "create_prorations",
});
await this.setSubscriptionForTeam(
teamId,
teamWithBilling?.platformBilling?.subscriptionId,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
);
}
/**
*
* Adds a job to the queue to increment usage of a stripe subscription.
@@ -0,0 +1,22 @@
import { ConferencingController } from "@/modules/conferencing/controllers/conferencing.controller";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [
ConferencingService,
ConferencingRepository,
GoogleMeetService,
CredentialsRepository,
UsersRepository,
],
exports: [],
controllers: [ConferencingController],
})
export class ConferencingModule {}

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